- 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.
146 lines
5.5 KiB
Python
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
|