Хаб: пакет проекта — метка проекта, чужие метки и задачи, batch.project, повтор с project (#61)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 20:49:08 +04:00
parent 63972572fd
commit 6d5c40b037
5 changed files with 218 additions and 10 deletions

View File

@ -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.

View File

@ -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:

View File

@ -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)
# --- агент и автообновление ----------------------------------------------------

View File

@ -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"

View File

@ -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