195 lines
11 KiB
Python
195 lines
11 KiB
Python
"""Журнал изменений задач (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]
|