From a09b7943024b3decdb054caba266409705a3a662 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:07:52 +0400 Subject: [PATCH] =?UTF-8?q?fix(queue):=20ensure=5Fissue=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8E=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B0=20(R-11,=20I-1);=20reconci?= =?UTF-8?q?le=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D1=82=D0=B8=D1=80=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=83=20(M-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/jira_hub/commands.py | 15 ++++++++------- src/jira_hub/queue.py | 10 +++++++--- tests/test_commands.py | 14 +++++++++++--- tests/test_queue.py | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index 913f558..d48ac57 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -108,17 +108,18 @@ def estimate(types, pacing: dict) -> int: def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]: """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются. - reporter: если задан, ensure_issue подтверждается только задачей, заведённой этим пользователем.""" + reporter: логин Jira, под которым агент заводит задачи. ensure_issue подтверждается ТОЛЬКО задачей этого + автора; пока reporter неизвестен (None), по состоянию не подтверждается (R-11): совпадение темы с чужой + задачей не доказательство, а запись в чужую задачу недопустима.""" ctype, p = cmd["type"], cmd["params"] if ctype in ("ensure_comment", "refresh"): return False, None if ctype == "ensure_issue": - sql = "SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days')" - args = [p["summary"]] - if reporter is not None: - sql += " AND reporter = ?" - args.append(reporter) - row = conn.execute(sql + " ORDER BY created DESC LIMIT 1", args).fetchone() + 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: diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index ed45ff7..2977e5c 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -206,7 +206,8 @@ 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") + 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) @@ -214,8 +215,11 @@ def reconcile(conn) -> list[int]: if not ok: continue with db.transaction(conn): - conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? WHERE id = ?", - (db.now(), _dump(result) if result else None, cmd["id"])) + cur = conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? " + "WHERE id = ? AND status = 'claimed'", + (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"]) diff --git a/tests/test_commands.py b/tests/test_commands.py index 6518e5a..c42aa58 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -111,17 +111,25 @@ def _ensure_issue(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) + 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) + 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.""" + 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) + ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") assert ok is False diff --git a/tests/test_queue.py b/tests/test_queue.py index fe697d3..b092239 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -225,3 +225,43 @@ def test_reconcile_respects_reporter_setting(): 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) + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],)) + return c + + +def test_reconcile_does_not_confirm_ensure_issue_without_reporter(): + """R-11: настройка reporter не задана — совпадение темы не подтверждает, заметка new:N не переезжает.""" + conn = fresh() + c = _claimed_ensure_issue(conn) + mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t") + created = db.utcnow().isoformat() + mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], []) + assert queue.reconcile(conn) == [] + assert queue.get_command(conn, c["id"])["status"] == "claimed" + assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None + + +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": []}], []) + real = queue.C.check_confirmed + + def racing(*a, **kw): + out = real(*a, **kw) + queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки + return out + + 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 не выполнялся