This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
"""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