Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 21 additions & 7 deletions src/et/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
48 changes: 43 additions & 5 deletions src/et/jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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, ...],
Expand All @@ -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

Expand Down Expand Up @@ -493,19 +502,28 @@ 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
the response has no usable summary/status.
"""
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")
Expand All @@ -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),
)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
)

Expand All @@ -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`.

Expand Down
59 changes: 50 additions & 9 deletions src/et/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,6 +47,7 @@
JiraIssue,
JiraSprint,
add_issue_to_sprint,
assign_issue,
create_comment,
discover_board_id,
fetch_active_issues,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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).
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
),
Expand All @@ -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
Expand All @@ -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)

Expand Down
Loading
Loading