2026-10-26 02:27:47 +04:00

195 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля, отметка «с какого момента» для
refresh. Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model)."""
from datetime import datetime, timedelta, timezone
from . import projects
from ...core import db, settings
from ...core.http import is_digits
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5)
def jira_utc(value) -> str | None:
"""Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → ISO-8601 UTC в формате
now(); без смещения — UTC; неразбираемое — None."""
if not isinstance(value, str) or not value:
return None
s = value[:-1] + "+00:00" if value.endswith("Z") else value
if len(s) > 5 and s[-5] in "+-" and is_digits(s[-4:]): # ±HHMM → ±HH:MM
s = s[:-2] + ":" + s[-2:]
try:
d = datetime.fromisoformat(s)
d = d if d.tzinfo else d.replace(tzinfo=timezone.utc)
return d.astimezone(timezone.utc).replace(microsecond=0).isoformat()
except (ValueError, OverflowError): # время за краем диапазона datetime — тоже «не разобралось»
return None
def fill_comment_utc(conn) -> None:
for r in conn.execute("SELECT key, comment_id, created FROM issue_comment").fetchall():
conn.execute("UPDATE issue_comment SET created_at = ? WHERE key = ? AND comment_id = ?",
(jira_utc(r["created"]), r["key"], r["comment_id"]))
BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution")
_REQUIRED = ("history_id", "key", "created", "field_name")
_OPTIONAL = ("author", "field_id", "from", "from_string", "to", "to_string")
def check_changes(changes) -> None:
"""Форма пачки журнала до записи: нарушение — ValueError с номером элемента и полем (422 bad_history)."""
if not isinstance(changes, list):
raise ValueError("changes — список элементов журнала")
for n, c in enumerate(changes):
where = f"changes[{n}]"
if not isinstance(c, dict):
raise ValueError(f"{where} — объект элемента журнала")
for f in _REQUIRED:
if not isinstance(c.get(f), str) or not c[f]:
raise ValueError(f"{where}.{f} — непустая строка")
seq = c.get("seq")
if not isinstance(seq, int) or isinstance(seq, bool) or seq < 0:
raise ValueError(f"{where}.seq — целое >= 0")
for f in _OPTIONAL:
if c.get(f) is not None and not isinstance(c[f], str):
raise ValueError(f"{where}.{f} — строка или null")
def logical_field(item: dict, field_ids: dict | None, epic_link: dict | None) -> str:
"""Логическое имя поля журнала: встроенные — по field_id, без него — по field_name; плановые даты и эпик — по id
из настроек; иначе — имя поля Jira как есть."""
fid, name = item.get("field_id"), item.get("field_name")
if fid:
if fid in BUILTIN_FIELDS:
return fid
for logical in ("planned_start", "planned_end"):
if (field_ids or {}).get(logical) == fid:
return logical
if (epic_link or {}).get("field_id") == fid:
return "epic_link"
elif name in BUILTIN_FIELDS:
return name
return name
def record_changes(conn, changes: list[dict]) -> dict:
"""Записать проверенную пачку (INSERT OR IGNORE по (history_id, seq)) в одной транзакции."""
field_ids, epic_link = settings.get(conn, "field_ids"), settings.get(conn, "epic_link")
now, new = db.now(), 0
with db.transaction(conn):
for c in changes:
cur = conn.execute(
"INSERT OR IGNORE INTO issue_change(history_id, seq, key, at, created, author, field, field_id, field_name, "
"from_value, from_string, to_value, to_string, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(c["history_id"], c["seq"], c["key"], jira_utc(c["created"]), c["created"], c.get("author"),
logical_field(c, field_ids, epic_link), c.get("field_id"), c["field_name"], c.get("from"),
c.get("from_string"), c.get("to"), c.get("to_string"), now))
new += cur.rowcount
return {"accepted": new, "duplicates": len(changes) - new}
def backfill_jql(base_jql: str) -> str:
"""JQL стартовой загрузки: незакрытые задачи открытых спринтов (design #6)."""
return f"({base_jql}) AND sprint in openSprints() AND statusCategory != Done"
def delivery_param(conn, params: dict) -> dict | None:
"""Параметр history выдаваемого refresh (spec commands «Журнал изменений в refresh»); None — не дописывать."""
base = projects.base_jql(conn)
if base is None:
return None
mark = conn.execute("SELECT started_at, query FROM sync_run WHERE history_at IS NOT NULL "
"ORDER BY started_at DESC, id DESC LIMIT 1").fetchone()
out = {}
if mark is not None:
since = db.parse(mark["started_at"]) - SINCE_MARGIN
out["since"] = since.strftime("%Y-%m-%dT%H:%M:%SZ")
if params.get("jql") == base and (mark is None or mark["query"] != base):
out["jql"] = backfill_jql(base)
return out or None
def accepted(result) -> bool:
"""Итог журнала в результате refresh принят: history — словарь без непустого error (backfill_error и incomplete
отметку не держат)."""
return isinstance(result, dict) and isinstance(result.get("history"), dict) and not result["history"].get("error")
# --- лента и история задачи (spec hub-api «Лента изменений», «История задачи в карточке») -------------------------
KINDS = ("status", "description", "dates", "comment", "other")
DEFAULT_KINDS = ("status", "description", "dates", "comment")
DATE_FIELDS = ("duedate", "planned_start", "planned_end")
def parse_kinds(value: str | None) -> tuple[str, ...]:
if not value:
return DEFAULT_KINDS
if value.strip() == "all":
return KINDS
kinds = tuple(k.strip() for k in value.split(",") if k.strip())
unknown = [k for k in kinds if k not in KINDS]
if unknown or not kinds:
raise ValueError(f"kinds: неизвестный вид {', '.join(unknown) or value!r}; допустимо {', '.join(KINDS)} или all")
return kinds
def kind_of(field: str) -> str:
if field in ("status", "description"):
return field
return "dates" if field in DATE_FIELDS else "other"
def _event(row) -> dict:
"""Элемент ленты из строки issue_change: from/to — строка журнала, без неё — значение."""
fs, ts = row["from_string"], row["to_string"]
return {"at": row["at"], "key": row["key"], "kind": kind_of(row["field"]), "field": row["field"],
"author": row["author"], "from": fs if fs is not None else row["from_value"],
"to": ts if ts is not None else row["to_value"], "from_value": row["from_value"], "to_value": row["to_value"]}
_OWN = "EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)"
def feed(conn, since: str, until: str, kinds: tuple, project: str | None = None, key: str | None = None,
limit: int = 500) -> dict:
"""События за [since, until] (UTC в формате db.now()): журнал и новые комментарии. project — только свои задачи
проекта; key — одна задача без ограничения проектом. Больше limit — первые limit по порядку и truncated."""
where, args = [], []
if key:
where.append("x.key = ?")
args.append(key)
elif project:
where.append(_OWN)
args.append(project)
extra = "".join(" AND " + w for w in where)
events = [] # (сортировочный ключ, элемент)
if any(k != "comment" for k in kinds):
for r in conn.execute("SELECT x.*, i.summary AS i_summary, i.status AS i_status FROM issue_change x "
"LEFT JOIN issue i ON i.key = x.key WHERE x.at >= ? AND x.at <= ?" + extra,
[since, until] + args):
e = _event(r)
if e["kind"] in kinds:
events.append(((r["at"], r["key"], r["history_id"], r["seq"]), {**e, "summary": r["i_summary"],
"status": r["i_status"]}))
if "comment" in kinds:
for r in conn.execute("SELECT x.*, i.summary AS i_summary, i.status AS i_status FROM issue_comment x "
"LEFT JOIN issue i ON i.key = x.key WHERE x.created_at >= ? AND x.created_at <= ?" + extra,
[since, until] + args):
events.append(((r["created_at"], r["key"], r["comment_id"], 0), {
"at": r["created_at"], "key": r["key"], "summary": r["i_summary"], "status": r["i_status"],
"kind": "comment", "field": "comment", "author": r["author"], "from": None, "to": r["body"],
"from_value": None, "to_value": None}))
# at — по убыванию, при равном времени — key, history_id, seq по возрастанию
events.sort(key=lambda p: p[0][1:])
events.sort(key=lambda p: p[0][0], reverse=True)
order = ("at", "key", "summary", "status", "kind", "field", "author", "from", "to", "from_value", "to_value")
return {"since": since, "until": until, "truncated": len(events) > limit,
"changes": [{k: e[k] for k in order} for _, e in events[:limit]]}
def issue_changes(conn, key: str) -> list[dict]:
"""Весь журнал задачи, новые сверху; строки без времени (at = NULL) — в конце."""
rows = conn.execute("SELECT * FROM issue_change WHERE key = ? ORDER BY at IS NULL, at DESC, history_id, seq", (key,))
return [_event(r) for r in rows]