feat(hub): зеркало задач, связи, служебные заметки

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 21:53:13 +04:00
parent f082541566
commit e981d62620
2 changed files with 185 additions and 0 deletions

114
src/jira_hub/mirror.py Normal file
View File

@ -0,0 +1,114 @@
"""Зеркало задач 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) -> dict:
"""Upsert задач; связи с source_key среди присланных задач заменяются присланными.
Связи, у которых источник не прислан, не трогаются до загрузки их источника."""
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)
conn.execute(
f"INSERT INTO issue({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols if c != "key"),
[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))}")
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:
"""После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом,
если вдруг была, уступает место)."""
if get_note(conn, old_key) is None:
return
conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,))
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))

71
tests/test_mirror.py Normal file
View File

@ -0,0 +1,71 @@
import pytest
from jira_hub import db, mirror
def issue(key, **over):
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
"duedate": "2026-10-23", "planned_start": None, "planned_end": "2026-10-23", "epic_key": "AURORA-1350",
"labels": ["AWPCA"], "sprint": "Спринт 2", "created": "2026-10-10T12:00:00.000+0300",
"jira_updated": "2026-10-11T12:00:00.000+0300"}
base.update(over)
return base
def test_apply_state_upserts_and_replaces_links():
conn = db.connect(":memory:")
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
assert out == {"issues": 2, "links": 1}
mirror.apply_state(conn, [issue("A-1", summary="Новая тема")],
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
got = mirror.get_issue(conn, "A-1")
assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"]
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные"}]
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
def test_links_from_other_source_survive():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
def test_apply_state_marks_neighbors():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
assert mirror.get_issue(conn, "A-1")["neighbor"] is False
mirror.apply_state(conn, [issue("B-9")], []) # вошла в базовую JQL
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
assert mirror.known_keys(conn) == ["A-1", "B-9"]
def test_note_survives_refresh_and_renames():
conn = db.connect(":memory:")
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
mirror.rename_note(conn, "new:7", "A-5")
mirror.apply_state(conn, [issue("A-5")], [])
got = mirror.get_issue(conn, "A-5")
assert got["note"]["fact_status"] == "В работе" and got["note"]["updated_by"] == "import"
assert mirror.get_note(conn, "new:7") is None
with pytest.raises(ValueError):
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
def test_list_issues_filters():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
issue("A-3", sprint="Спринт 2", duedate=None)], [])
mirror.set_note(conn, "A-3", {"work_kind": "тестирование"}, "x")
keys = [i["key"] for i in mirror.list_issues(conn, {"sprint": "Спринт 2"})]
assert keys == ["A-1", "A-3"] # без срока — последней
assert [i["key"] for i in mirror.list_issues(conn, {"label": "fact-done"})] == ["A-2"]
assert [i["key"] for i in mirror.list_issues(conn, {"work_kind": "тестирование"})] == ["A-3"]
assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"]
assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None
assert len(mirror.list_issues(conn, None, limit=1)) == 1