Приём досок, спринтов, ранга и оценок от агента v15 (#108)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 18:39:56 +04:00
parent ef48e62953
commit 4f9024d5c3
5 changed files with 235 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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}])