From bf73299732b38e6ef077b2e6c0db48d191f58d3a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:46:41 +0400 Subject: [PATCH] =?UTF-8?q?feat(queue):=20409=20=D0=BD=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D1=91=D1=82=20=D0=BF=D0=BE=20=D0=BD=D0=B5=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D0=B5,=20=D0=BA=D0=BB=D1=8E=D1=87=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BF=D0=BE=D0=B7=D0=B4=D0=BD=D0=B5=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20=D0=BF=D0=BE=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=D0=BD=D0=BE=D0=B9=20ensure?= =?UTF-8?q?=5Fissue,=20422=20=D0=BD=D0=B0=20=D0=B4=D1=83=D0=B1=D0=BB=D1=8C?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BC=D1=8B=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/api.py | 2 ++ src/jira_hub/commands.py | 5 +++++ src/jira_hub/queue.py | 14 ++++++++++++++ tests/test_api.py | 16 ++++++++++++++++ tests/test_commands.py | 10 ++++++++++ tests/test_queue.py | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 81 insertions(+) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 6251d20..163aad5 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -104,6 +104,8 @@ def h_agent_result(hub, conn, m, q, body): body.get("jira_calls", 0), body.get("rate_limited", 0)) except KeyError: raise ApiError(404, "not_found", "command") + except queue.NotClaimedError as e: + raise ApiError(409, "not_claimed", str(e)) except ValueError as e: raise ApiError(422, "bad_status", str(e)) return 200, cmd diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index d48ac57..320f6b3 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -85,6 +85,11 @@ def validate(commands) -> list[dict]: if ctype == "ensure_issue": defined.add(params["temp_key"]) out.append({"type": ctype, "params": params}) + summaries = [c["params"]["summary"].strip() for c in out if c["type"] == "ensure_issue"] + repeated = sorted({s for s in summaries if summaries.count(s) > 1}) + if repeated: + raise CommandError("повторяется тема ensure_issue: " + ", ".join(f"«{s}»" for s in repeated) + + " — в Jira получится одна задача на два временных ключа") return out diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 95bba99..3b265f4 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -22,6 +22,10 @@ class ClaimedError(Exception): self.claimed_at = claimed_at +class NotClaimedError(Exception): + """Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409.""" + + def _cmd(row) -> dict: d = dict(row) d["params"] = json.loads(d["params"]) @@ -212,7 +216,17 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= cmd = get_command(conn, command_id) if cmd is None: raise KeyError(command_id) + if cmd["status"] == "queued": + raise NotClaimedError(f"команда {command_id} не выдана") if cmd["status"] in TERMINAL: + # поздний отчёт о созданной задаче по отменённой команде: ключ не теряем, статус не трогаем (M-5) + if (cmd["status"] == "cancelled" and cmd["type"] == "ensure_issue" and status == "done" + and result and result.get("key") and not cmd["result"]): + conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", + (_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id)) + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + log.warning("команда %s отменена, но агент создал %s — ключ записан в результат", command_id, result["key"]) + return get_command(conn, command_id) return cmd # идемпотентно; cancelled не затирается now = db.now() conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", diff --git a/tests/test_api.py b/tests/test_api.py index a06a168..d48517a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -313,3 +313,19 @@ def test_relay_dir_put_validation(hub): assert c.put("/settings", {"relay_dir": "/tmp/jira-bridge"})["settings_version"] == before + 1 assert c.put("/settings", {"relay_dir": None})["settings_version"] == before + 2 assert "relay_dir" not in hub["agent"].get("/agent/settings") + + +def test_result_for_queued_command_is_409(hub): + b = hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"] + with pytest.raises(HubError) as e: + hub["agent"].post(f"/agent/commands/{cid}/result", {"status": "done"}) + assert e.value.status == 409 and e.value.error == "not_claimed" + + +def test_duplicate_summary_batch_is_422(hub): + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "x", "commands": [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Шахматка: комментарии"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Шахматка: комментарии"}}]}) + assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail diff --git a/tests/test_commands.py b/tests/test_commands.py index c42aa58..b087f17 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -149,3 +149,13 @@ def test_ensure_issue_two_matches_takes_newest(): 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(): + cmds = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Отчёт"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": " Отчёт "}}, + {"type": "ensure_issue", "params": {"temp_key": "new:3", "issue_type": "task", "summary": "отчёт"}}] + with pytest.raises(commands.CommandError) as e: + commands.validate(cmds) + assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value) + commands.validate([cmds[0], cmds[2]]) # регистр различается — пакет допустим diff --git a/tests/test_queue.py b/tests/test_queue.py index c492055..5f2d0df 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -350,3 +350,37 @@ def test_migrated_claimed_is_redelivered(): conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,)) assert queue.claim(conn)["id"] == cid assert queue.get_command(conn, cid)["deliveries"] == 2 + + +def test_report_for_queued_command_rejected(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] + with pytest.raises(queue.NotClaimedError): + queue.report_result(conn, cid, "done") + assert queue.get_command(conn, cid)["status"] == "queued" + + +def test_late_report_after_force_cancel_keeps_issue_key(): + conn = fresh() + mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import") + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + cancelled = queue.cancel_command(conn, c["id"], force=True) + late = queue.report_result(conn, c["id"], "done", result={"key": "A-20", "created": True}, jira_calls=3) + assert late["status"] == "cancelled" and late["result"] == {"key": "A-20", "created": True} and late["jira_calls"] == 3 + assert late["finished_at"] == cancelled["finished_at"] + assert mirror.get_note(conn, "A-20")["fact_status"] == "В работе" and mirror.get_note(conn, "new:7") is None + again = queue.report_result(conn, c["id"], "done", result={"key": "A-21"}) + assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет + + +def test_late_failed_report_after_force_cancel_changes_nothing(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + queue.cancel_command(conn, c["id"], force=True) + late = queue.report_result(conn, c["id"], "failed", error="HTTP 400") + assert late["status"] == "cancelled" and late["result"] is None and late["error"] is None