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 9373a6b..f8b48c8 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. If less than 60 seconds have elapsed, skip logging and continue with the completion prompts. diff --git a/src/et/cli.py b/src/et/cli.py index 92aa0cd..4120809 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 f3630cd..650cdd6 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 030ed8a..f0c30a6 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 161aa69..bbf048e 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 -------------------------------------------