Приём досок, спринтов, ранга и оценок от агента v15 (#108)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ef48e62953
commit
4f9024d5c3
@ -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` |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user