From 4f9024d5c38633dd3ddd5caa9f58e7a7f9538028 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:39:56 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D1=91=D0=BC=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=BA,=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2,=20=D1=80=D0=B0=D0=BD=D0=B3=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=BD=D0=BE=D0=BA=20=D0=BE=D1=82=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20v15=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- src/sdlc_hub/modules/jira/api.py | 5 +- src/sdlc_hub/modules/jira/mirror.py | 101 +++++++++++++++++++++++++++- tests/test_api.py | 44 ++++++++++++ tests/test_mirror.py | 88 ++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a012c99..6fa8a96 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py | `GET /agent/settings` | агент | настройки для агента (без токенов) | | `GET /agent/commands/{id}` | агент | команда по id | | `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) | -| `POST /agent/state` | агент | загрузка состояния задач пачками | +| `POST /agent/state` | агент | загрузка состояния задач пачками; агент v15 — у задач `rank`, `story_points`, `sprint_ids` (заменяет спринты задачи), в пачке `boards` (доска заменяет свою строку и, если `sprints` — список, свои спринты; тип доски пишется проектам с этой доской); без полей — данные прежние; неверная форма — 422 `bad_state` | | `POST /agent/history` | агент | журнал изменений Jira пачками (агент v13+, по `history` в `refresh`) → `{accepted, duplicates}`; повторы отбрасываются, неверная пачка — 422 `bad_history` | | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | diff --git a/src/sdlc_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py index c001c6d..74665b3 100644 --- a/src/sdlc_hub/modules/jira/api.py +++ b/src/sdlc_hub/modules/jira/api.py @@ -22,14 +22,17 @@ _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") def h_agent_state(hub, conn, m, q, body): issues = body.get("issues") or [] + boards = body.get("boards") try: mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком mirror.check_attachments(issues) + mirror.check_agile_fields(issues) + mirror.check_boards(boards) except ValueError as e: raise ApiError(422, "bad_state", str(e)) with db.transaction(conn): out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [], - authoritative=refresh.is_full_refresh(conn, body.get("command_id"))) + authoritative=refresh.is_full_refresh(conn, body.get("command_id")), boards=boards) if body.get("command_id"): refresh.record_state(conn, body["command_id"], out["issues"], out["links"]) return 200, out diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index be3cd08..eb1ec25 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -1,13 +1,15 @@ """Зеркало задач Jira (issue, issue_link, issue_comment, issue_attachment) и служебные заметки (issue_note). Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json +import math -from . import attachments, history +from . import attachments, history, projects from ...core import db ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] +SPRINT_STATES = ("active", "future", "closed") def check_comments(issues) -> None: """Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем.""" @@ -53,16 +55,87 @@ def check_attachments(issues) -> None: raise ValueError(f"{where}.{f} — строка или null") +def check_agile_fields(issues) -> None: + """Форма полей rank, story_points, sprint_ids у задач состояния (агент v15+); поля необязательны (не присланное + не проверяется). Нарушение — ValueError с ключом и полем.""" + for it in issues: + key = it.get("key") + if "rank" in it and it["rank"] is not None and not isinstance(it["rank"], str): + raise ValueError(f"{key}: rank — строка или null") + if "story_points" in it and it["story_points"] is not None: + sp = it["story_points"] + if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not math.isfinite(sp): + raise ValueError(f"{key}: story_points — число или null") + if "sprint_ids" in it: + ids = it["sprint_ids"] + if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) for i in ids): + raise ValueError(f"{key}: sprint_ids — список целых") + + +def _is_id(v) -> bool: + return isinstance(v, int) and not isinstance(v, bool) and v > 0 + + +def check_boards(boards) -> None: + """Форма списка boards состояния (агент v15+); None — доски не присланы. Нарушение — ValueError с доской и полем.""" + if boards is None: + return + if not isinstance(boards, list): + raise ValueError("boards — список досок") + for n, b in enumerate(boards): + if not isinstance(b, dict): + raise ValueError(f"boards[{n}] — объект доски") + if not _is_id(b.get("id")): + raise ValueError(f"boards[{n}].id — целое > 0") + where = f"доска {b['id']}: " + if b.get("type") is not None and b["type"] not in projects.BOARD_TYPES: + raise ValueError(where + "type — scrum, kanban или null") + cols = b.get("columns") + if cols is not None: + if not isinstance(cols, list): + raise ValueError(where + "columns — список колонок или null") + for k, c in enumerate(cols): + if not isinstance(c, dict) or not isinstance(c.get("name"), str): + raise ValueError(where + f"columns[{k}].name — строка") + st = c.get("statuses") + if not isinstance(st, list) or any(not isinstance(s, str) for s in st): + raise ValueError(where + f"columns[{k}].statuses — список строк") + if b.get("kanban_backlog") is not None and not isinstance(b["kanban_backlog"], bool): + raise ValueError(where + "kanban_backlog — true, false или null") + sprints = b.get("sprints") + if sprints is None: + continue + if not isinstance(sprints, list): + raise ValueError(where + "sprints — список спринтов или null") + for k, s in enumerate(sprints): + w = where + f"sprints[{k}]" + if not isinstance(s, dict): + raise ValueError(f"{w} — объект спринта") + if not _is_id(s.get("id")): + raise ValueError(f"{w}.id — целое > 0") + if not isinstance(s.get("name"), str) or not s["name"]: + raise ValueError(f"{w}.name — непустая строка") + if s.get("state") not in SPRINT_STATES: + raise ValueError(f"{w}.state — active, future или closed") + for f in ("start_date", "end_date", "complete_date", "goal"): + if s.get(f) is not None and not isinstance(s[f], str): + raise ValueError(f"{w}.{f} — строка или null") + + _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", "work_kind": "n.work_kind = ?"} -def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict: +def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True, boards=None) -> dict: """Upsert задач; связи с source_key среди присланных задач заменяются присланными. Связи, у которых источник не прислан, не трогаются до загрузки их источника. authoritative=True — загрузка по базовой JQL: флаг neighbor пересчитывается по neighbor_keys (задача, вошедшая в базовую выборку, перестаёт быть соседней). authoritative=False — точечное обновление (по ключам, по - произвольной JQL): флаг только поднимается, никогда не снижается.""" + произвольной JQL): флаг только поднимается, никогда не снижается. + Поля v15 (агент v15+): у задачи rank, story_points — пишутся, только если ключ есть в словаре задачи (null очищает); + sprint_ids — список заменяет спринты задачи в issue_sprint. boards — список досок: доска заменяет свою строку + jira_board и, если sprints — список, свои спринты (sprints null/нет — спринты не трогаются); тип доски + записывается проектам с этой доской. Без поля или без boards прежние данные не меняются (агент v14).""" fetched_at = fetched_at or db.now() neighbors = set(neighbor_keys) keys = [] @@ -72,6 +145,9 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False) row["fetched_at"] = fetched_at row["neighbor"] = 1 if it["key"] in neighbors else 0 + for f in ("rank", "story_points"): + if f in it: + row[f] = it[f] cols = list(row) sets = [f"{c} = excluded.{c}" for c in cols if c not in ("key", "neighbor")] sets.append("neighbor = excluded.neighbor" if authoritative else "neighbor = MAX(issue.neighbor, excluded.neighbor)") @@ -80,6 +156,10 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit "ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets), [row[c] for c in cols]) keys.append(it["key"]) + if isinstance(it.get("sprint_ids"), list): # без поля (агент до v15) спринты задачи не трогаем + conn.execute("DELETE FROM issue_sprint WHERE key = ?", (it["key"],)) + conn.executemany("INSERT OR IGNORE INTO issue_sprint(key, sprint_id) VALUES (?, ?)", + [(it["key"], sid) for sid in it["sprint_ids"]]) if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],)) conn.executemany( @@ -98,6 +178,21 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit for link in links: conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", (link["source_key"], link["target_key"], link["link_type"])) + for b in boards or []: + conn.execute( + "INSERT OR REPLACE INTO jira_board(board_id, type, columns, kanban_backlog, fetched_at) VALUES (?, ?, ?, ?, ?)", + (b["id"], b.get("type"), + json.dumps(b["columns"], ensure_ascii=False) if b.get("columns") is not None else None, + None if b.get("kanban_backlog") is None else int(b["kanban_backlog"]), fetched_at)) + if isinstance(b.get("sprints"), list): # null/нет — спринты доски не меняются (R-14) + conn.execute("DELETE FROM jira_sprint WHERE board_id = ?", (b["id"],)) + conn.executemany( + "INSERT OR REPLACE INTO jira_sprint(sprint_id, board_id, name, state, start_date, end_date, complete_date, goal) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [(s["id"], b["id"], s["name"], s["state"], s.get("start_date"), s.get("end_date"), + s.get("complete_date"), s.get("goal")) for s in b["sprints"]]) + if b.get("type") in projects.BOARD_TYPES: + projects.record_board_types(conn, {str(b["id"]): b["type"]}) return {"issues": len(keys), "links": len(links)} diff --git a/tests/test_api.py b/tests/test_api.py index 6abe76b..94300bb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1221,3 +1221,47 @@ def test_sweep_files_downloads(hub): finally: conn.close() assert not d.exists() + + +@pytest.mark.parametrize("over, detail", [ + ({"sprint_ids": None}, "A-1: sprint_ids"), ({"sprint_ids": ["1"]}, "A-1: sprint_ids"), ({"sprint_ids": [True]}, "A-1: sprint_ids"), + ({"story_points": "3"}, "A-1: story_points"), ({"story_points": True}, "A-1: story_points"), + ({"story_points": float("nan")}, "A-1: story_points"), ({"story_points": float("inf")}, "A-1: story_points"), # Review Focus 5 + ({"rank": 5}, "A-1: rank"), +]) +def test_agent_state_bad_agile_fields_422(hub, over, detail): + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": [], **over}], "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail + assert hub["client"].get("/issues")["issues"] == [] # пачка не применена + + +@pytest.mark.parametrize("boards, detail", [ + ("x", "boards — список досок"), ([{"id": "4216"}], "boards[0].id"), + ([{"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "sprints": [{"id": 1, "name": "S", "state": "closing"}]}], + "доска 4216: sprints[0].state — active, future или closed"), + ([{"id": 4216, "type": "sprint"}], "доска 4216: type"), + ([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"), +]) +def test_agent_state_bad_boards_422(hub, boards, detail): + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": boards}) + assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail + c = hub["hub"].conn() + try: + assert c.execute("SELECT COUNT(*) FROM jira_board").fetchone()[0] == 0 + finally: + c.close() + + +def test_agent_state_empty_batch_with_boards(hub): + b = {"id": 4216, "type": "scrum", "columns": [{"name": "К", "statuses": ["Backlog"]}], "kanban_backlog": None, + "sprints": [{"id": 101, "name": "S1", "state": "active", "start_date": None, "end_date": None, + "complete_date": None, "goal": None}]} + assert hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": [b]}) == {"issues": 0, "links": 0} + c = hub["hub"].conn() + try: + assert c.execute("SELECT type FROM jira_board WHERE board_id = 4216").fetchone()[0] == "scrum" + assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 + finally: + c.close() diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 6baec01..78c5558 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -1,3 +1,5 @@ +import json + import pytest from sdlc_hub.core import db @@ -406,3 +408,89 @@ def test_get_issue_commands_skip_fetch(conn): conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (1, ?, ?, ?)", (seq, typ, '{"key": "A-1", "attachment_id": "1"}')) assert [c["type"] for c in mirror.get_issue(conn, "A-1")["commands"]] == ["ensure_fields"] + + +COLS = [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}] + + +def board(id=4216, type="scrum", sprints=(), **over): + return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over} + + +def sprint(id, state, name=None, **over): + return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None, + "complete_date": None, "goal": None, **over} + + +def sprint_rows(conn, key): + return sorted(r[0] for r in conn.execute("SELECT sprint_id FROM issue_sprint WHERE key = ?", (key,))) + + +def test_state_issue_in_two_sprints(conn): # сценарий «Задача прошла два спринта» + mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [], boards=[board(sprints=[sprint(101, "closed"), sprint(102, "active")])]) + assert sprint_rows(conn, "X") == [101, 102] + + +def test_state_sprint_ids_replace_keep_clear(conn): + mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], []) + mirror.apply_state(conn, [issue("X", sprint_ids=[102])], []); assert sprint_rows(conn, "X") == [102] + mirror.apply_state(conn, [issue("X")], []); assert sprint_rows(conn, "X") == [102] # агент v14 + mirror.apply_state(conn, [issue("X", sprint_ids=[])], []); assert sprint_rows(conn, "X") == [] + + +def test_state_rank_and_points_set_keep_clear(conn): + mirror.apply_state(conn, [issue("X", rank="0|i0001:", story_points=3)], []) + mirror.apply_state(conn, [issue("X", summary="другая")], []) # без полей — прежние значения + row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone() + assert (row["rank"], row["story_points"]) == ("0|i0001:", 3.0) + mirror.apply_state(conn, [issue("X", rank=None, story_points=None)], []) + row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone() + assert (row["rank"], row["story_points"]) == (None, None) + + +def test_state_board_replaces_row_and_sprints(conn): # сценарий «Пачка с досками» + mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active", "старое"), sprint(102, "future")]), + board(id=5120, type="kanban", sprints=[sprint(201, "active")])]) + new = [sprint(101, "closed", complete_date="2026-10-14T18:00:00.000+03:00"), sprint(102, "active"), sprint(103, "future")] + mirror.apply_state(conn, [], [], boards=[board(columns=COLS[:1], sprints=new)]) + rows = {r["sprint_id"]: dict(r) for r in conn.execute("SELECT * FROM jira_sprint WHERE board_id = 4216")} + assert set(rows) == {101, 102, 103} and rows[101]["state"] == "closed" and rows[101]["name"] == "S101" + assert rows[101]["complete_date"] == "2026-10-14T18:00:00.000+03:00" + b = conn.execute("SELECT * FROM jira_board WHERE board_id = 4216").fetchone() + assert json.loads(b["columns"]) == COLS[:1] and b["kanban_backlog"] is None and b["fetched_at"] + assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint WHERE board_id = 5120")] == [201] # не в пачке — не тронута + + +def test_state_board_without_sprints_keeps_them(conn): # R-14 + mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active")])]) + mirror.apply_state(conn, [], [], boards=[{**board(), "sprints": None, "columns": None}]) + assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint")] == [101] + assert conn.execute("SELECT columns FROM jira_board").fetchone()[0] is None + mirror.apply_state(conn, [], [], boards=[board(sprints=[])]) + assert conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 0 + + +def test_state_board_type_to_all_projects_of_board(conn, add_project): # Review Focus 3 + add_project(conn, board_type=None); add_project(conn, "C", board_id=4216, board_type=None) + mirror.apply_state(conn, [], [], boards=[board(type="kanban", kanban_backlog=True)]) + assert {r["label"]: r["board_type"] for r in conn.execute("SELECT label, board_type FROM jira_project")} == {"AWPCA": "kanban", "C": "kanban"} + assert tuple(conn.execute("SELECT type, kanban_backlog FROM jira_board").fetchone()) == ("kanban", 1) + mirror.apply_state(conn, [], [], boards=[board(type=None)]) # null тип проекта не меняет + assert conn.execute("SELECT board_type FROM jira_project WHERE label = 'AWPCA'").fetchone()[0] == "kanban" + + +def test_state_v14_keeps_agile_data(conn): # сценарий «Агент v14» + mirror.apply_state(conn, [issue("X", rank="r", story_points=2, sprint_ids=[101])], [], boards=[board(sprints=[sprint(101, "active")])]) + mirror.apply_state(conn, [issue("X")], []) + assert sprint_rows(conn, "X") == [101] and conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 + assert tuple(conn.execute("SELECT rank, story_points FROM issue").fetchone()) == ("r", 2.0) + + +def test_check_agile_fields_ok_and_absent(): + mirror.check_agile_fields([{"key": "A"}, {"key": "B", "rank": None, "story_points": 0.5, "sprint_ids": []}, + {"key": "C", "rank": "r", "story_points": 3, "sprint_ids": [1, 2]}]) + + +def test_check_boards_ok_and_none(): + mirror.check_boards(None) + mirror.check_boards([board(sprints=[sprint(1, "closed", start_date="2026-10-01")]), {"id": 5, "type": None}])