"""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. Nothing is written back: you close an issue in Jira, and the next refresh notices it has left the "open issues" query. One owner per task. """ from __future__ import annotations import re from collections.abc import Iterator from datetime import date from typing import Any 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 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