diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index 489b5a6..9ec0a60 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -250,3 +250,9 @@ раскладки AWPCA. Цена ошибки: схема ключей в `storage.ts`. 21. 2026-10-19 — **`last_sync` после создания проекта — `null` до завершения его обновления, `sync_run.query` не переписывается.** Обоснование: прежний прогон новую метку не покрывал. Цена ошибки: временная надпись на доске. +22. 2026-10-19 — **пустой `author` в `POST /projects` — как `api`, нестрока — 422 `bad_project`.** Обоснование: как у + остальных маршрутов (`body.get("author") or "api"`); спека требует 422 для не-строки. Цена ошибки: пустая строка + пишется автором `api`, а не отвергается. +23. 2026-10-19 — **`CommandError` из постановки refresh в `POST /projects` — 422 `bad_batch`, а не `bad_project`.** + Обоснование: ошибка относится к пакету, а не к полям проекта; спека называет только `project_exists` и + `bad_project`. Цена ошибки: код ошибки в ответе. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 9b8b109..deea3f7 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -393,6 +393,30 @@ def h_settings_put(hub, conn, m, q, body): return 200, {**settings.get_all(conn), "base_jql": projects.base_jql(conn)} +def h_projects_get(hub, conn, m, q, body): + return 200, {"projects": projects.list_projects(conn)} + + +def h_projects_post(hub, conn, m, q, body): + """Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной + транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).""" + author = body.get("author") or "api" + if not isinstance(author, str): + raise ApiError(422, "bad_project", "author — строка") + try: + with db.transaction(conn): + project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id")) + batch = queue.create_batch(conn, f"Новый проект {project['label']}", author, + [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + except projects.ProjectExists as e: + raise ApiError(409, "project_exists", str(e)) + except projects.ProjectError as e: + raise ApiError(422, "bad_project", str(e)) + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) + return 201, {"project": project, "batch_id": batch["id"]} + + ROUTES = [ ("GET", r"^/(links)?$", h_root, None), ("GET", r"^/app(?:/(.*))?$", h_app, None), @@ -420,6 +444,8 @@ ROUTES = [ ("GET", r"^/sync-runs$", h_sync_runs, "client"), ("GET", r"^/settings$", h_settings_get, "client"), ("PUT", r"^/settings$", h_settings_put, "client"), + ("GET", r"^/projects$", h_projects_get, "client"), + ("POST", r"^/projects$", h_projects_post, "client"), ] _COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] diff --git a/tests/test_api.py b/tests/test_api.py index 7aa6889..6c39a60 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -780,3 +780,53 @@ def test_issues_hide_closed(hub): assert keys() == ["A-1", "A-2", "A-3", "A-4"] c.put("/settings", {"closed_statuses": ["Готово"]}) assert keys(hide_closed="1") == ["A-1", "A-3", "A-4"] + + +def test_create_project(hub): # сценарий «Новый проект» + c = hub["client"] + conn = hub["hub"].conn(); version = settings.get(conn, "settings_version"); conn.close() + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216}) + out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": 5120, "author": "viewer"}) + assert out["project"]["label"] == "STOCKS_DRRK" and out["project"]["board_type"] is None + got = c.get("/projects")["projects"] + assert [p["label"] for p in got] == ["AWPCA", "STOCKS_DRRK"] and got[1]["issues_count"] == 0 + b = c.get(f"/batches/{out['batch_id']}") + assert b["author"] == "viewer" and b["title"] == "Новый проект STOCKS_DRRK" and b["project"] is None + assert b["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"} + assert c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек» + assert c.get("/settings")["base_jql"] == "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" + + +def test_create_project_without_board(hub): + c = hub["client"] + out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) + assert out["project"]["board_id"] is None and out["project"]["board_type"] is None + b = c.get(f"/batches/{out['batch_id']}") + assert b["author"] == "api" + + +@pytest.mark.parametrize("body, status, field", [ + ({"label": "AWPCA", "name": "x"}, 409, "label"), + ({"label": "awpca", "name": "x"}, 409, "label"), + ({"label": "STOCKS DRRK", "name": "x"}, 422, "label"), + ({"label": "STOCKS_DRRK", "name": " "}, 422, "name"), + ({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"), + ({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None), +]) +def test_create_project_errors(hub, body, status, field): + c = hub["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) + before = len(c.get("/batches")["batches"]) + with pytest.raises(HubError) as e: + c.post("/projects", body) + assert e.value.status == status and (field is None or e.value.detail.startswith(field)) + assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before + + +def test_projects_need_client_token(hub): + with pytest.raises(HubError) as e: + hub["agent"].get("/projects") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["agent"].post("/projects", {"label": "STOCKS_DRRK", "name": "x"}) + assert e.value.status == 401