Хаб: пакет проекта — метка проекта, чужие метки и задачи, batch.project, повтор с project (#61)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
63972572fd
commit
6d5c40b037
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
# --- агент и автообновление ----------------------------------------------------
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user