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