"""Jira Cloud as a todo source. Auth is HTTP Basic with an Atlassian account email + API token, and search goes through the token-paginated `/rest/api/3/search/jql` endpoint — both carried over from jira-helper's client. The difference is the query: jira-helper asks "what is due today", this asks "what is open at all", because you need something to start a pomodoro on even when nothing is due. Writing back is new here: finishing a Jira-backed pomodoro task transitions the issue to Done, so the app is not a second place to keep your task list in sync. """ from __future__ import annotations import re from datetime import date from typing import Any, Iterator import httpx from app.settings_schema import JiraSourceSettings from app.sources.base import SourceError, TodoItem _SEARCH_PATH = "/rest/api/3/search/jql" _BASE_FIELDS = ["summary", "duedate", "status", "assignee", "priority", "project", "labels"] _OPEN_CONDITION = "statusCategory != Done" _CUSTOM_FIELD_RE = re.compile(r"^customfield_(\d+)$") def start_date_clause(field_id: str) -> str: """`customfield_10015` has to be written `cf[10015]` in JQL; names get quoted.""" match = _CUSTOM_FIELD_RE.match(field_id) return f"cf[{match.group(1)}]" if match else f'"{field_id}"' class JiraSource: name = "jira" def __init__(self, config: JiraSourceSettings, *, timeout: int = 30) -> None: if not (config.base_url and config.email and config.api_token): raise SourceError("Jira needs a base URL, an account email and an API token.") self._config = config self._base_url = config.base_url.rstrip("/") self._client = httpx.Client( base_url=self._base_url, auth=(config.email, config.api_token), headers={"Accept": "application/json", "Content-Type": "application/json"}, timeout=timeout, ) # ---- plumbing ---------------------------------------------------------- def _request(self, method: str, path: str, **kwargs: Any) -> Any: try: resp = self._client.request(method, path, **kwargs) except httpx.HTTPError as exc: raise SourceError(f"Jira is unreachable: {exc}") from exc if resp.status_code in (401, 403): raise SourceError( "Jira rejected the credentials (check the account email and the " "API token — a token, never your password)." ) if not resp.is_success: raise SourceError(f"Jira {method} {path} -> {resp.status_code}: {resp.text[:300]}") if resp.status_code == 204 or not resp.content: return None return resp.json() def _fields(self) -> list[str]: return [*_BASE_FIELDS, self._config.start_date_field] def _jql(self) -> str: """Open issues, newest deadlines first, narrowed by the user's extra JQL.""" jql = _OPEN_CONDITION if not self._config.include_all_open: clause = start_date_clause(self._config.start_date_field) jql = ( f"({_OPEN_CONDITION}) AND " f"(duedate <= startOfDay('+7d') OR {clause} <= startOfDay())" ) if self._config.extra_jql.strip(): jql = f"({jql}) AND ({self._config.extra_jql.strip()})" return f"{jql} ORDER BY duedate ASC, priority DESC, updated DESC" def _search(self, jql: str) -> Iterator[dict[str, Any]]: payload: dict[str, Any] = { "jql": jql, "maxResults": 100, "fields": self._fields(), } seen = 0 while True: data = self._request("POST", _SEARCH_PATH, json=payload) or {} for issue in data.get("issues", []): yield issue seen += 1 if seen >= self._config.max_issues: return token = data.get("nextPageToken") if not token: return payload["nextPageToken"] = token # ---- source API -------------------------------------------------------- def fetch(self) -> list[TodoItem]: return [self._to_item(issue) for issue in self._search(self._jql())] def check(self) -> str: me = self._request("GET", "/rest/api/3/myself") or {} return f"{me.get('displayName', 'unknown')} on {self._base_url}" def complete(self, item: TodoItem) -> bool: """Transition the issue into a Done-category status. Jira has no generic "close" verb: every project defines its own workflow, so we ask the issue which transitions it offers and pick the one named in the settings, else any transition landing in the Done category. """ if not self._config.writeback_done: return False key = item.external_id data = self._request("GET", f"/rest/api/3/issue/{key}/transitions") or {} transitions = data.get("transitions", []) wanted = self._config.done_transition_name.strip().lower() chosen = next( (t for t in transitions if (t.get("name") or "").lower() == wanted), None, ) or next( ( t for t in transitions if ((t.get("to") or {}).get("statusCategory") or {}).get("key") == "done" ), None, ) if chosen is None: raise SourceError( f"{key} has no transition to a Done status" + (f" named {self._config.done_transition_name!r}" if wanted else "") + ". Available: " + ", ".join(t.get("name", "?") for t in transitions) ) self._request( "POST", f"/rest/api/3/issue/{key}/transitions", json={"transition": {"id": chosen["id"]}}, ) return True def close(self) -> None: self._client.close() # ---- mapping ----------------------------------------------------------- def _to_item(self, issue: dict[str, Any]) -> TodoItem: fields = issue.get("fields") or {} key = issue.get("key", "") status = (fields.get("status") or {}).get("name") category = ((fields.get("status") or {}).get("statusCategory") or {}).get("key") return TodoItem( source=self.name, external_id=key, title=(fields.get("summary") or "").strip() or key, url=f"{self._base_url}/browse/{key}", project=((fields.get("project") or {}).get("name")), status=status, priority=(fields.get("priority") or {}).get("name"), due_date=_parse_date(fields.get("duedate")), start_date=_parse_date(fields.get(self._config.start_date_field)), labels=list(fields.get("labels") or []), is_done=category == "done", raw={"key": key, "statusCategory": category}, ) def _parse_date(raw: Any) -> date | None: if not raw: return None try: return date.fromisoformat(str(raw)[:10]) except ValueError: return None