sdlc-hub/src/jira_hub/mirror.py
Andrey Epifancev 62057606c0 Правки финального ревью: ссылки на вложения, размер, 413 в пакетах, имя копии, документация (#83)
I-1 I-2 M-1 M-2 M-3 M-5 M-6 M-7 T3.4 T4.3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:14:40 +04:00

285 lines
18 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.

"""Зеркало задач Jira (issue, issue_link, issue_comment, issue_attachment) и служебные заметки (issue_note).
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json
from . import attachments, db, history
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"]
def check_comments(issues) -> None:
"""Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем."""
for it in issues:
if "comments" not in it:
continue
key, lst = it.get("key"), it["comments"]
if not isinstance(lst, list):
raise ValueError(f"{key}: comments — список комментариев")
for n, c in enumerate(lst):
where = f"{key}: comments[{n}]"
if not isinstance(c, dict):
raise ValueError(f"{where} — объект комментария")
if not isinstance(c.get("id"), str) or not c["id"]:
raise ValueError(f"{where}.id — непустая строка")
if not isinstance(c.get("body"), str) and c.get("body") is not None:
raise ValueError(f"{where}.body — строка")
for f in ("author", "created", "updated"):
if c.get(f) is not None and not isinstance(c[f], str):
raise ValueError(f"{where}.{f} — строка или null")
def check_attachments(issues) -> None:
"""Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем."""
for it in issues:
if "attachments" not in it:
continue
key, lst = it.get("key"), it["attachments"]
if not isinstance(lst, list):
raise ValueError(f"{key}: attachments — список вложений")
for n, a in enumerate(lst):
where = f"{key}: attachments[{n}]"
if not isinstance(a, dict):
raise ValueError(f"{where} — объект вложения")
if not isinstance(a.get("id"), str) or not a["id"].isdigit():
raise ValueError(f"{where}.id — строка из цифр")
if not isinstance(a.get("filename"), str) or not a["filename"]:
raise ValueError(f"{where}.filename — непустая строка")
size = a.get("size")
if size is not None and (not isinstance(size, int) or isinstance(size, bool) or size < 0):
raise ValueError(f"{where}.size — неотрицательное целое или null")
for f in ("mime_type", "author", "created"):
if a.get(f) is not None and not isinstance(a[f], str):
raise ValueError(f"{where}.{f} — строка или null")
_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
"work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"}
def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict:
"""Upsert задач; связи с source_key среди присланных задач заменяются присланными.
Связи, у которых источник не прислан, не трогаются до загрузки их источника.
authoritative=True — загрузка по базовой JQL: флаг neighbor пересчитывается по neighbor_keys (задача, вошедшая
в базовую выборку, перестаёт быть соседней). authoritative=False — точечное обновление (по ключам, по
произвольной JQL): флаг только поднимается, никогда не снижается."""
fetched_at = fetched_at or db.now()
neighbors = set(neighbor_keys)
keys = []
with db.transaction(conn):
for it in issues:
row = {c: it.get(c) for c in ISSUE_COLUMNS}
row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False)
row["fetched_at"] = fetched_at
row["neighbor"] = 1 if it["key"] in neighbors else 0
cols = list(row)
sets = [f"{c} = excluded.{c}" for c in cols if c not in ("key", "neighbor")]
sets.append("neighbor = excluded.neighbor" if authoritative else "neighbor = MAX(issue.neighbor, excluded.neighbor)")
conn.execute(
f"INSERT INTO issue({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets),
[row[c] for c in cols])
keys.append(it["key"])
if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем
conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],))
conn.executemany(
"INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "",
db.jira_utc(c.get("created"))) for c in it["comments"]])
if isinstance(it.get("attachments"), list): # без поля (агент до v14) вложения не трогаем; attachment_file не трогаем никогда
conn.execute("DELETE FROM issue_attachment WHERE key = ?", (it["key"],))
conn.executemany( # REPLACE по attachment_id: вложение, переехавшее на другой ключ, перезаписывается
"INSERT OR REPLACE INTO issue_attachment(attachment_id, key, filename, size, mime_type, author, created, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[(a["id"], it["key"], a["filename"], a.get("size"), a.get("mime_type"), a.get("author"), a.get("created"),
db.jira_utc(a.get("created"))) for a in it["attachments"]])
conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
for link in links:
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
(link["source_key"], link["target_key"], link["link_type"]))
return {"issues": len(keys), "links": len(links)}
def known_keys(conn) -> list[str]:
return [r["key"] for r in conn.execute("SELECT key FROM issue ORDER BY key")]
def _issue_dict(row) -> dict:
d = dict(row)
d["labels"] = json.loads(d.get("labels") or "[]")
d["neighbor"] = bool(d.get("neighbor"))
return d
# Разрез по проекту: свои задачи (метка проекта — точное совпадение), их соседи по связям в обе стороны и эпики своих.
# Единственный параметр — метка, первым в списке аргументов запроса.
SCOPE_CTE = """WITH own(key) AS (
SELECT i.key FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)),
near(key) AS (
SELECT target_key FROM issue_link WHERE source_key IN (SELECT key FROM own)
UNION SELECT source_key FROM issue_link WHERE target_key IN (SELECT key FROM own)
UNION SELECT epic_key FROM issue WHERE key IN (SELECT key FROM own) AND epic_key IS NOT NULL),
scope(key, nb) AS (
SELECT key, 0 FROM own
UNION ALL SELECT n.key, 1 FROM near n JOIN issue i ON i.key = n.key WHERE n.key NOT IN (SELECT key FROM own))
"""
def labels_of(conn, key: str) -> list[str] | None:
"""Метки задачи зеркала; None — задачи в зеркале нет."""
row = conn.execute("SELECT labels FROM issue WHERE key = ?", (key,)).fetchone()
return None if row is None else json.loads(row["labels"] or "[]")
def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> list[dict]:
"""hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта).
filters["project"] — разрез по проекту (SCOPE_CTE): neighbor — относительно проекта."""
filters = filters or {}
project = filters.get("project") or None
where, args = [], []
for name, sql in _FILTERS.items():
if filters.get(name) not in (None, ""):
where.append(sql)
args.append(filters[name])
if filters.get("label"):
where.append("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)")
args.append(filters["label"])
if filters.get("neighbor") not in (None, ""):
where.append("s.nb = ?" if project else "i.neighbor = ?")
args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0)
if filters.get("hide_closed") in (True, 1, "1", "true") and closed_statuses:
where.append(f"(i.status IS NULL OR i.status NOT IN ({', '.join('?' * len(closed_statuses))}))")
args += list(closed_statuses)
if filters.get("q"):
where.append("(i.summary LIKE ? OR i.key LIKE ?)")
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
if project:
sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, n.fact_status, n.work_kind FROM issue i "
"JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key")
args = [project] + args
else:
sql = "SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key"
sql = (sql
+ (" WHERE " + " AND ".join(where) if where else "")
+ " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?")
out = [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])]
if project: # i.* уже несёт neighbor зеркала — в разрезе он относительно проекта
for d in out:
d["neighbor"] = bool(d.pop("scope_nb"))
return out
def get_issue(conn, key, files_dir=None) -> dict | None:
row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone()
if row is None:
return None
d = _issue_dict(row)
epic = conn.execute("SELECT summary FROM issue WHERE key = ?", (d["epic_key"],)).fetchone() if d.get("epic_key") else None
d["epic_summary"] = epic["summary"] if epic else None
d["links_out"] = [dict(r) for r in conn.execute(
"SELECT l.target_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.target_key "
"WHERE l.source_key = ? ORDER BY l.link_type, l.target_key", (key,))]
d["links_in"] = [dict(r) for r in conn.execute(
"SELECT l.source_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.source_key "
"WHERE l.target_key = ? ORDER BY l.link_type, l.source_key", (key,))]
d["comments"] = [dict(r) for r in conn.execute(
"SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? "
"ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))]
rows = [dict(r) for r in conn.execute(
"SELECT attachment_id, filename, size, mime_type, author, created, created_at FROM issue_attachment WHERE key = ? "
"ORDER BY created_at DESC, CAST(attachment_id AS INTEGER) DESC, attachment_id DESC", (key,))]
d["attachments"] = []
for r in rows:
st = attachments.state(conn, files_dir, r["attachment_id"])
d["attachments"].append({**{k: v for k, v in r.items() if k != "created_at"}, "state": st["state"], "error": st["error"]})
d["description_attachment_ids"] = attachments.resolve(attachments.ref_names(d.get("description")), rows)
for c in d["comments"]:
c["attachment_ids"] = attachments.resolve(attachments.ref_names(c["body"]), rows)
d["changes"] = history.issue_changes(conn, key)
d["note"] = get_note(conn, key)
d["commands"] = [dict(r) for r in conn.execute(
"SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? AND type != 'fetch_attachment' "
"ORDER BY id DESC LIMIT 10",
(f'%"{key}"%',))]
return d
BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate", "planned_start",
"planned_end", "epic_key", "labels", "neighbor"]
def board(conn, base_jql, project: str | None = None) -> dict:
"""Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний
успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски
делает интерфейс. С project — свои задачи проекта и соседи (neighbor относительно проекта), связи — те, у которых
source или target — своя задача; last_sync — как без проекта. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы."""
own = not conn.in_transaction
if own:
conn.execute("BEGIN")
try:
if project:
cols = ", ".join("i." + c for c in BOARD_FIELDS if c != "neighbor")
issues = [_issue_dict(r) for r in conn.execute(
SCOPE_CTE + f"SELECT {cols}, s.nb AS neighbor, n.fact_status, n.work_kind FROM issue i "
"JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key", (project,))]
links = [dict(r) for r in conn.execute(
SCOPE_CTE + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
"WHERE source_key IN (SELECT key FROM own) OR target_key IN (SELECT key FROM own) "
"ORDER BY source_key, target_key, link_type", (project,))]
else:
issues = [_issue_dict(r) for r in conn.execute(
f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind "
"FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")]
links = [dict(r) for r in conn.execute(
"SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
"ORDER BY source_key, target_key, link_type")]
run = conn.execute(
"SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id "
"WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' "
"ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone()
finally:
if own:
conn.execute("COMMIT")
return {"generated_at": db.now(), "last_sync": dict(run) if run else None, "issues": issues, "links": links}
def get_note(conn, key) -> dict | None:
row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone()
return dict(row) if row else None
def set_note(conn, key, fields: dict, updated_by: str) -> dict:
unknown = set(fields) - set(NOTE_FIELDS)
if unknown:
raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}")
with db.transaction(conn): # чтение, слияние и запись — атомарно (каждый запрос и поток открывает своё соединение, R-2)
cur = get_note(conn, key) or {"key": key}
cur.update(fields)
cur["updated_at"], cur["updated_by"] = db.now(), updated_by
cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"]
conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]),
[cur.get(c) for c in cols])
return get_note(conn, key)
def rename_note(conn, old_key, new_key) -> None:
"""После создания задачи: заметка с временного ключа переезжает на настоящий. Если под настоящим ключом заметка уже
есть — слияние (M-3 #4): поле временной заметки побеждает, если оно непустое; updated_at/updated_by — от более
поздней; временная строка удаляется."""
if old_key == new_key:
return
with db.transaction(conn):
old = get_note(conn, old_key)
if old is None:
return
cur = get_note(conn, new_key)
if cur is None:
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))
return
merged = [old[f] if old[f] not in (None, "") else cur[f] for f in NOTE_FIELDS]
newer = old if (old["updated_at"] or "") >= (cur["updated_at"] or "") else cur
conn.execute(f"UPDATE issue_note SET {', '.join(f'{f} = ?' for f in NOTE_FIELDS)}, updated_at = ?, updated_by = ? "
"WHERE key = ?", merged + [newer["updated_at"], newer["updated_by"], new_key])
conn.execute("DELETE FROM issue_note WHERE key = ?", (old_key,))