feat: ensure_issue закрывается довыдачей, не сверкой; reversed_links в отчёте импорта (R-11, O-3, #9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 22:48:57 +04:00
parent bf73299732
commit 817c761fb2
7 changed files with 38 additions and 83 deletions

View File

@ -40,7 +40,7 @@ uv run jira-hub import ../admin/jira-sync
Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON:
`notes` (сколько заметок записано), `expected_links`, `missing_keys` (ключи из CSV, которых нет в зеркале) и
`missing_links` (ожидаемые связи, которых нет). Запускать после первого `refresh` с `full: true`: пока зеркало пусто,
`missing_links` (ожидаемые связи, которых нет), `reversed_links` (ожидаемые связи, которые в Jira стоят в обратную сторону — для «Связанных» это та же связь). Запускать после первого `refresh` с `full: true`: пока зеркало пусто,
все ключи будут в `missing_keys`. Заметки к ещё не загруженным задачам сохраняются.
## MCP в Claude

View File

@ -111,21 +111,13 @@ def estimate(types, pacing: dict) -> int:
return sum(int(pacing.get(t, 0)) for t in types)
def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.
reporter: логин Jira, под которым агент заводит задачи. ensure_issue подтверждается ТОЛЬКО задачей этого
автора; пока reporter неизвестен (None), по состоянию не подтверждается (R-11): совпадение темы с чужой
задачей не доказательство, а запись в чужую задачу недопустима."""
def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются:
потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability,
решение 4); совпадение темы с задачей неизвестного автора не доказательство."""
ctype, p = cmd["type"], cmd["params"]
if ctype in ("ensure_comment", "refresh"):
if ctype in ("ensure_issue", "ensure_comment", "refresh"):
return False, None
if ctype == "ensure_issue":
if reporter is None:
return False, None
row = conn.execute("SELECT key FROM issue WHERE summary = ? AND reporter = ? "
"AND substr(created, 1, 10) >= date('now', '-7 days') ORDER BY created DESC LIMIT 1",
(p["summary"], reporter)).fetchone()
return (True, {"key": row["key"]}) if row else (False, None)
issue = mirror.get_issue(conn, p.get("key") or p.get("source"))
if issue is None:
return False, None

View File

@ -69,7 +69,7 @@ def _import(conn, folder: Path) -> dict:
csv_keys.append(key)
link_types = settings.get(conn, "link_types") or {}
expected, missing_links = [], []
expected, missing_links, reversed_links = [], [], []
for r in _rows(folder / "links.csv"):
src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда")))
dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда")))
@ -79,10 +79,14 @@ def _import(conn, folder: Path) -> dict:
link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)}
expected.append(link)
issue = mirror.get_issue(conn, src)
present = issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"])
if not present:
if issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"]):
continue
if issue and any(l["source_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_in"]):
reversed_links.append(link) # связь есть, но в Jira стоит в обратную сторону (O-3)
else:
missing_links.append(link)
known = set(mirror.known_keys(conn))
missing_keys = sorted({k for k in csv_keys if k not in known})
return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links}
return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links,
"reversed_links": reversed_links}

View File

@ -251,12 +251,10 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
def reconcile(conn) -> list[int]:
"""Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state."""
link_types = settings.get(conn, "link_types") or {}
reporter = settings.get(conn, "reporter") # задел: сейчас не задаётся, ensure_issue по состоянию не подтверждается (R-11);
# позже сюда придёт jira_user от агента
changed = []
for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall():
cmd = _cmd(row)
ok, result = C.check_confirmed(conn, cmd, link_types, reporter)
ok, result = C.check_confirmed(conn, cmd, link_types)
if not ok:
continue
with db.transaction(conn):
@ -265,8 +263,6 @@ def reconcile(conn) -> list[int]:
(db.now(), _dump(result) if result else None, cmd["id"]))
if cur.rowcount == 0: # отчёт агента или отмена опередили сверку
continue
if cmd["type"] == "ensure_issue" and result:
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
changed.append(cmd["id"])
return changed

