sdlc-hub/src/jira_hub/mirror.py
Andrey Epifancev 7a2f2e694b fix(mirror): rename_note сливает заметки, а не удаляет заметку под ключом (#13)
M-3 (hub#4, PR #2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:39:49 +04:00

133 lines
7.3 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_note).
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json
from . import db
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"]
_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"])
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
def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]:
filters = filters or {}
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("i.neighbor = ?")
args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0)
if filters.get("q"):
where.append("(i.summary LIKE ? OR i.key LIKE ?)")
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
sql = ("SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key"
+ (" WHERE " + " AND ".join(where) if where else "")
+ " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?")
return [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])]
def get_issue(conn, key) -> dict | None:
row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone()
if row is None:
return None
d = _issue_dict(row)
d["links_out"] = [dict(r) for r in conn.execute(
"SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))]
d["links_in"] = [dict(r) for r in conn.execute(
"SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (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 ? ORDER BY id DESC LIMIT 10",
(f'%"{key}"%',))]
return d
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,))