diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index 9ec0a60..5e7b5a1 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -256,3 +256,11 @@ 23. 2026-10-19 — **`CommandError` из постановки refresh в `POST /projects` — 422 `bad_batch`, а не `bad_project`.** Обоснование: ошибка относится к пакету, а не к полям проекта; спека называет только `project_exists` и `bad_project`. Цена ошибки: код ошибки в ответе. +24. 2026-10-19 — **`GET /batches?project=` с неизвестной меткой — пустой список, а не 404; пустой `project=` — как без + фильтра.** Обоснование: спека требует 404 только для `/issues` и `/board`; у пакетов проект — произвольная + сохранённая метка. Цена ошибки: одна ветка в `h_batches_get`. +25. 2026-10-19 — **в `ensure_issue` пакета проекта повторы других (не P) меток не убираются.** Обоснование: спека + убирает только повтор P; остальное — как прислал клиент. Цена ошибки: одна строка в `_check_project`. +26. 2026-10-19 — **`project` в `retry` / `POST /batches` не строка — 422 `bad_retry` / `bad_batch`; в `retry_command` + сначала сверка `project` с проектом исходного, затем `create_batch` с проверками проекта.** Обоснование: «команда + #N не из проекта P» точнее ошибки проверок. Цена ошибки: текст 422. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 16b4d9d..5c5e7fb 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -286,14 +286,17 @@ def h_batches_post(hub, conn, m, q, body): raise ApiError(422, "bad_batch", "author — строка") if not isinstance(body.get("commands"), list): raise ApiError(422, "bad_batch", "commands — список команд") + if body.get("project") is not None and not isinstance(body["project"], str): + raise ApiError(422, "bad_batch", "project — строка") try: - return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir) + return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), + files_dir=hub.files_dir, project=body.get("project")) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) def h_batches_get(hub, conn, m, q, body): - return 200, {"batches": queue.list_batches(conn, _int_param(q, "limit", 20))} + return 200, {"batches": queue.list_batches(conn, _int_param(q, "limit", 20), project=q.get("project") or None)} def h_batch_get(hub, conn, m, q, body): @@ -329,8 +332,11 @@ def h_files_post(hub, conn, m, q, body): def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") + if body.get("project") is not None and not isinstance(body["project"], str): + raise ApiError(422, "bad_retry", "project — строка") try: - return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir) + return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir, + project=body.get("project")) except KeyError: raise ApiError(404, "not_found", "command") except CommandError as e: diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 7a7095b..b2a1942 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -69,8 +69,51 @@ def _check_setup(conn, cmds, files_dir) -> None: raise C.CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)") -def create_batch(conn, title, author, cmds, files_dir=None) -> dict: +def _check_project(conn, cmds, project) -> list[dict]: + """Проверки пакета проекта (spec commands, «Пакет проекта»): новые словари команд — входные params не мутируются. + Номер команды — как в присланном пакете.""" + if projects.get(conn, project) is None: + raise C.CommandError(f"проекта {project} нет") + others = {l.casefold() for l in projects.labels(conn) if l != project} + temps = {c["params"]["temp_key"] for c in cmds if c["type"] == "ensure_issue"} + + def own(key): + return key in temps or project in (mirror.labels_of(conn, key) or []) + + out = [] + for i, c in enumerate(cmds, 1): + t, p = c["type"], dict(c["params"]) + if t == "ensure_issue": + labels = [] + for l in p.get("labels") or []: + if l.casefold() in others: + raise C.CommandError(f"команда {i}: метка другого проекта: {l}") + l = project if l.casefold() == project.casefold() else l + if l not in labels or l != project: + labels.append(l) + if project not in labels: + labels.append(project) + p["labels"] = labels + elif t == "ensure_link": + if not (own(p["source"]) or own(p["target"])): + raise C.CommandError(f"команда {i}: связь {p['source']} → {p['target']} не касается проекта {project}") + elif t != "refresh": + if t == "ensure_fields": + if project.casefold() in {l.casefold() for l in p.get("labels_remove") or []}: + raise C.CommandError(f"команда {i}: снять метку своего проекта {project} нельзя") + for l in p.get("labels_add") or []: + if l.casefold() in others: + raise C.CommandError(f"команда {i}: метка другого проекта: {l}") + if not own(p["key"]): + raise C.CommandError(f"команда {i}: {p['key']} не в проекте {project}") + out.append({**c, "params": p}) + return out + + +def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dict: cmds = C.validate(cmds) + if project is not None: + cmds = _check_project(conn, cmds, project) _check_setup(conn, cmds, files_dir) keys = [] for c in cmds: @@ -79,8 +122,8 @@ def create_batch(conn, title, author, cmds, files_dir=None) -> dict: cmds.append({"type": "refresh", "params": {"keys": keys}}) est = C.estimate([c["type"] for c in cmds], settings.pacing(conn)) with db.transaction(conn): - cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds) VALUES (?, ?, ?, ?)", - (title, author or "api", db.now(), est)) + cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES (?, ?, ?, ?, ?)", + (title, author or "api", db.now(), est, project)) batch_id = cur.lastrowid defined = {} for seq, c in enumerate(cmds, 1): @@ -118,8 +161,9 @@ def batch_view(conn, batch_id, with_commands=True) -> dict | None: return d -def list_batches(conn, limit=20) -> list[dict]: - ids = [r["id"] for r in conn.execute("SELECT id FROM batch ORDER BY id DESC LIMIT ?", (int(limit),))] +def list_batches(conn, limit=20, project=None) -> list[dict]: + where, args = ("WHERE project = ?", [project]) if project is not None else ("", []) + ids = [r["id"] for r in conn.execute(f"SELECT id FROM batch {where} ORDER BY id DESC LIMIT ?", (*args, int(limit)))] return [batch_view(conn, i, with_commands=False) for i in ids] @@ -349,12 +393,15 @@ def cancel_command(conn, command_id, force=False) -> dict: return get_command(conn, command_id) -def retry_command(conn, command_id, author, files_dir=None) -> dict: +def retry_command(conn, command_id, author, files_dir=None, project=None) -> dict: cmd = get_command(conn, command_id) if cmd is None: raise KeyError(command_id) + origin = conn.execute("SELECT project FROM batch WHERE id = ?", (cmd["batch_id"],)).fetchone()["project"] + if project is not None and project != origin: + raise C.CommandError(f"команда #{command_id} не из проекта {project}") return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}], - files_dir=files_dir) + files_dir=files_dir, project=origin) # --- агент и автообновление ---------------------------------------------------- diff --git a/tests/test_api.py b/tests/test_api.py index 2256189..9365f92 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -851,3 +851,38 @@ def test_board_and_issues_by_project(hub): with pytest.raises(HubError) as e: c.get(path) assert e.value.status == 404 and "project NOPE" in e.value.detail + + +def test_project_batches_api(hub, add_project): # #61, Task 5 + conn = hub["hub"].conn() + try: + add_project(conn) + add_project(conn, "STOCKS_DRRK", board_id=None) + finally: + conn.close() + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "А", "labels": ["AWPCA"]}, + {"key": "A-2", "summary": "С", "labels": ["STOCKS_DRRK"]}], "links": []}) + c = hub["client"] + cmd = lambda key: [{"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23"}}] + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-1")}) + assert e.value.status == 422 and "не в проекте" in e.value.detail + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "project": 5, "commands": cmd("A-2")}) + assert e.value.status == 422 + mine = c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-2")}) + c.post("/batches", {"title": "y", "commands": cmd("A-1")}) + assert [b["id"] for b in c.get("/batches", project="STOCKS_DRRK")["batches"]] == [mine["id"]] + assert c.get(f"/batches/{mine['id']}")["project"] == "STOCKS_DRRK" + own = c.post("/batches", {"title": "z", "project": "AWPCA", "commands": cmd("A-1")}) + cid = c.get(f"/batches/{own['id']}")["commands"][0]["id"] + before = len(c.get("/batches", limit=50)["batches"]) + with pytest.raises(HubError) as e: + c.post(f"/commands/{cid}/retry", {"project": "STOCKS_DRRK"}) + assert e.value.status == 422 and f"команда #{cid} не из проекта STOCKS_DRRK" in e.value.detail + with pytest.raises(HubError) as e: + c.post(f"/commands/{cid}/retry", {"project": 5}) + assert e.value.status == 422 + assert len(c.get("/batches", limit=50)["batches"]) == before + r = c.post(f"/commands/{cid}/retry", {}) + assert c.get(f"/batches/{r['id']}")["project"] == "AWPCA" diff --git a/tests/test_queue.py b/tests/test_queue.py index e03c462..fce4aa1 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -655,3 +655,115 @@ def test_attachment_not_confirmed_by_state(conn, store): queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) c = queue.claim(conn, 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 + + +@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