View File

@ -108,47 +108,11 @@ def _ensure_issue(summary):
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
def test_check_confirmed_issue_by_summary():
conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
assert ok and result == {"key": "A-1"}
ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT, reporter="petrov")
assert not ok
def test_ensure_issue_without_reporter_never_confirmed():
"""R-11: пока автор неизвестен, совпадение темы не подтверждает ensure_issue."""
def test_ensure_issue_never_confirmed_by_state():
"""bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом."""
conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter=None) == (False, None)
def test_ensure_issue_older_than_7_days_not_confirmed():
conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-30 days') WHERE key = 'A-1'")
ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
assert ok is False
def test_ensure_issue_reporter_filter():
conn = _mirror()
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="ivanov")
assert ok is False
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
assert ok and result == {"key": "A-1"}
def test_ensure_issue_two_matches_takes_newest():
conn = _mirror()
mirror.apply_state(conn, [{"key": "A-3", "summary": "Тема", "reporter": "petrov"}], [])
conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-1 day') WHERE key = 'A-1'")
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-3'")
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
assert ok and result == {"key": "A-3"}
def test_validate_rejects_duplicate_issue_summary():

View File

@ -48,6 +48,7 @@ def test_import_notes_and_report(tmp_path):
assert rep["missing_keys"] == ["AURORA-1350", "AURORA-1416", "AURORA-1529"]
assert rep["expected_links"] == 2 # new:3 без ключа — не сверяется
assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}]
assert rep["reversed_links"] == []
assert importer.import_package(conn, tmp_path)["notes"] == 6
assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6
@ -98,4 +99,19 @@ def test_missing_csv_files_give_empty_report(tmp_path):
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
rep = importer.import_package(conn, tmp_path)
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": []}
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []}
def test_reversed_link_reported_separately(tmp_path):
make_package(tmp_path)
write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"],
[["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"],
["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]])
conn = db.connect(":memory:")
settings.ensure_defaults(conn)
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
rep = importer.import_package(conn, tmp_path)
assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}]
assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}]

View File

@ -205,20 +205,6 @@ def test_make_backup_rotates(tmp_path):
assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
def test_reconcile_respects_reporter_setting():
conn = fresh()
settings.set_(conn, "reporter", "robot")
queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}])
c = queue.claim(conn)
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
created = db.utcnow().isoformat()
mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], [])
assert queue.reconcile(conn) == [] # чужая задача не подтверждает
mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", "created": created, "labels": []}], [])
assert queue.reconcile(conn) == [c["id"]]
assert queue.get_command(conn, c["id"])["result"] == {"key": "A-8"}
def _claimed_ensure_issue(conn, summary="Тема"):
queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}])
c = queue.claim(conn)
@ -227,7 +213,7 @@ def _claimed_ensure_issue(conn, summary="Тема"):
def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
"""R-11: настройка reporter не задана — совпадение темы не подтверждает, заметка new:N не переезжает."""
"""ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""
conn = fresh()
c = _claimed_ensure_issue(conn)
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
@ -241,11 +227,9 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
def test_reconcile_does_not_overwrite_finished_command(monkeypatch):
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
conn = fresh()
settings.set_(conn, "reporter", "robot")
c = _claimed_ensure_issue(conn)
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot",
"created": db.utcnow().isoformat(), "labels": []}], [])
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
c = queue.claim(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
real = queue.C.check_confirmed
def racing(*a, **kw):
@ -256,7 +240,6 @@ def test_reconcile_does_not_overwrite_finished_command(monkeypatch):
monkeypatch.setattr(queue.C, "check_confirmed", racing)
assert queue.reconcile(conn) == []
assert queue.get_command(conn, c["id"])["status"] == "cancelled"
assert mirror.get_note(conn, "new:1") is not None # rename_note не выполнялся
def _two_batches_with_dependent(conn):