sdlc-hub/tests/test_queue.py
Andrey Epifancev 07357732da Хвост пакета со спринтом — refresh по JQL проекта (#108)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 19:23:46 +04:00

1294 lines
74 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import hashlib
import pytest
from sdlc_hub.core import api, db, files, queue, settings
from sdlc_hub.modules.jira import attachments, commands, mirror, refresh
from sdlc_hub.modules.jira import fetch as jira_fetch
from sdlc_hub.modules.jira import projects as jira_projects
from sdlc_hub.modules.jira.commands import CommandError
from sdlc_hub.core.queue import ClaimedError
LT = {"связанные": "ASUPR_Связанные"}
@pytest.fixture
def conn(open_db):
"""База очереди в памяти с настройками по умолчанию и типами связей LT; закрывается после теста."""
c = open_db()
settings.ensure_defaults(c)
settings.set_(c, "link_types", LT)
return c
def fields(key, **extra):
return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}}
def test_create_batch_adds_tail_refresh_and_estimate(conn):
b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}])
view = queue.batch_view(conn, b["id"])
assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"]
assert view["commands"][2]["params"] == {"keys": ["A-1", "A-2"]}
assert b["estimate_seconds"] == 120 + 30 + 2 and b["count"] == 3
assert view["status"] == "queued" and view["author"] == "claude"
def test_refresh_only_batch_has_no_tail(conn):
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
assert b["count"] == 1
with pytest.raises(CommandError):
queue.create_batch(conn, "x", "claude", [{"type": "nope"}])
def test_claim_order_pause_and_report(conn):
b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")])
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
c = queue.claim(conn, "jira", agent_version=1, settings_version=1)
assert c["type"] == "ensure_fields" and c["params"]["key"] == "A-1" and c["pause_seconds"] == 120 and c["batch_id"] == b1["id"]
again = queue.claim(conn, "jira") # ответ потерян — та же команда снова
assert again["id"] == c["id"] and again["pause_seconds"] == 120
assert queue.get_command(conn, c["id"])["deliveries"] == 2
assert queue.agent_view(conn, "jira")["current_command_id"] == c["id"]
done = queue.report_result(conn, c["id"], "done", result={"changed": ["duedate"]}, jira_calls=2)
assert done["status"] == "done" and done["jira_calls"] == 2
again = queue.report_result(conn, c["id"], "failed", error="поздно")
assert again["status"] == "done" # повторный отчёт не меняет
tail = queue.claim(conn, "jira")
assert tail["type"] == "refresh" and tail["pause_seconds"] == 2
assert conn.execute("SELECT kind, query FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()["query"] == "A-1"
refresh.record_state(conn, tail["id"], 1, 0)
queue.report_result(conn, tail["id"], "done", jira_calls=1)
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()
assert run["finished_at"] and run["issues_count"] == 1 and run["jira_calls"] == 1
assert queue.batch_view(conn, b1["id"])["status"] == "done"
assert queue.claim(conn, "jira")["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
def test_claim_is_exclusive(tmp_path, open_db):
import threading
path = tmp_path / "hub.sqlite"
setup = open_db(path)
settings.ensure_defaults(setup)
queue.create_batch(setup, "x", "claude", [fields("A-1")])
conns = [open_db(path), open_db(path)]
barrier, results = threading.Barrier(2), []
def worker(c):
barrier.wait()
results.append(queue.claim(c, "jira"))
threads = [threading.Thread(target=worker, args=(c,)) for c in conns]
[t.start() for t in threads]
[t.join() for t in threads]
got = [r for r in results if r is not None]
assert got and len({r["id"] for r in got}) == 1 # один и тот же id при любом порядке потоков
row = setup.execute("SELECT COUNT(*) AS n, MAX(deliveries) AS d FROM command WHERE status = 'claimed'").fetchone()
assert row["n"] == 1 and row["d"] == len(got)
def test_backup_failure_does_not_block_claim(conn, caplog):
queue.create_batch(conn, "x", "claude", [fields("A-1")])
def broken(label):
raise OSError("диск полон")
c = queue.claim(conn, "jira", backup=broken)
assert c is not None and c["params"]["key"] == "A-1"
assert queue.get_command(conn, c["id"])["status"] == "claimed"
assert "не создана" in caplog.text
def test_temp_key_substitution_and_skip(conn, add_project):
add_project(conn)
mirror.set_note(conn, "new:60", {"rest_text": "В работе"}, "import")
queue.create_batch(conn, "создать", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
{"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая",
"labels": ["AWPCA"]}},
{"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}},
])
c1 = queue.claim(conn, "jira")
queue.report_result(conn, c1["id"], "done", result={"key": "A-90"})
assert mirror.get_note(conn, "A-90")["rest_text"] == "В работе" and mirror.get_note(conn, "new:60") is None
c2 = queue.claim(conn, "jira")
assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90"
queue.report_result(conn, c2["id"], "done")
c3 = queue.claim(conn, "jira")
queue.report_result(conn, c3["id"], "failed", error="HTTP 400")
tail = queue.claim(conn, "jira") # ensure_sprint пропущена, refresh без new:61
assert tail["type"] == "refresh" and tail["params"]["keys"] == ["A-90", "A-1"]
view = queue.batch_view(conn, c1["batch_id"])
assert [c["status"] for c in view["commands"]][:4] == ["done", "done", "failed", "skipped"]
assert view["commands"][3]["error"] == "задача new:61 не создана"
def test_cancel_and_retry(conn):
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn, "jira")
with pytest.raises(ClaimedError):
queue.cancel_command(conn, c["id"])
view = queue.cancel_batch(conn, b["id"])
assert [x["status"] for x in view["commands"]] == ["claimed", "cancelled", "cancelled"]
assert queue.claim(conn, "jira")["id"] == c["id"] # взятая из отменённого пакета довыдаётся
forced = queue.cancel_command(conn, c["id"], force=True)
assert forced["status"] == "cancelled"
after = queue.report_result(conn, c["id"], "done", result={"changed": []})
assert after["status"] == "cancelled" and after["result"] is None # отчёт после force-отмены не затирает
assert queue.batch_view(conn, b["id"])["status"] == "cancelled"
r = queue.retry_command(conn, c["id"], "claude")
assert queue.batch_view(conn, r["id"])["title"] == f"Повтор команды {c['id']}"
assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"]
def test_report_after_force_cancel_keeps_cancelled(conn):
queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn, "jira")
queue.cancel_command(conn, c["id"], force=True)
assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled"
def test_reconcile_confirms_by_state(conn, add_project):
add_project(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], [])
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
c = queue.claim(conn, "jira")
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.batch_view(conn, b["id"])["commands"][0]
conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, deliveries = 1 WHERE id = ?", (db.now(), r["id"]))
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
queue.report_result(conn, r["id"], "done")
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"
assert queue.batch_view(conn, b["id"])["status"] == "done"
def test_auto_refresh_needs_projects(conn):
queue.claim(conn, "jira", agent_version=1, settings_version=1)
assert refresh.maybe_auto_refresh(conn) is None # агент на связи, очередь пуста, проектов нет
def test_auto_refresh_two_projects(conn, add_project): # сценарий «Два проекта»
add_project(conn)
add_project(conn, "STOCKS_DRRK", board_id=None)
queue.claim(conn, "jira", agent_version=1, settings_version=1)
b = refresh.maybe_auto_refresh(conn)
assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}
def test_auto_refresh_conditions(conn, add_project):
add_project(conn)
assert refresh.maybe_auto_refresh(conn) is None # агент не на связи
queue.claim(conn, "jira", agent_version=1, settings_version=1) # отметился, очередь пуста
b = refresh.maybe_auto_refresh(conn)
assert b and queue.batch_view(conn, b["id"])["title"] == "Автообновление"
assert refresh.maybe_auto_refresh(conn) is None # очередь занята
c = queue.claim(conn, "jira")
queue.report_result(conn, c["id"], "done")
assert refresh.maybe_auto_refresh(conn) is None # интервал не прошёл
conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'")
assert refresh.maybe_auto_refresh(conn) is not None
settings.set_(conn, "auto_refresh_minutes", 0)
conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'")
assert refresh.maybe_auto_refresh(conn) is None
def test_backup_called_for_first_write_only(conn, tmp_path):
calls = []
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c1 = queue.claim(conn, "jira", backup=calls.append)
queue.report_result(conn, c1["id"], "done")
queue.claim(conn, "jira", backup=calls.append)
assert calls == [f"batch-{c1['batch_id']}"]
def test_no_backup_for_refresh_batch(conn):
calls = []
queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
queue.claim(conn, "jira", backup=calls.append)
assert calls == []
def test_make_backup_rotates(tmp_path, open_db):
from sdlc_hub.core import backup
conn = open_db(tmp_path / "hub.sqlite")
for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
assert len(names) == 2 and names[-1].endswith("-b3.sqlite")
assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2
def test_rotation_keeps_schema_migration_copy(tmp_path, open_db):
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
from sdlc_hub.core import backup
conn = open_db(tmp_path / "hub.sqlite")
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2)
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
assert len([n for n in names if n.endswith("-schema-v1.sqlite")]) == 1
assert len([n for n in names if "-batch-" in n]) == 2
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, "jira")
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(conn):
"""ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""
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(conn, monkeypatch, add_project):
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
add_project(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], [])
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
c = queue.claim(conn, "jira")
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": ["AWPCA"]}], [])
real = commands.check_confirmed
def racing(*a, **kw):
out = real(*a, **kw)
queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки
return out
monkeypatch.setattr(commands, "check_confirmed", racing)
assert queue.reconcile(conn) == []
assert queue.get_command(conn, c["id"])["status"] == "cancelled"
def _two_batches_with_dependent(conn):
a = queue.create_batch(conn, "A", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}},
{"type": "ensure_link", "params": {"source": "new:1", "target": "A-1", "link_type": "связанные"}}])
first = queue.claim(conn, "jira")
assert first["type"] == "ensure_issue"
b = queue.create_batch(conn, "B", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
return a, b, first
def test_redelivered_definer_blocks_dependents(conn):
a, b, first = _two_batches_with_dependent(conn)
assert queue.claim(conn, "jira")["id"] == first["id"] # довыдача определителя, зависимые ждут
conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],))
nxt = queue.claim(conn, "jira")
assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9"
def test_auto_refresh_waits_for_claimed_without_report(conn):
queue.claim(conn, "jira", agent_version=1, settings_version=1) # агент на связи
queue.create_batch(conn, "y", "claude", [fields("A-3")])
queue.claim(conn, "jira") # выдана, отчёта нет
conn.execute("DELETE FROM command WHERE status = 'queued'") # хвостовой refresh не мешает проверке
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE status = 'claimed'") # давно без отчёта
assert refresh.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята
def test_redelivery_of_lost_claim_before_queue(conn):
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn, "jira")
again = queue.claim(conn, "jira")
assert again["id"] == c["id"] and again["params"] == c["params"]
assert queue.agent_view(conn, "jira")["current_command_id"] == c["id"]
view = queue.agent_view(conn, "jira")["redelivered"]
assert [(x["id"], x["deliveries"]) for x in view] == [(c["id"], 2)]
queue.report_result(conn, c["id"], "done")
assert queue.claim(conn, "jira")["params"]["key"] == "A-2"
assert queue.agent_view(conn, "jira")["redelivered"] == []
def test_redelivery_limit_fails_command(conn):
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn, "jira")
queue.claim(conn, "jira")
queue.claim(conn, "jira") # третья выдача
nxt = queue.claim(conn, "jira") # четвёртой нет — failed и дальше по очереди
assert nxt["params"]["key"] == "A-2"
failed = queue.get_command(conn, c["id"])
assert failed["status"] == "failed" and failed["error"] == "агент не отчитался после 3 выдач" and failed["finished_at"]
assert queue.MAX_DELIVERIES == 3
def test_redelivery_oldest_first(conn):
queue.create_batch(conn, "x", "claude", [fields("A-1")])
queue.create_batch(conn, "y", "claude", [fields("A-2")])
rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall()
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-13T10:00:00+00:00', deliveries = 1 WHERE id = ?", (rows[1]["id"],))
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-13T11:00:00+00:00', deliveries = 1 WHERE id = ?", (rows[0]["id"],))
assert queue.claim(conn, "jira")["id"] == rows[1]["id"]
def test_redelivered_refresh_keeps_one_sync_run(conn):
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn, "jira")
assert queue.claim(conn, "jira")["id"] == r["id"]
assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1
def test_redelivery_does_not_repeat_backup(conn):
queue.create_batch(conn, "x", "claude", [fields("A-1")])
labels = []
queue.claim(conn, "jira", backup=labels.append)
queue.claim(conn, "jira", backup=labels.append)
assert len(labels) == 1
def test_migrated_claimed_is_redelivered(conn):
"""Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2."""
queue.create_batch(conn, "x", "claude", [fields("A-1")])
cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"]
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, "jira")["id"] == cid
assert queue.get_command(conn, cid)["deliveries"] == 2
def test_report_for_queued_command_rejected(conn):
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):
mirror.set_note(conn, "new:7", {"rest_text": "В работе"}, "import")
queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
c = queue.claim(conn, "jira")
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")["rest_text"] == "В работе" 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):
queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
c = queue.claim(conn, "jira")
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
def _fail_by_limit(conn, cid):
for _ in range(queue.MAX_DELIVERIES - 1):
assert queue.claim(conn, "jira")["id"] == cid
queue.claim(conn, "jira") # четвёртый claim: cid → failed по пределу
assert queue.get_command(conn, cid)["status"] == "failed"
def test_error_is_trimmed_to_1500(conn):
queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn, "jira")
out = queue.report_result(conn, c["id"], "failed", error="е" * 5000)
assert out["error"] == "е" * 1500 and queue.ERROR_LIMIT == 1500
def test_late_done_after_delivery_limit_keeps_issue_key(conn):
mirror.set_note(conn, "new:8", {"rest_text": "План"}, "import")
queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
c = queue.claim(conn, "jira")
_fail_by_limit(conn, c["id"])
late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2)
assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True}
assert late["error"] == "агент не отчитался после 3 выдач"
assert mirror.get_note(conn, "A-30")["rest_text"] == "План" and mirror.get_note(conn, "new:8") is None
again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"})
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
def test_cancelled_batch_with_done_command_is_cancelled(conn):
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2"), fields("A-3")])
first = queue.claim(conn, "jira")
queue.report_result(conn, first["id"], "done", result={"changed": []})
second = queue.claim(conn, "jira")
queue.cancel_batch(conn, b["id"])
queue.report_result(conn, second["id"], "done", result={"changed": []})
view = queue.batch_view(conn, b["id"])
assert view["status"] == "cancelled"
assert view["counts"]["done"] == 2
def test_empty_full_refresh_keeps_neighbors(conn, add_project):
add_project(conn)
jql = jira_projects.base_jql(conn)
mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [],
fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
r = queue.claim(conn, "jira")
queue.report_result(conn, r["id"], "done") # загрузок состояния не было: 0 задач
assert conn.execute("SELECT neighbor FROM issue WHERE key = 'A-1'").fetchone()["neighbor"] == 0
def test_cancel_finished_batch_stays_done(conn):
b = queue.create_batch(conn, "x", "claude", [fields("A-1")])
while (c := queue.claim(conn, "jira")):
queue.report_result(conn, c["id"], "done", result={"changed": []})
view = queue.cancel_batch(conn, b["id"])
assert view["status"] == "done"
assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None
def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project):
add_project(conn)
jql = jira_projects.base_jql(conn)
mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00")
queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}])
r = queue.claim(conn, "jira")
refresh.record_state(conn, r["id"], 1, 0)
mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], [])
assert queue.claim(conn, "jira")["id"] == r["id"] # отчёт потерян — довыдача
assert conn.execute("SELECT deliveries FROM command WHERE id = ?", (r["id"],)).fetchone()["deliveries"] == 2
refresh.record_state(conn, r["id"], 1, 0)
mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], [])
queue.report_result(conn, r["id"], "done")
nb = {row["key"]: row["neighbor"] for row in conn.execute("SELECT key, neighbor FROM issue")}
assert nb == {"OLD": 1, "NEW": 0}
assert conn.execute("SELECT issues_count FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["issues_count"] == 1
def test_redelivered_refresh_resets_counts(conn):
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn, "jira")
refresh.record_state(conn, r["id"], 150, 40)
started = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["started_at"]
assert queue.claim(conn, "jira")["id"] == r["id"] # отчёт потерян — довыдача
refresh.record_state(conn, r["id"], 150, 40)
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()
assert (run["issues_count"], run["links_count"]) == (150, 40)
assert run["started_at"] == started
def test_refresh_failed_by_limit_finishes_sync_run(conn):
queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
r = queue.claim(conn, "jira")
for _ in range(queue.MAX_DELIVERIES):
queue.claim(conn, "jira")
assert queue.get_command(conn, r["id"])["status"] == "failed"
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["finished_at"]
def test_cancel_batch_reports_still_claimed(conn):
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
c = queue.claim(conn, "jira")
view = queue.cancel_batch(conn, b["id"])
assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}]
assert queue.get_command(conn, c["id"])["status"] == "claimed"
def test_dependent_skipped_when_creation_cancelled(conn):
"""O-10 #16, решение 10, спека «Создание отменено»: зависимая команда — «создание задачи new:N отменено»."""
b = queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:901", "issue_type": "task", "summary": "Отменённая"}},
{"type": "ensure_comment", "params": {"key": "new:901", "marker": "m", "body": "b"}}])
c = queue.claim(conn, "jira")
queue.cancel_command(conn, c["id"], force=True)
assert queue.claim(conn, "jira") is None # комментарий и хвостовой refresh пропущены
cmds = queue.batch_view(conn, b["id"])["commands"]
assert [x["status"] for x in cmds] == ["cancelled", "skipped", "skipped"]
assert cmds[1]["error"] == "создание задачи new:901 отменено"
def test_dependent_on_failed_and_cancelled_names_cancelled(conn):
"""Review Focus 4: два временных ключа — создатель первого failed, второго cancelled: текст по отменённому."""
b = queue.create_batch(conn, "x", "claude", [
{"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_link", "params": {"source": "new:1", "target": "new:2", "link_type": "связанные"}}])
first = queue.claim(conn, "jira")
queue.report_result(conn, first["id"], "failed", error="HTTP 400")
second = queue.claim(conn, "jira")
queue.cancel_command(conn, second["id"], force=True)
queue.claim(conn, "jira")
link = queue.batch_view(conn, b["id"])["commands"][2]
assert link["status"] == "skipped" and link["error"] == "создание задачи new:2 отменено"
def test_reconcile_confirms_fields_with_crlf(conn):
"""O-9 #16, решение 11, спека «Описание с CRLF в зеркале»: потерянный отчёт закрывается по состоянию."""
queue.create_batch(conn, "x", "claude", [{"type": "ensure_fields", "params": {"key": "A-1", "description": "a\nb"}}])
c = queue.claim(conn, "jira")
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], [])
assert queue.reconcile(conn) == [c["id"]]
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"
def test_delivery_limit_of_write_command_keeps_open_sync_run(conn):
"""T5.2 #16: предел выдач у не-refresh команды не закрывает журнал чужой загрузки (finished_at — только для refresh)."""
queue.create_batch(conn, "x", "claude", [fields("A-1")])
c = queue.claim(conn, "jira")
r = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-9"]}}])
rid = queue.batch_view(conn, r["id"])["commands"][0]["id"]
conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2099-01-01T00:00:00+00:00', deliveries = 1 "
"WHERE id = ?", (rid,))
conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, 'keys', 'A-9', ?)", (db.now(), rid))
for _ in range(queue.MAX_DELIVERIES - 1):
assert queue.claim(conn, "jira")["id"] == c["id"]
assert queue.claim(conn, "jira")["id"] == rid # c — failed по пределу, довыдаётся refresh
assert queue.get_command(conn, c["id"])["status"] == "failed"
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None
def bug(temp="new:1", summary="ОСВ 60 не распознана"):
return {"type": "ensure_issue", "params": {"temp_key": temp, "issue_type": "bug", "summary": summary,
"labels": ["AWPCA"]}}
SHA = hashlib.sha256(b"abc").hexdigest()
@pytest.fixture
def store(tmp_path):
"""Каталог хранилища с файлом «abc»."""
root = tmp_path / "files"
files.put(root, b"abc")
return root
def attach(key="A-1", file=SHA, filename="ОСВ 60.xlsx"):
return {"type": "ensure_attachment", "params": {"key": key, "file": file, "filename": filename}}
def test_bug_without_setting_rejected(conn):
"""Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан."""
settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}})
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "ошибка", "claude", [fields("A-1"), bug()])
assert str(e.value) == "команда 2: нет настройки issue_types.bug"
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
@pytest.mark.parametrize("value", [{"name": None, "id": None}, {"name": "", "id": ""}, "Ошибка", None])
def test_bug_with_empty_setting_rejected(conn, value):
settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": None}, "bug": value})
with pytest.raises(CommandError, match="нет настройки issue_types.bug"):
queue.create_batch(conn, "ошибка", "claude", [bug()])
@pytest.mark.parametrize("value", [{"name": "Ошибка", "id": None}, {"name": None, "id": "10102"}])
def test_bug_with_setting_accepted(conn, value):
settings.set_(conn, "issue_types", {"bug": value})
b = queue.create_batch(conn, "ошибка", "claude", [bug()])
assert b["count"] == 2 # ensure_issue + хвостовой refresh
def test_attachment_without_file_rejected(conn, store):
"""Спека commands «Вложение без загруженного файла»: 422, пакет не создан."""
missing = "0" * 64
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "вложение", "claude", [attach(file=missing)], files_dir=store)
assert str(e.value) == f"команда 1: файл {missing} не загружен: POST /files"
with pytest.raises(CommandError, match=f"файл {SHA} не загружен"):
queue.create_batch(conn, "вложение", "claude", [attach()]) # хранилище не передано
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
@pytest.mark.parametrize("params, msg", [
({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
({"filename": " "}, "команда 1: filename пустой"),
({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
({"filename": "ОСВ\n60.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
({"filename": "ОСВ\r.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
({"filename": "ОСВ\x7f.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
])
def test_attachment_params_rejected(conn, store, params, msg):
cmd = attach()
cmd["params"].update(params)
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "вложение", "claude", [cmd], files_dir=store)
assert str(e.value) == msg
def test_bug_with_attachment_one_batch(conn, store, add_project):
"""Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size."""
add_project(conn)
settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}})
b = queue.create_batch(conn, "ошибка", "claude", [
bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")],
files_dir=store)
assert b["count"] == 4 and b["estimate_seconds"] == 120 + 30 + 30 + 2
c1 = queue.claim(conn, "jira", files_dir=store)
assert c1["type"] == "ensure_issue"
queue.report_result(conn, c1["id"], "done", result={"key": "AURORA-1650"})
c2 = queue.claim(conn, "jira", files_dir=store)
assert c2["type"] == "ensure_sprint"
queue.report_result(conn, c2["id"], "done")
c3 = queue.claim(conn, "jira", files_dir=store)
assert c3["type"] == "ensure_attachment" and c3["pause_seconds"] == 30
assert c3["params"] == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx", "size": 3}
again = queue.claim(conn, "jira", files_dir=store) # довыдача — тоже с size
assert again["id"] == c3["id"] and again["params"]["size"] == 3
stored = queue.get_command(conn, c3["id"])["params"]
assert stored == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx"} # в базе без size
def test_attachment_size_null_when_file_gone(conn, store):
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
(store / SHA).unlink()
c = queue.claim(conn, "jira", files_dir=store)
assert c["type"] == "ensure_attachment" and c["params"]["size"] is None
assert queue.claim(conn, "jira")["params"]["size"] is None # без хранилища — тоже null, не исключение
assert queue.deliver_params(conn, queue.get_command(conn, c["id"]), store)["size"] is None
def test_attachment_retry_passes_validation(conn, store):
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
c = queue.claim(conn, "jira", files_dir=store)
queue.report_result(conn, c["id"], "failed", error="HTTP 413")
r = queue.retry_command(conn, c["id"], "claude", files_dir=store)
assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "file": SHA, "filename": "ОСВ 60.xlsx"}
(store / SHA).unlink() # убран уборкой
with pytest.raises(CommandError, match=f"команда 1: файл {SHA} не загружен: POST /files"):
queue.retry_command(conn, c["id"], "claude", files_dir=store)
def test_attachment_not_confirmed_by_state(conn, store):
mirror.apply_state(conn, [{"key": "A-1", "summary": "Т", "labels": []}], [])
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
c = queue.claim(conn, "jira", files_dir=store)
assert queue.reconcile(conn) == [] and queue.get_command(conn, c["id"])["status"] == "claimed"
# --- пакет проекта (#61, Task 5) ----------------------------------------------
S = "STOCKS_DRRK"
def two(conn, add_project):
"""Проекты AWPCA (доска 4216, scrum) и STOCKS_DRRK (без доски); AURORA-1538 — AWPCA, AURORA-1 — STOCKS_DRRK."""
add_project(conn)
add_project(conn, S, board_id=None)
mirror.apply_state(conn, [{"key": "AURORA-1538", "summary": "А", "labels": ["AWPCA"]},
{"key": "AURORA-1", "summary": "С", "labels": [S]}], [])
def new_issue(labels=None, temp="new:1"):
p = {"temp_key": temp, "issue_type": "task", "summary": "Сверка остатков"}
return {"type": "ensure_issue", "params": p if labels is None else {**p, "labels": labels}}
def test_project_label_appended(conn, add_project): # сценарий «Метка проекта дописана»
two(conn, add_project)
src = new_issue(["kind-dev"])
b = queue.create_batch(conn, "x", "claude", [src], project=S)
view = queue.batch_view(conn, b["id"])
assert view["project"] == S and view["commands"][0]["params"]["labels"] == ["kind-dev", S]
assert src["params"]["labels"] == ["kind-dev"] # входные параметры не мутируются
b = queue.create_batch(conn, "y", "claude", [new_issue(temp="new:2")], project=S)
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S]
def test_project_label_case_variant_replaced(conn, add_project): # своя метка другим регистром заменяется на P
two(conn, add_project)
b = queue.create_batch(conn, "x", "claude", [new_issue(["stocks_drrk", "kind-dev"])], project=S)
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S, "kind-dev"]
b = queue.create_batch(conn, "y", "claude", [new_issue(["Stocks_Drrk", S], temp="new:2")], project=S)
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] # без повтора P
def test_project_ensure_fields_labels_add_case_normalized(conn, add_project): # M-2
two(conn, add_project)
b = queue.create_batch(conn, "x", "claude", [fields("AURORA-1", labels_add=["stocks_drrk", "kind-dev", S])], project=S)
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels_add"] == [S, "kind-dev"]
@pytest.mark.parametrize("cmd, msg", [
(new_issue(["awpca"]), "команда 1: метка другого проекта: awpca"), # «Чужая метка проекта»
(fields("AURORA-1538"), "команда 1: AURORA-1538 не в проекте STOCKS_DRRK"), # «Задача другого проекта»
(fields("AURORA-1", labels_remove=["stocks_drrk"]), "команда 1: снять метку своего проекта STOCKS_DRRK нельзя"),
(fields("AURORA-1", labels_add=["AWPCA"]), "команда 1: метка другого проекта: AWPCA"),
({"type": "ensure_comment", "params": {"key": "AURORA-404", "marker": "m", "body": "b"}}, "AURORA-404 не в проекте"),
({"type": "ensure_status", "params": {"key": "AURORA-1538", "target": "Готово"}}, "AURORA-1538 не в проекте"),
({"type": "ensure_link", "params": {"source": "AURORA-1538", "target": "AURORA-404", "link_type": "связанные"}},
"команда 1: связь AURORA-1538 → AURORA-404 не касается проекта STOCKS_DRRK"),
])
def test_project_batch_rejected(conn, add_project, cmd, msg):
two(conn, add_project)
with pytest.raises(CommandError, match=msg):
queue.create_batch(conn, "x", "claude", [cmd], project=S)
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
def test_project_batch_accepted(conn, add_project):
two(conn, add_project)
link = {"type": "ensure_link", "params": {"source": "AURORA-1", "target": "AURORA-1538", "link_type": "связанные"}}
queue.create_batch(conn, "связь", "claude", [link], project=S) # «Связь с соседом»
queue.create_batch(conn, "новая", "claude", [new_issue(), {"type": "ensure_comment",
"params": {"key": "new:1", "marker": "m", "body": "b"}}], project=S) # временный ключ — свой
queue.create_batch(conn, "jql", "claude", [{"type": "refresh", "params": {"jql": "key = AURORA-1538"}}], project=S)
def test_unknown_project_batch(conn):
with pytest.raises(CommandError, match="проекта NOPE нет"):
queue.create_batch(conn, "x", "claude", [fields("AURORA-1")], project="NOPE")
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
def test_batch_without_project_unchanged(conn, add_project):
two(conn, add_project)
b = queue.create_batch(conn, "x", "claude", [new_issue(["awpca"]), fields("AURORA-1538")])
view = queue.batch_view(conn, b["id"])
assert view["project"] is None and view["commands"][0]["params"]["labels"] == ["awpca"]
def test_list_batches_by_project(conn, add_project): # сценарий «Пакеты проекта»
two(conn, add_project)
a = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S)
queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")])
assert [b["id"] for b in queue.list_batches(conn, project=S)] == [a["id"]]
assert len(queue.list_batches(conn)) == 2
def test_retry_project_rules(conn, add_project): # сценарий «Повтор чужой команды»
two(conn, add_project)
own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1538")], project="AWPCA")
loose = queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")])
cid = lambda b: queue.batch_view(conn, b["id"])["commands"][0]["id"]
with pytest.raises(CommandError, match=f"команда #{cid(own)} не из проекта STOCKS_DRRK"):
queue.retry_command(conn, cid(own), "claude", project=S)
with pytest.raises(CommandError, match="не из проекта AWPCA"):
queue.retry_command(conn, cid(loose), "claude", project="AWPCA")
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 2
r = queue.retry_command(conn, cid(own), "claude") # без project — проект исходного
assert queue.batch_view(conn, r["id"])["project"] == "AWPCA"
assert queue.batch_view(conn, queue.retry_command(conn, cid(loose), "claude")["id"])["project"] is None
ok = queue.retry_command(conn, cid(own), "claude", project="AWPCA")
assert queue.batch_view(conn, ok["id"])["project"] == "AWPCA"
def test_retry_inherits_project_checks(conn, add_project): # Review Focus 5: повтор без project наследует проверки
two(conn, add_project)
own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S)
cid = queue.batch_view(conn, own["id"])["commands"][0]["id"]
conn.execute("UPDATE issue SET labels = '[\"AWPCA\"]' WHERE key = 'AURORA-1'") # задача ушла из проекта
before = conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"]
with pytest.raises(CommandError, match="AURORA-1 не в проекте STOCKS_DRRK"):
queue.retry_command(conn, cid, "claude")
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == before
# --- доски: ensure_sprint.board_id, refresh.boards, типы досок (#61, Task 6) -----------------------------------------
def sprint(key, name="Спринт 2"):
return {"type": "ensure_sprint", "params": {"key": key, "sprint": name}}
def test_sprint_gets_project_board(conn, add_project): # сценарий «Спринт задачи проекта»
add_project(conn)
mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], [])
b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) # и без project пакета
assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"key": "AURORA-1538", "sprint": "Спринт 2", "board_id": 4216}
@pytest.mark.parametrize("setup, key, msg", [
(dict(board_id=5120, board_type="kanban"), "AURORA-1", "у проекта STOCKS_DRRK канбан-доска, спринтов нет"),
(dict(board_id=5120, board_type=None), "AURORA-1", "тип доски проекта STOCKS_DRRK ещё не известен"),
(dict(board_id=None, board_type=None), "AURORA-1", "у проекта STOCKS_DRRK нет доски Jira"),
(dict(board_id=5120, board_type="scrum"), "AURORA-77", "AURORA-77 не входит ни в один проект"),
(dict(board_id=5120, board_type="scrum"), "AURORA-404", "AURORA-404 не входит ни в один проект"), # нет в зеркале
(dict(board_id=5120, board_type="scrum"), "AURORA-12", "с разными досками"),
])
def test_sprint_board_rejected(conn, add_project, setup, key, msg):
add_project(conn) # AWPCA 4216 scrum
add_project(conn, "STOCKS_DRRK", **setup)
mirror.apply_state(conn, [{"key": "AURORA-1", "labels": ["STOCKS_DRRK"]}, {"key": "AURORA-77", "labels": ["kind-dev"]},
{"key": "AURORA-12", "labels": ["AWPCA", "STOCKS_DRRK"]}], [])
with pytest.raises(CommandError, match=msg):
queue.create_batch(conn, "x", "claude", [sprint(key)])
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
def test_sprint_rejected_message_has_command_number(conn, add_project):
add_project(conn, board_id=5120, board_type="kanban")
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "x", "claude", [fields("A-1"), sprint("A-1")])
assert str(e.value) == "команда 2: у проекта AWPCA канбан-доска, спринтов нет"
def test_backlog_on_kanban_rejected(conn, add_project):
add_project(conn, board_id=5120, board_type="kanban")
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
with pytest.raises(CommandError, match="канбан-доска"):
queue.create_batch(conn, "x", "claude", [sprint("A-1", "backlog")])
def test_sprint_two_projects_same_board(conn, add_project):
add_project(conn)
add_project(conn, "C", board_id=4216)
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA", "C"]}], [])
b = queue.create_batch(conn, "x", "claude", [sprint("A-1")])
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["board_id"] == 4216
def test_sprint_for_new_issue_uses_batch_labels(conn, add_project):
add_project(conn)
add_project(conn, "C", board_id=5120)
b = queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, sprint("new:1")],
project="AWPCA") # метка проекта дописана Task 5
assert queue.batch_view(conn, b["id"])["commands"][1]["params"]["board_id"] == 4216
b = queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Н2", "labels": ["C"]}},
sprint("new:2")]) # без проекта — метки самой команды
assert queue.batch_view(conn, b["id"])["commands"][1]["params"]["board_id"] == 5120
def test_sprint_for_new_issue_without_project_labels_rejected(conn, add_project):
add_project(conn)
mirror.apply_state(conn, [{"key": "new:1", "labels": ["AWPCA"]}], []) # зеркало не должно подменять метки пакета
with pytest.raises(CommandError, match="не входит ни в один проект"):
queue.create_batch(conn, "x", "claude", [
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, sprint("new:1")])
def test_client_board_id_rejected(conn, add_project):
add_project(conn)
with pytest.raises(CommandError, match="лишние параметры board_id"):
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "С", "board_id": 1}}])
def test_input_params_not_mutated(conn, add_project):
add_project(conn)
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
cmds = [sprint("A-1")]
queue.create_batch(conn, "x", "claude", cmds)
assert cmds == [sprint("A-1")]
def test_retry_sprint_recomputes_board(conn, add_project): # сценарий «Повтор ensure_sprint»
add_project(conn)
mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], [])
b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")])
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
conn.execute("UPDATE jira_project SET board_id = 5120, board_type = 'scrum'")
r = queue.retry_command(conn, cid, "claude")
assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120
def test_retry_premigration_sprint_without_board(conn, add_project): # Review Focus 5
add_project(conn)
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], [])
cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES ('старый', 'claude', ?, 1, NULL)",
(db.now(),))
cid = conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (?, 1, 'ensure_sprint', ?)",
(cur.lastrowid, '{"key": "A-1", "sprint": "Спринт 2"}')).lastrowid
r = queue.retry_command(conn, cid, "claude")
assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "sprint": "Спринт 2", "board_id": 4216}
def meta(name="AWPCA_Sprint_4"):
return {"type": "ensure_sprint_meta", "params": {"sprint": name, "start_date": "2026-10-28", "end_date": "2026-11-11"}}
def close(name="AWPCA_Sprint_3_Corrections", to="AWPCA_Sprint_4"):
return {"type": "ensure_sprint_state", "params": {"sprint": name, "state": "closed", "move_open_to": to}}
def test_sprint_meta_gets_batch_project_board(conn, add_project):
add_project(conn)
b = queue.create_batch(conn, "x", "claude", [meta(), close()], project="AWPCA")
cmds = queue.batch_view(conn, b["id"])["commands"]
assert cmds[0]["params"]["board_id"] == 4216 and cmds[1]["params"]["board_id"] == 4216
def test_sprint_meta_without_project_rejected(conn, add_project): # сценарий «Спринт без проекта пакета»
add_project(conn)
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "x", "claude", [meta()])
assert str(e.value) == "команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта"
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
@pytest.mark.parametrize("setup, msg", [
(dict(board_id=5120, board_type="kanban"), "команда 1: у проекта AWPCA канбан-доска, спринтов нет"),
(dict(board_id=5120, board_type=None), "команда 1: тип доски проекта AWPCA ещё не известен — дождитесь обновления из Jira"),
(dict(board_id=None, board_type=None), "команда 1: у проекта AWPCA нет доски Jira"),
])
def test_sprint_state_board_rejected(conn, add_project, setup, msg):
add_project(conn, **setup)
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "x", "claude", [close()], project="AWPCA")
assert str(e.value) == msg
def test_rank_belongs_to_project_by_key(conn, add_project): # design #19
add_project(conn); add_project(conn, "C", board_id=5120)
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["C"]}], [])
queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA")
with pytest.raises(CommandError, match="команда 1: Y не в проекте AWPCA"):
queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "Y", "after": "X"}}], project="AWPCA")
def test_retry_sprint_meta_recomputes_board(conn, add_project):
add_project(conn)
b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA")
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
conn.execute("UPDATE jira_project SET board_id = 5120")
r = queue.retry_command(conn, cid, "claude")
assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120
def test_retry_sprint_state_after_board_became_kanban(conn, add_project): # Review Focus 4
add_project(conn)
b = queue.create_batch(conn, "x", "claude", [close()], project="AWPCA")
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
conn.execute("UPDATE jira_project SET board_type = 'kanban'")
with pytest.raises(CommandError, match="канбан-доска, спринтов нет"):
queue.retry_command(conn, cid, "claude")
def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1
add_project(conn)
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)
add_project(conn, "C", board_id=4216)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": "key = A-1"}}])
got = queue.claim(conn, "jira")
assert got["params"]["boards"] == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]
assert "boards" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
assert queue.claim(conn, "jira")["params"]["boards"] == got["params"]["boards"] # довыдача — тоже с boards
def test_refresh_boards_empty_list(conn, add_project):
add_project(conn, board_id=None, board_type=None)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
assert queue.claim(conn, "jira")["params"]["boards"] == []
@pytest.mark.parametrize("result, expected", [
({"boards": {"5120": "kanban"}}, "kanban"), # ключ — строка, project.board_id — int
({"issues": 3}, None), # агент v11 / v12 без запроса типов: ключа нет
({"boards": {"5120": None}}, None), # доска не найдена — тип не меняется
({"boards": {"abc": "scrum"}}, None),
({"boards": {"5120": "sprint"}}, None), # мусорный тип
({"boards": ["5120", "kanban"]}, None), # boards не словарь
({"boards": "kanban"}, None),
(None, None),
])
def test_refresh_result_board_types(conn, add_project, result, expected):
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
c = queue.claim(conn, "jira")
queue.report_result(conn, c["id"], "done", result=result)
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == expected
def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project): # доска в Jira стала канбаном
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type="scrum")
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
c = queue.claim(conn, "jira")
queue.report_result(conn, c["id"], "done", result={"boards": {"5120": "kanban"}})
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == "kanban"
def test_refresh_failed_result_records_board_types(conn, add_project): # R-3 / T6.1: тип доски — факт о доске, а не часть выборки
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
c = queue.claim(conn, "jira")
queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e")
assert api.project_rows(conn, ["STOCKS_DRRK"])[0]["board_type"] == "kanban"
# --- журнал изменений: history в выдаче refresh, sync_run.history_at (#70, Task 3) ----------------------------------
def _full_run(conn, result, started_at=None, status="done"):
"""Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона."""
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
c = queue.claim(conn, "jira")
if started_at:
conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"]))
queue.report_result(conn, c["id"], status, result=result)
return c
def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки»
add_project(conn)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
got = queue.claim(conn, "jira")
assert got["params"]["history"] == {
"jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'}
assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
def test_regular_refresh_gets_since(conn, add_project): # «Регулярный refresh» и «Журнал не дочитан»
add_project(conn)
_full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
_full_run(conn, {"history": {"error": "HTTP 500"}}, "2026-10-22T10:00:00+00:00")
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
assert queue.claim(conn, "jira")["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
def test_new_project_gets_since_and_backfill(conn, add_project): # «Новый проект»
add_project(conn)
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
add_project(conn, "STOCKS_DRRK", board_id=5120)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
h = queue.claim(conn, "jira")["params"]["history"]
assert h["since"] == "2026-10-22T08:45:00Z"
assert h["jql"] == "(" + jira_projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done"
assert "STOCKS_DRRK" in h["jql"]
def test_tail_refresh_since_and_no_mark(conn, add_project): # «Хвост пакета»
add_project(conn)
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
c = queue.claim(conn, "jira")
assert c["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
queue.report_result(conn, c["id"], "done", result={"history": {"issues": 0, "changes": 0, "incomplete": []}})
assert conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()["history_at"] is None
def test_tail_refresh_without_mark_gets_nothing(conn, add_project): # «Хвост пакета до первой отметки»
add_project(conn)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
assert "history" not in queue.claim(conn, "jira")["params"]
def test_no_projects_no_history(conn):
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
assert "history" not in queue.claim(conn, "jira")["params"]
def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3
add_project(conn)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}])
first = queue.claim(conn, "jira")
again = queue.claim(conn, "jira") # отчёта нет — довыдача
assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"]
assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
@pytest.mark.parametrize("result, status, marked", [
({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "done", True),
({"history": {"issues": 1, "changes": 2, "incomplete": ["X"], "backfill_error": "HTTP 400"}}, "done", True),
({"history": {"issues": 0, "changes": 0, "error": ""}}, "done", True),
({"history": {"error": "HTTP 500"}}, "done", False),
({"issues": 3}, "done", False), # агент v12
({"history": "мусор"}, "done", False),
(None, "done", False),
({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "failed", False),
])
def test_report_sets_history_at(conn, add_project, result, status, marked):
add_project(conn)
c = _full_run(conn, result, status=status)
run = conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()
assert (run["history_at"] is not None) == marked
# --- fetch_attachment (#83) ---------------------------------------------------
import threading
FSHA = "cd" * 32
@pytest.fixture
def fconn(conn):
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
{"id": "31337", "filename": "ОСВ 60.xlsx", "size": 3, "created": "2026-10-20T10:00:00.000+0300"},
{"id": "31338", "filename": "big.bin", "size": 25_000_000, "created": "2026-10-20T10:00:00.000+0300"},
{"id": "31339", "filename": "unk.bin", "created": "2026-10-20T10:00:00.000+0300"}]},
{"key": "B-1", "summary": "т", "labels": []}], [])
return conn
def fetch(att="31337", key="A-1"):
return {"type": "fetch_attachment", "params": {"key": key, "attachment_id": att}}
def test_fetch_batch_no_tail_refresh(fconn):
b = queue.create_batch(fconn, "x", "claude", [fetch()])
assert b["count"] == 1
assert [c["type"] for c in queue.batch_view(fconn, b["id"])["commands"]] == ["fetch_attachment"]
assert b["estimate_seconds"] == 2
def test_fetch_checks(fconn):
with pytest.raises(CommandError):
queue.create_batch(fconn, "x", "claude", [fetch("1a")])
with pytest.raises(CommandError) as e:
queue.create_batch(fconn, "x", "claude", [fetch(key="B-1")])
assert str(e.value) == "у B-1 нет вложения #31337 в зеркале — обновите задачу"
with pytest.raises(jira_fetch.TooLarge) as e:
queue.create_batch(fconn, "x", "claude", [fetch("31338")])
assert str(e.value) == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"
assert queue.create_batch(fconn, "x", "claude", [fetch("31339")])["count"] == 1 # size NULL — ставится
def test_fetch_size_limit_boundary(fconn):
"""T3.4: ровно MAX_SIZE принимается, MAX_SIZE+1 — TooLarge."""
mirror.apply_state(fconn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
{"id": "41", "filename": "eq.bin", "size": files.MAX_SIZE, "created": "2026-10-20T10:00:00.000+0300"},
{"id": "42", "filename": "over.bin", "size": files.MAX_SIZE + 1, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
assert jira_fetch.request_fetch(fconn, None, "41", "claude")["state"] == "fetching"
with pytest.raises(jira_fetch.TooLarge):
jira_fetch.request_fetch(fconn, None, "42", "claude")
def test_fetch_report_done_non_dict_result_fails(fconn, tmp_path):
"""T3.4: done с result не-словарём — failed, вложение не отмечено скачанным."""
queue.create_batch(fconn, "x", "claude", [fetch()])
c = queue.claim(fconn, "jira")
r = queue.report_result(fconn, c["id"], "done", result="ok", files_dir=tmp_path)
assert r["status"] == "failed"
assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0
def test_fetch_no_backup(fconn):
queue.create_batch(fconn, "x", "claude", [fetch()])
calls = []
cmd = queue.claim(fconn, "jira", backup=calls.append)
assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2 and calls == []
def test_fetch_project_batch_no_checks(fconn, add_project):
add_project(fconn, "AWPCA")
b = queue.create_batch(fconn, "x", "claude", [fetch(key="A-1")], project="AWPCA") # A-1 не в проекте
assert b["count"] == 1
def test_request_fetch_once(open_db, tmp_path):
path = tmp_path / "hub.sqlite"
c1 = open_db(path)
settings.ensure_defaults(c1)
mirror.apply_state(c1, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
{"id": "31337", "filename": "a.png", "size": 3, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
c2 = open_db(path)
res, errs = [], []
barrier = threading.Barrier(2)
def run(c):
try:
barrier.wait()
res.append(jira_fetch.request_fetch(c, tmp_path / "files", "31337", "x"))
except Exception as e: # noqa: BLE001
errs.append(e)
ts = [threading.Thread(target=run, args=(c,)) for c in (c1, c2)]
[t.start() for t in ts]
[t.join() for t in ts]
assert not errs
assert res[0]["command_id"] == res[1]["command_id"] and res[0]["batch_id"] == res[1]["batch_id"]
assert {r["state"] for r in res} <= {"fetching"}
assert c1.execute("SELECT COUNT(*) AS n FROM command WHERE type = 'fetch_attachment'").fetchone()["n"] == 1
def put_file(tmp_path, data=b"abc"):
root = tmp_path / "files"
sha, _ = files.put(root, data)
return root, sha
def test_request_fetch_ready(fconn, tmp_path):
root, sha = put_file(tmp_path)
fconn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('31337', ?, 3, ?)", (sha, db.now()))
r = jira_fetch.request_fetch(fconn, root, "31337", "x")
assert r["state"] == "ready" and r["filename"] == "ОСВ 60.xlsx"
assert fconn.execute("SELECT COUNT(*) AS n FROM command").fetchone()["n"] == 0
with pytest.raises(KeyError):
jira_fetch.request_fetch(fconn, root, "999", "x")
def test_request_fetch_agent_too_old(fconn, tmp_path):
fconn.execute("UPDATE agent_status SET agent_version = '13' WHERE agent = 'jira'")
with pytest.raises(jira_fetch.AgentTooOld) as e:
jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")
assert str(e.value) == "агент v13 не умеет скачивать вложения — нужен v14"
fconn.execute("UPDATE agent_status SET agent_version = '14' WHERE agent = 'jira'")
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
fconn.execute("UPDATE agent_status SET agent_version = NULL WHERE agent = 'jira'")
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
def test_request_fetch_after_failed(fconn, tmp_path):
root = tmp_path / "files"
first = jira_fetch.request_fetch(fconn, root, "31337", "x")
cmd = queue.claim(fconn, "jira")
queue.report_result(fconn, cmd["id"], "failed", error="нет сети", files_dir=root)
again = jira_fetch.request_fetch(fconn, root, "31337", "x")
assert again["state"] == "fetching" and again["batch_id"] != first["batch_id"]
def report_done(conn, root, result):
queue.create_batch(conn, "x", "claude", [fetch()])
cmd = queue.claim(conn, "jira")
return queue.report_result(conn, cmd["id"], "done", result=result, files_dir=root)
def test_report_fetch_done_records_file(fconn, tmp_path):
root, sha = put_file(tmp_path)
cmd = report_done(fconn, root, {"file": sha, "size": 3})
assert cmd["status"] == "done"
row = fconn.execute("SELECT * FROM attachment_file WHERE attachment_id = '31337'").fetchone()
assert row["file"] == sha and row["size"] == 3
assert attachments.state(fconn, root, "31337")["state"] == "ready"
def test_report_fetch_done_missing_file(fconn, tmp_path):
root = tmp_path / "files"
cmd = report_done(fconn, root, {"file": FSHA, "size": 3})
assert cmd["status"] == "failed" and cmd["error"] == f"хаб: файл {FSHA} не найден в хранилище"
assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0
# после уборки файла (done → файла нет) и повторной постановки — новый пакет
assert jira_fetch.request_fetch(fconn, root, "31337", "x")["state"] == "fetching"
def test_report_fetch_wrong_size(fconn, tmp_path):
root, sha = put_file(tmp_path)
cmd = report_done(fconn, root, {"file": sha, "size": 99})
assert cmd["status"] == "failed" and cmd["error"] == f"хаб: файл {sha} не найден в хранилище"
assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0
def test_request_fetch_after_done_file_swept(fconn, tmp_path):
root, sha = put_file(tmp_path)
report_done(fconn, root, {"file": sha, "size": 3})
files.path(root, sha).unlink() # уборка файла
r = jira_fetch.request_fetch(fconn, root, "31337", "x")
assert r["state"] == "fetching"
assert fconn.execute("SELECT COUNT(*) AS n FROM command WHERE type = 'fetch_attachment'").fetchone()["n"] == 2
def test_estimate_sums_module_pauses_replaces_commands_estimate(conn):
"""Замена утверждения test_commands про commands.estimate (функция ушла в ядро): сумма пауз реального модуля jira
через pacing -> hooks.pause -> queue.estimate."""
settings.set_(conn, "pacing", {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2})
assert queue.estimate(conn, ["ensure_fields", "ensure_comment", "refresh"]) == 152
def test_sprint_batch_tail_is_project_jql(conn, add_project): # design #13, R-10
add_project(conn); add_project(conn, "C", board_id=None)
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["AWPCA"]}], [])
b = queue.create_batch(conn, "x", "claude", [meta(), {"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA")
cmds = queue.batch_view(conn, b["id"])["commands"]
assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "ensure_rank", "refresh"]
assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
def test_rank_batch_tail_by_keys(conn, add_project):
add_project(conn)
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}], [])
b = queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "after": "Z"}}], project="AWPCA")
assert queue.batch_view(conn, b["id"])["commands"][-1]["params"] == {"keys": ["X", "Z"]}
def test_retry_sprint_meta_gets_jql_tail(conn, add_project):
add_project(conn)
b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA")
cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
r = queue.retry_command(conn, cid, "claude")
cmds = queue.batch_view(conn, r["id"])["commands"]
assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"]
assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}