From c482215587607c181677d083459bee4437603a6b Mon Sep 17 00:00:00 2001 From: Sebastien Georget Date: Thu, 10 Sep 2026 14:55:03 +0200 Subject: [PATCH] Prompt to assign issue to self on et jira start Adds a "Assign to yourself?" confirmation prompt (defaulting to yes) to et jira start, right after the existing "move to In Progress?" prompt. Applies to both the interactive picker and et jira start KEY. Skips the prompt entirely if the issue is already assigned to the current Jira user. - et.jira: add PUT support to the request client, assign_issue(), and an assignee_account_id field on JiraIssue populated from fetch_active_issues/fetch_issue. - et.task: add _maybe_assign_to_self(), wired into create_task_from_jira and create_task_from_jira_key via a new confirm_assign callback. - et.cli: wire up the confirm_assign prompt in jira_start. - Update CHANGELOG.md and README.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 ++ README.md | 2 +- src/et/cli.py | 28 ++++++-- src/et/jira.py | 48 ++++++++++++-- src/et/task.py | 59 ++++++++++++++--- tests/test_cli.py | 121 +++++++++++++++++++++++++++++++--- tests/test_jira.py | 87 +++++++++++++++++++++++- tests/test_task.py | 160 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 477 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bcb676..38373e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to `et` are documented here. Format loosely follows ## [Unreleased] +### Added +- `et jira start`: now offers to assign the issue to yourself (defaulting + to yes) right after the existing "move to In Progress?" prompt, unless + it's already assigned to you. Applies to both the interactive picker and + the direct `et jira start KEY` form. + ## [1.0.0] - 2026-09-08 Built from `main`, including `feat/native-wayland` (PR #9). diff --git a/README.md b/README.md index a789c6c..3f646c0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ can link workspaces to Jira issues to log time against them. - **`et jira [start|create|log-time|complete|comment|status]`**: interact and align your workspaces with Jira from the command line: - `create`: wrapper to create Jira issue from the command line (can take GitHub PR as input) - - `start`: let you select and assigned task, create a workspace associated to it, and track the time you spend on it. + - `start`: let you select and assigned task, create a workspace associated to it, and track the time you spend on it (prompts to move it to "In Progress" and to assign it to yourself if it isn't already). - `log-time`: log your current progress (typically to do it on a daily-basis) - `complete`: log the time spent, and move the issue to Done. - `comment`: add a comment to the ticket. diff --git a/src/et/cli.py b/src/et/cli.py index a35d13c..211bd73 100644 --- a/src/et/cli.py +++ b/src/et/cli.py @@ -640,11 +640,12 @@ def jira_start( Lists your active Jira issues that aren't already linked to a workspace and lets you pick one (its summary becomes the workspace name/description and its key is linked). If the selected issue isn't - already "In Progress", offers to move it there. When every workspace is - already taken, `et jira start` asks whether to add one more (bumping - GNOME's workspace count by one). The terminal window `et jira start` - was run from is moved along to the new workspace, so it doesn't get - left behind. + already "In Progress", offers to move it there. If it isn't already + assigned to you, offers to assign it to yourself too. When every + workspace is already taken, `et jira start` asks whether to add one + more (bumping GNOME's workspace count by one). The terminal window + `et jira start` was run from is moved along to the new workspace, so + it doesn't get left behind. With KEY given, skips the picker and starts that specific issue directly (failing if it's already linked to a workspace). It follows @@ -668,6 +669,12 @@ def confirm_transition(issue: JiraIssue) -> bool: default=True, ) + def confirm_assign(issue: JiraIssue) -> bool: + return typer.confirm( + f"Assign {_key_display(issue.key)} to yourself?", + default=True, + ) + def confirm_grow(count: int) -> bool: return typer.confirm( f"All {count} workspaces are in use. Add another workspace?", default=True @@ -697,7 +704,12 @@ def warn(message: str) -> None: try: key_result = create_task_from_jira_key( - key, confirm_transition, select_sprint, confirm_grow, warn + key, + confirm_transition, + confirm_assign, + select_sprint, + confirm_grow, + warn, ) except (ConfigError, TaskError) as error: typer.echo(f"Error: {error}", err=True) @@ -729,7 +741,9 @@ def select_issue(issues: list[JiraIssue]) -> JiraIssue | None: return issues[selected - 1] try: - result = create_task_from_jira(select_issue, confirm_transition, confirm_grow) + result = create_task_from_jira( + select_issue, confirm_transition, confirm_assign, confirm_grow + ) except (ConfigError, TaskError) as error: typer.echo(f"Error: {error}", err=True) raise typer.Exit(code=1) from error diff --git a/src/et/jira.py b/src/et/jira.py index 9b9bece..baf6059 100644 --- a/src/et/jira.py +++ b/src/et/jira.py @@ -30,6 +30,7 @@ COMPONENTS_PATH_TEMPLATE = "rest/api/3/project/{key}/components" FIELD_PATH = "rest/api/3/field" ISSUE_PATH = "rest/api/3/issue" +ASSIGNEE_PATH_TEMPLATE = "rest/api/3/issue/{key}/assignee" BOARD_PATH = "rest/agile/1.0/board" BOARD_SPRINT_PATH_TEMPLATE = "rest/agile/1.0/board/{board_id}/sprint" AGILE_ISSUE_PATH_TEMPLATE = "rest/agile/1.0/issue/{key}" @@ -62,6 +63,7 @@ class JiraIssue: priority: str status: str = "" original_estimate_seconds: int | None = None + assignee_account_id: str | None = None @dataclass(frozen=True) @@ -118,7 +120,7 @@ def text_to_adf(text: str) -> dict[str, object]: def _request( jira_config: JiraConfig, - method: Literal["GET", "POST"], + method: Literal["GET", "POST", "PUT"], url: str, *, expected_statuses: tuple[int, ...], @@ -134,13 +136,20 @@ def _request( auth=(jira_config.email, jira_config.pat), timeout=30, ) - else: + elif method == "POST": response = requests.post( url, json=json, auth=(jira_config.email, jira_config.pat), timeout=30, ) + else: + response = requests.put( + url, + json=json, + auth=(jira_config.email, jira_config.pat), + timeout=30, + ) except requests.RequestException as exc: raise JiraError(f"could not reach Jira at {url}: {exc}") from exc @@ -493,11 +502,20 @@ def _parse_original_estimate_seconds(fields: dict[str, object]) -> int | None: return value +def _parse_assignee_account_id(fields: dict[str, object]) -> str | None: + """Return `fields.assignee.accountId`, or `None` if the issue is unassigned/malformed.""" + assignee = fields.get("assignee") + if not isinstance(assignee, dict): + return None + account_id = assignee.get("accountId") + return account_id if isinstance(account_id, str) and account_id else None + + def fetch_issue(jira_config: JiraConfig, issue_key: str) -> JiraIssue: """Return `issue_key`'s summary, priority, status, and original estimate as a `JiraIssue`. Calls Jira's - `GET /rest/api/3/issue/{key}?fields=summary,priority,status,timetracking` + `GET /rest/api/3/issue/{key}?fields=summary,priority,status,timetracking,assignee` endpoint. Used by `et jira start KEY` to look up an issue given directly by key, rather than picked from `fetch_active_issues`'s candidate list. Raises `JiraError` if the request cannot be made, Jira rejects it, or @@ -505,7 +523,7 @@ def fetch_issue(jira_config: JiraConfig, issue_key: str) -> JiraIssue: """ url = _jira_url(jira_config, f"{ISSUE_PATH}/{issue_key}") payload = _get_json( - jira_config, url, params={"fields": "summary,priority,status,timetracking"} + jira_config, url, params={"fields": "summary,priority,status,timetracking,assignee"} ) if not isinstance(payload, dict): raise JiraError(f"unexpected Jira API response from {url}: not a JSON object") @@ -532,6 +550,7 @@ def fetch_issue(jira_config: JiraConfig, issue_key: str) -> JiraIssue: priority=priority if isinstance(priority, str) else "", status=status, original_estimate_seconds=_parse_original_estimate_seconds(fields), + assignee_account_id=_parse_assignee_account_id(fields), ) @@ -576,7 +595,7 @@ def _fetch_issue_pages(jira_config: JiraConfig, url: str) -> list[object]: while True: params: dict[str, str] = { "jql": jira_config.jql, - "fields": "summary,priority,status,timetracking", + "fields": "summary,priority,status,timetracking,assignee", } if next_page_token is not None: params["nextPageToken"] = next_page_token @@ -679,6 +698,7 @@ def fetch_active_issues(jira_config: JiraConfig) -> list[JiraIssue]: priority=priority if isinstance(priority, str) else "", status=status if isinstance(status, str) else "", original_estimate_seconds=_parse_original_estimate_seconds(fields), + assignee_account_id=_parse_assignee_account_id(fields), ) ) @@ -698,6 +718,24 @@ def fetch_active_issues(jira_config: JiraConfig) -> list[JiraIssue]: return [issue for _, issue in indexed] +def assign_issue(jira_config: JiraConfig, issue_key: str, account_id: str) -> None: + """Assign `issue_key` to the Jira user identified by `account_id`. + + Calls Jira's `PUT /rest/api/3/issue/{key}/assignee` endpoint (which + returns 204 No Content on success). Raises `JiraError` if the request + cannot be made or Jira rejects it. + """ + url = _jira_url(jira_config, ASSIGNEE_PATH_TEMPLATE.format(key=issue_key)) + + _request( + jira_config, + "PUT", + url, + expected_statuses=(204,), + json={"accountId": account_id}, + ) + + def fetch_transitions(jira_config: JiraConfig, issue_key: str) -> list[JiraTransition]: """Fetch the workflow transitions currently available for `issue_key`. diff --git a/src/et/task.py b/src/et/task.py index 24d9877..6c21851 100644 --- a/src/et/task.py +++ b/src/et/task.py @@ -8,7 +8,8 @@ switches GNOME to it — moving the terminal window it's run from along with it — picking the slot's name/description/Jira link/original estimate from the user's active Jira issues (and offering to move the selected issue to -"In Progress" if it isn't already). `et jira complete` logs the active +"In Progress" if it isn't already, and to assign it to the current user if +it isn't already). `et jira complete` logs the active workspace's tracked time to Jira (reusing `et.jira_time.log_time_for_current_workspace`), then — each behind its own confirmation prompt — optionally deletes that workspace (reclaiming its @@ -46,6 +47,7 @@ JiraIssue, JiraSprint, add_issue_to_sprint, + assign_issue, create_comment, discover_board_id, fetch_active_issues, @@ -54,6 +56,7 @@ fetch_issue_sprint, fetch_issue_status, fetch_transitions, + search_user_account_id, transition_issue, ) from et.jira_ref import JIRA_REF_PREFIX, default_entry, jira_key_from_ref, truncate_summary @@ -458,9 +461,38 @@ def _maybe_transition_to_in_progress( _transition_to_status(jira_config, issue.key, IN_PROGRESS_STATUS, display="In Progress") +def _maybe_assign_to_self( + jira_config: JiraConfig, + issue: JiraIssue, + confirm_assign: Callable[[JiraIssue], bool] | None, +) -> None: + """Assign `issue` to the current Jira user if not already, and `confirm_assign` agrees. + + Shared by `create_task_from_jira` (interactive picker) and + `create_task_from_jira_key` (positional `KEY`): both offer the same + "not already assigned to you? assign it" step, right after the + In-Progress transition step and before the workspace is created. + + Looks up the current user's accountId via `search_user_account_id` + (using `jira_config.email`). If that lookup fails to find a matching + user, or the issue is already assigned to that accountId, the prompt is + skipped entirely without calling `confirm_assign`. + """ + if confirm_assign is None: + return + + account_id = search_user_account_id(jira_config, jira_config.email) + if account_id is None or issue.assignee_account_id == account_id: + return + + if confirm_assign(issue): + assign_issue(jira_config, issue.key, account_id) + + def create_task_from_jira( select_issue: Callable[[list[JiraIssue]], JiraIssue | None], confirm_transition: Callable[[JiraIssue], bool] | None = None, + confirm_assign: Callable[[JiraIssue], bool] | None = None, confirm_grow: Callable[[int], bool] = lambda count: False, ) -> TaskCreateResult | None: """Create a task workspace from one of the user's active Jira issues. @@ -476,6 +508,11 @@ def create_task_from_jira( issue is moved to its "In Progress" transition before the workspace is created. + Right after that, if the issue isn't already assigned to the current + Jira user and `confirm_assign` is given, calls `confirm_assign(issue)` + — if it returns `True`, the issue is assigned to the current user + before the workspace is created. + `confirm_grow(count)` is forwarded to `create_task_workspace`: it's called only when all `count` workspaces are taken, to ask whether to add one more (returning `None` here if declined). @@ -508,6 +545,7 @@ def create_task_from_jira( return None _maybe_transition_to_in_progress(config.jira, issue, confirm_transition) + _maybe_assign_to_self(config.jira, issue, confirm_assign) return create_task_workspace( name=truncate_summary(issue.summary), @@ -590,6 +628,7 @@ def ensure_issue_in_active_sprint( def create_task_from_jira_key( issue_key: str, confirm_transition: Callable[[JiraIssue], bool] | None = None, + confirm_assign: Callable[[JiraIssue], bool] | None = None, select_sprint: Callable[[list[JiraSprint]], JiraSprint | None] = ( lambda sprints: sprints[0] if sprints else None ), @@ -600,14 +639,15 @@ def create_task_from_jira_key( Follows the same steps as `create_task_from_jira`'s interactive flow — offering to move the issue to "In Progress" via `confirm_transition` if - it isn't already, then creating the workspace/timer and switching to - it via `create_task_workspace` — but for a specific `issue_key` instead - of one picked from the active-issues list. Additionally ensures the - issue is in one of its project's current active sprints via - `ensure_issue_in_active_sprint`, letting `select_sprint` pick among - them (or decline) if it isn't already in one (any board/sprint - resolution problem is reported via `warn` and skipped rather than - failing the command). + it isn't already, then offering to assign it to the current user via + `confirm_assign` if it isn't already, then creating the workspace/timer + and switching to it via `create_task_workspace` — but for a specific + `issue_key` instead of one picked from the active-issues list. + Additionally ensures the issue is in one of its project's current + active sprints via `ensure_issue_in_active_sprint`, letting + `select_sprint` pick among them (or decline) if it isn't already in one + (any board/sprint resolution problem is reported via `warn` and skipped + rather than failing the command). Raises `ConfigError` if the config file is missing/malformed, `TaskError` if there's no `jira` config block or `issue_key` is already @@ -633,6 +673,7 @@ def create_task_from_jira_key( raise TaskError(str(exc)) from exc _maybe_transition_to_in_progress(config.jira, issue, confirm_transition) + _maybe_assign_to_self(config.jira, issue, confirm_assign) ensure_issue_in_active_sprint(config, config.jira, issue_key, select_sprint, warn) diff --git a/tests/test_cli.py b/tests/test_cli.py index a870c47..a52f10b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -528,9 +528,10 @@ def test_jira_start_lists_issues_and_creates_from_selection(mock_create_from_jir workspace_index=2, name="ISD-2", ref="jira:ISD-2", window_moved=True ) - def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): del confirm_grow del confirm_transition + del confirm_assign issues = [ JiraIssue(key="ISD-2", summary="Second issue", priority="High", status="In Progress") ] @@ -554,9 +555,10 @@ def test_jira_start_notes_when_window_could_not_be_moved(mock_create_from_jira): workspace_index=2, name="ISD-2", ref="jira:ISD-2", window_moved=False ) - def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): del confirm_grow del confirm_transition + del confirm_assign issues = [ JiraIssue(key="ISD-2", summary="Second issue", priority="High", status="In Progress") ] @@ -595,9 +597,10 @@ def test_jira_start_reports_error(mock_create_from_jira): def test_jira_start_prompts_to_move_issue_to_in_progress_when_confirmed(mock_create_from_jira): captured = {} - def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): del confirm_grow del select_issue + del confirm_assign issue = JiraIssue(key="ISD-2", summary="Second issue", priority="High", status="To Do") captured["confirmed"] = confirm_transition(issue) return None @@ -615,9 +618,10 @@ def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): def test_jira_start_does_not_transition_when_declined(mock_create_from_jira): captured = {} - def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): del confirm_grow del select_issue + del confirm_assign issue = JiraIssue(key="ISD-2", summary="Second issue", priority="High", status="To Do") captured["confirmed"] = confirm_transition(issue) return None @@ -634,9 +638,10 @@ def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): def test_jira_start_defaults_transition_prompt_to_yes(mock_create_from_jira): captured = {} - def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): del confirm_grow del select_issue + del confirm_assign issue = JiraIssue(key="ISD-2", summary="Second issue", priority="High", status="To Do") captured["confirmed"] = confirm_transition(issue) return None @@ -654,9 +659,10 @@ def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): def test_jira_start_defaults_grow_prompt_to_yes(mock_create_from_jira): captured = {} - def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): del confirm_transition del select_issue + del confirm_assign captured["confirmed"] = confirm_grow(4) return None @@ -668,6 +674,68 @@ def fake_create_from_jira(select_issue, confirm_transition, confirm_grow): assert "All 4 workspaces are in use. Add another workspace? [Y/n]" in result.stdout +@patch("et.cli.create_task_from_jira") +def test_jira_start_prompts_to_assign_issue_to_self_when_confirmed(mock_create_from_jira): + captured = {} + + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): + del confirm_grow + del select_issue + del confirm_transition + issue = JiraIssue(key="ISD-2", summary="Second issue", priority="High", status="To Do") + captured["confirmed"] = confirm_assign(issue) + return None + + mock_create_from_jira.side_effect = fake_create_from_jira + + result = runner.invoke(app, ["jira", "start"], input="y\n") + + assert result.exit_code == 0 + assert "Assign ISD-2 to yourself?" in result.stdout + assert captured["confirmed"] is True + + +@patch("et.cli.create_task_from_jira") +def test_jira_start_does_not_assign_when_declined(mock_create_from_jira): + captured = {} + + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): + del confirm_grow + del select_issue + del confirm_transition + issue = JiraIssue(key="ISD-2", summary="Second issue", priority="High", status="To Do") + captured["confirmed"] = confirm_assign(issue) + return None + + mock_create_from_jira.side_effect = fake_create_from_jira + + result = runner.invoke(app, ["jira", "start"], input="n\n") + + assert result.exit_code == 0 + assert captured["confirmed"] is False + + +@patch("et.cli.create_task_from_jira") +def test_jira_start_defaults_assign_prompt_to_yes(mock_create_from_jira): + captured = {} + + def fake_create_from_jira(select_issue, confirm_transition, confirm_assign, confirm_grow): + del confirm_grow + del select_issue + del confirm_transition + issue = JiraIssue(key="ISD-2", summary="Second issue", priority="High", status="To Do") + captured["confirmed"] = confirm_assign(issue) + return None + + mock_create_from_jira.side_effect = fake_create_from_jira + + result = runner.invoke(app, ["jira", "start"], input="\n") + + assert result.exit_code == 0 + assert "Assign ISD-2 to yourself? [Y/n]" in result.stdout + assert captured["confirmed"] is True + + @patch("et.cli.create_task_from_jira_key") def test_jira_start_with_key_creates_task_directly(mock_create_from_jira_key): mock_create_from_jira_key.return_value = TaskCreateResult( @@ -699,11 +767,12 @@ def test_jira_start_with_key_prompts_to_move_issue_to_in_progress_when_confirmed captured = {} def fake_create_from_jira_key( - issue_key, confirm_transition, select_sprint, confirm_grow, warn + issue_key, confirm_transition, confirm_assign, select_sprint, confirm_grow, warn ): del select_sprint del confirm_grow del warn + del confirm_assign issue = JiraIssue(key=issue_key, summary="Second issue", priority="High", status="To Do") captured["confirmed"] = confirm_transition(issue) return TaskCreateResult( @@ -720,16 +789,46 @@ def fake_create_from_jira_key( assert captured["confirmed"] is True +@patch("et.cli.create_task_from_jira_key") +def test_jira_start_with_key_prompts_to_assign_issue_to_self_when_confirmed( + mock_create_from_jira_key, +): + captured = {} + + def fake_create_from_jira_key( + issue_key, confirm_transition, confirm_assign, select_sprint, confirm_grow, warn + ): + del select_sprint + del confirm_grow + del warn + del confirm_transition + issue = JiraIssue(key=issue_key, summary="Second issue", priority="High", status="To Do") + captured["confirmed"] = confirm_assign(issue) + return TaskCreateResult( + workspace_index=0, name="ISD-2", ref="jira:ISD-2", + window_moved=True, + ) + + mock_create_from_jira_key.side_effect = fake_create_from_jira_key + + result = runner.invoke(app, ["jira", "start", "ISD-2"], input="y\n") + + assert result.exit_code == 0 + assert "Assign ISD-2 to yourself?" in result.stdout + assert captured["confirmed"] is True + + @patch("et.cli.create_task_from_jira_key") def test_jira_start_with_key_prompts_to_add_to_sprint(mock_create_from_jira_key): captured = {} def fake_create_from_jira_key( - issue_key, confirm_transition, select_sprint, confirm_grow, warn + issue_key, confirm_transition, confirm_assign, select_sprint, confirm_grow, warn ): del confirm_transition del confirm_grow del warn + del confirm_assign captured["selected"] = select_sprint([JiraSprint(id="7", name="Sprint 7")]) return TaskCreateResult( workspace_index=0, name="ISD-2", ref="jira:ISD-2", @@ -752,11 +851,12 @@ def test_jira_start_with_key_prompts_to_choose_among_multiple_active_sprints( captured = {} def fake_create_from_jira_key( - issue_key, confirm_transition, select_sprint, confirm_grow, warn + issue_key, confirm_transition, confirm_assign, select_sprint, confirm_grow, warn ): del confirm_transition del confirm_grow del warn + del confirm_assign captured["selected"] = select_sprint( [JiraSprint(id="7", name="Sprint 7"), JiraSprint(id="8", name="Sprint 8")] ) @@ -777,12 +877,13 @@ def fake_create_from_jira_key( @patch("et.cli.create_task_from_jira_key") def test_jira_start_with_key_prints_warnings(mock_create_from_jira_key): def fake_create_from_jira_key( - issue_key, confirm_transition, select_sprint, confirm_grow, warn + issue_key, confirm_transition, confirm_assign, select_sprint, confirm_grow, warn ): del issue_key del confirm_transition del select_sprint del confirm_grow + del confirm_assign warn("no active sprint found on the project's board; skipping sprint") return TaskCreateResult( workspace_index=0, name="ISD-2", ref="jira:ISD-2", diff --git a/tests/test_jira.py b/tests/test_jira.py index 1ea834a..63d6282 100644 --- a/tests/test_jira.py +++ b/tests/test_jira.py @@ -15,6 +15,7 @@ JiraSprint, JiraTransition, add_issue_to_sprint, + assign_issue, create_comment, create_issue, create_worklog, @@ -62,12 +63,15 @@ def _issue( priority: str, status: str | None = None, original_estimate_seconds: int | None = None, + assignee_account_id: str | None = None, ) -> dict: fields: dict[str, object] = {"summary": summary, "priority": {"name": priority}} if status is not None: fields["status"] = {"name": status} if original_estimate_seconds is not None: fields["timetracking"] = {"originalEstimateSeconds": original_estimate_seconds} + if assignee_account_id is not None: + fields["assignee"] = {"accountId": assignee_account_id} return {"key": key, "fields": fields} @@ -98,7 +102,7 @@ def test_fetch_active_issues_passes_jql_and_basic_auth(mock_get): assert args[0] == "https://example.atlassian.net/rest/api/3/search/jql" assert kwargs["params"] == { "jql": config.jql, - "fields": "summary,priority,status,timetracking", + "fields": "summary,priority,status,timetracking,assignee", } assert kwargs["auth"] == (config.email, config.pat) @@ -301,6 +305,26 @@ def test_fetch_active_issues_defaults_original_estimate_seconds_to_none_when_mis assert issues[0].original_estimate_seconds is None +@patch("et.jira.requests.get") +def test_fetch_active_issues_parses_assignee_account_id(mock_get): + mock_get.return_value = _response( + [_issue("PROJ-1", "Task A", "High", assignee_account_id="account-123")] + ) + + issues = fetch_active_issues(_config()) + + assert issues[0].assignee_account_id == "account-123" + + +@patch("et.jira.requests.get") +def test_fetch_active_issues_defaults_assignee_account_id_to_none_when_unassigned(mock_get): + mock_get.return_value = _response([_issue("PROJ-1", "Task A", "High")]) + + issues = fetch_active_issues(_config()) + + assert issues[0].assignee_account_id is None + + def _json_response(status_code: int, payload: object) -> MagicMock: response = MagicMock() response.status_code = status_code @@ -457,6 +481,36 @@ def test_transition_issue_wraps_network_errors(mock_post): transition_issue(_config(), "PROJ-1", "11") +# --- assign_issue --------------------------------------------------------- + + +@patch("et.jira.requests.put") +def test_assign_issue_puts_account_id(mock_put): + mock_put.return_value = _json_response(204, {}) + config = _config(base_url="https://example.atlassian.net") + + assign_issue(config, "PROJ-1", "account-123") + + args, kwargs = mock_put.call_args + assert args[0] == "https://example.atlassian.net/rest/api/3/issue/PROJ-1/assignee" + assert kwargs["json"] == {"accountId": "account-123"} + assert kwargs["auth"] == (config.email, config.pat) + + +@patch("et.jira.requests.put") +def test_assign_issue_raises_on_non_204_status(mock_put): + mock_put.return_value = _json_response(400, {"errorMessages": ["bad request"]}) + + with pytest.raises(JiraError, match="400"): + assign_issue(_config(), "PROJ-1", "account-123") + + +@patch("et.jira.requests.put", side_effect=requests.ConnectionError("no route to host")) +def test_assign_issue_wraps_network_errors(mock_put): + with pytest.raises(JiraError, match="no route to host"): + assign_issue(_config(), "PROJ-1", "account-123") + + # --- create_comment ----------------------------------------------------- @@ -551,7 +605,7 @@ def test_fetch_issue_returns_summary_priority_and_status(mock_get): ) args, kwargs = mock_get.call_args assert args[0] == "https://example.atlassian.net/rest/api/3/issue/PROJ-1" - assert kwargs["params"] == {"fields": "summary,priority,status,timetracking"} + assert kwargs["params"] == {"fields": "summary,priority,status,timetracking,assignee"} @patch("et.jira.requests.get") @@ -605,6 +659,35 @@ def test_fetch_issue_defaults_priority_to_empty_string(mock_get): assert issue.priority == "" +@patch("et.jira.requests.get") +def test_fetch_issue_parses_assignee_account_id(mock_get): + mock_get.return_value = _json_response( + 200, + { + "fields": { + "summary": "A summary", + "status": {"name": "To Do"}, + "assignee": {"accountId": "account-123"}, + } + }, + ) + + issue = fetch_issue(_config(), "PROJ-1") + + assert issue.assignee_account_id == "account-123" + + +@patch("et.jira.requests.get") +def test_fetch_issue_defaults_assignee_account_id_to_none_when_unassigned(mock_get): + mock_get.return_value = _json_response( + 200, {"fields": {"summary": "A summary", "status": {"name": "To Do"}}} + ) + + issue = fetch_issue(_config(), "PROJ-1") + + assert issue.assignee_account_id is None + + @patch("et.jira.requests.get") def test_fetch_issue_raises_when_no_summary_in_response(mock_get): mock_get.return_value = _json_response(200, {"fields": {"status": {"name": "To Do"}}}) diff --git a/tests/test_task.py b/tests/test_task.py index fc1e96b..0925bd8 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -300,6 +300,7 @@ def _issue( priority: str = "High", status: str = "", original_estimate_seconds: int | None = None, + assignee_account_id: str | None = None, ) -> JiraIssue: return JiraIssue( key=key, @@ -307,6 +308,7 @@ def _issue( priority=priority, status=status, original_estimate_seconds=original_estimate_seconds, + assignee_account_id=assignee_account_id, ) @@ -490,6 +492,95 @@ def test_create_task_from_jira_wraps_fetch_transitions_error( ) +@patch("et.task.assign_issue") +@patch("et.task.search_user_account_id") +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_active_issues") +@patch("et.task.load_config") +def test_create_task_from_jira_skips_confirm_assign_when_already_assigned_to_self( + mock_load_config, + mock_fetch, + _mock_create_task_workspace, + mock_search_user_account_id, + mock_assign_issue, +): + mock_load_config.return_value = _config() + mock_fetch.return_value = [_issue("ISD-2", assignee_account_id="acct-1")] + mock_search_user_account_id.return_value = "acct-1" + confirm_assign = MagicMock() + + create_task_from_jira(select_issue=lambda issues: issues[0], confirm_assign=confirm_assign) + + confirm_assign.assert_not_called() + mock_assign_issue.assert_not_called() + + +@patch("et.task.assign_issue") +@patch("et.task.search_user_account_id", return_value=None) +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_active_issues") +@patch("et.task.load_config") +def test_create_task_from_jira_skips_confirm_assign_when_current_user_lookup_fails( + mock_load_config, + mock_fetch, + _mock_create_task_workspace, + _mock_search_user_account_id, + mock_assign_issue, +): + mock_load_config.return_value = _config() + mock_fetch.return_value = [_issue("ISD-2")] + confirm_assign = MagicMock() + + create_task_from_jira(select_issue=lambda issues: issues[0], confirm_assign=confirm_assign) + + confirm_assign.assert_not_called() + mock_assign_issue.assert_not_called() + + +@patch("et.task.assign_issue") +@patch("et.task.search_user_account_id", return_value="acct-1") +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_active_issues") +@patch("et.task.load_config") +def test_create_task_from_jira_assigns_issue_to_self_when_confirmed( + mock_load_config, + mock_fetch, + _mock_create_task_workspace, + _mock_search_user_account_id, + mock_assign_issue, +): + mock_load_config.return_value = _config() + mock_fetch.return_value = [_issue("ISD-2", assignee_account_id="acct-2")] + + create_task_from_jira( + select_issue=lambda issues: issues[0], confirm_assign=lambda issue: True + ) + + mock_assign_issue.assert_called_once_with(_config().jira, "ISD-2", "acct-1") + + +@patch("et.task.assign_issue") +@patch("et.task.search_user_account_id", return_value="acct-1") +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_active_issues") +@patch("et.task.load_config") +def test_create_task_from_jira_skips_assign_when_declined( + mock_load_config, + mock_fetch, + _mock_create_task_workspace, + _mock_search_user_account_id, + mock_assign_issue, +): + mock_load_config.return_value = _config() + mock_fetch.return_value = [_issue("ISD-2", assignee_account_id="acct-2")] + + create_task_from_jira( + select_issue=lambda issues: issues[0], confirm_assign=lambda issue: False + ) + + mock_assign_issue.assert_not_called() + + # --- create_task_from_jira_key ----------------------------------------------- @@ -641,6 +732,75 @@ def test_create_task_from_jira_key_calls_ensure_issue_in_active_sprint_with_conf mock_ensure_sprint.assert_called_once_with(config, config.jira, "ISD-2", select_sprint, warn) +@patch("et.task.assign_issue") +@patch("et.task.search_user_account_id") +@patch("et.task.ensure_issue_in_active_sprint") +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_issue") +@patch("et.task.load_config") +def test_create_task_from_jira_key_skips_confirm_assign_when_already_assigned_to_self( + mock_load_config, + mock_fetch_issue, + _mock_create_task_workspace, + _mock_ensure_sprint, + mock_search_user_account_id, + mock_assign_issue, +): + mock_load_config.return_value = _config() + mock_fetch_issue.return_value = _issue("ISD-2", assignee_account_id="acct-1") + mock_search_user_account_id.return_value = "acct-1" + confirm_assign = MagicMock() + + create_task_from_jira_key("ISD-2", confirm_assign=confirm_assign) + + confirm_assign.assert_not_called() + mock_assign_issue.assert_not_called() + + +@patch("et.task.assign_issue") +@patch("et.task.search_user_account_id", return_value="acct-1") +@patch("et.task.ensure_issue_in_active_sprint") +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_issue") +@patch("et.task.load_config") +def test_create_task_from_jira_key_assigns_issue_to_self_when_confirmed( + mock_load_config, + mock_fetch_issue, + _mock_create_task_workspace, + _mock_ensure_sprint, + _mock_search_user_account_id, + mock_assign_issue, +): + mock_load_config.return_value = _config() + mock_fetch_issue.return_value = _issue("ISD-2", assignee_account_id="acct-2") + + create_task_from_jira_key("ISD-2", confirm_assign=lambda issue: True) + + mock_assign_issue.assert_called_once_with(_config().jira, "ISD-2", "acct-1") + + +@patch("et.task.assign_issue") +@patch("et.task.search_user_account_id", return_value="acct-1") +@patch("et.task.ensure_issue_in_active_sprint") +@patch("et.task.create_task_workspace") +@patch("et.task.fetch_issue") +@patch("et.task.load_config") +def test_create_task_from_jira_key_skips_assign_when_declined( + mock_load_config, + mock_fetch_issue, + _mock_create_task_workspace, + _mock_ensure_sprint, + _mock_search_user_account_id, + mock_assign_issue, +): + mock_load_config.return_value = _config() + mock_fetch_issue.return_value = _issue("ISD-2", assignee_account_id="acct-2") + + create_task_from_jira_key("ISD-2", confirm_assign=lambda issue: False) + + mock_assign_issue.assert_not_called() + + # --- ensure_issue_in_active_sprint -------------------------------------------