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:
parent
bf73299732
commit
817c761fb2
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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_Блокер_Начала"}]
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user