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>
285 lines
18 KiB
Python
285 lines
18 KiB
Python
"""Зеркало задач 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,))
|