Files
kadence/backend/app/sources/jira.py
T
timka123 77b26757bf refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency
- Refactored TimerPage component to improve layout and user experience.
- Updated theme configuration to utilize design tokens for better maintainability.
- Introduced new tokens for colors and typography to align with design principles.
- Simplified state management and improved timer functionality.
- Enhanced accessibility and responsiveness of UI elements.
2026-10-18 23:57:36 +03:00

146 lines
5.5 KiB
Python

"""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 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 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