feat(api): GET /board — снимок зеркала для доски связей (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cc7b83fbb8
commit
fbd47873b7
@ -138,6 +138,10 @@ def h_issue(hub, conn, m, q, body):
|
|||||||
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
|
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
|
||||||
|
|
||||||
|
|
||||||
|
def h_board(hub, conn, m, q, body):
|
||||||
|
return 200, mirror.board(conn, settings.get(conn, "base_jql"))
|
||||||
|
|
||||||
|
|
||||||
def h_note_put(hub, conn, m, q, body):
|
def h_note_put(hub, conn, m, q, body):
|
||||||
fields = {k: v for k, v in body.items() if k != "updated_by"}
|
fields = {k: v for k, v in body.items() if k != "updated_by"}
|
||||||
try:
|
try:
|
||||||
@ -253,6 +257,7 @@ ROUTES = [
|
|||||||
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
|
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
|
||||||
("GET", r"^/issues$", h_issues, "client"),
|
("GET", r"^/issues$", h_issues, "client"),
|
||||||
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
|
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
|
||||||
|
("GET", r"^/board$", h_board, "client"),
|
||||||
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
|
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
|
||||||
("POST", r"^/batches$", h_batches_post, "client"),
|
("POST", r"^/batches$", h_batches_post, "client"),
|
||||||
("GET", r"^/batches$", h_batches_get, "client"),
|
("GET", r"^/batches$", h_batches_get, "client"),
|
||||||
|
|||||||
@ -91,6 +91,34 @@ def get_issue(conn, key) -> dict | None:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate", "planned_start",
|
||||||
|
"planned_end", "epic_key", "labels", "neighbor"]
|
||||||
|
|
||||||
|
|
||||||
|
def board(conn, base_jql) -> dict:
|
||||||
|
"""Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний
|
||||||
|
успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски
|
||||||
|
делает интерфейс. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы."""
|
||||||
|
own = not conn.in_transaction
|
||||||
|
if own:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
try:
|
||||||
|
issues = [_issue_dict(r) for r in conn.execute(
|
||||||
|
f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind "
|
||||||
|
"FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")]
|
||||||
|
links = [dict(r) for r in conn.execute(
|
||||||
|
"SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
|
||||||
|
"ORDER BY source_key, target_key, link_type")]
|
||||||
|
run = conn.execute(
|
||||||
|
"SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id "
|
||||||
|
"WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' "
|
||||||
|
"ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone()
|
||||||
|
finally:
|
||||||
|
if own:
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
return {"generated_at": db.now(), "last_sync": dict(run) if run else None, "issues": issues, "links": links}
|
||||||
|
|
||||||
|
|
||||||
def get_note(conn, key) -> dict | None:
|
def get_note(conn, key) -> dict | None:
|
||||||
row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone()
|
row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|||||||
@ -339,3 +339,27 @@ def test_duplicate_summary_batch_is_422(hub):
|
|||||||
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Шахматка: комментарии"}},
|
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Шахматка: комментарии"}},
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Шахматка: комментарии"}}]})
|
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Шахматка: комментарии"}}]})
|
||||||
assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail
|
assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_route(hub):
|
||||||
|
a, c = hub["agent"], hub["client"]
|
||||||
|
a.post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "labels": ["kind-dev"]},
|
||||||
|
{"key": "B-1", "summary": "Сосед", "labels": []}],
|
||||||
|
"links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}],
|
||||||
|
"neighbor_keys": ["B-1"]})
|
||||||
|
snap = c.get("/board")
|
||||||
|
assert [i["key"] for i in snap["issues"]] == ["A-1", "B-1"]
|
||||||
|
assert snap["issues"][0]["labels"] == ["kind-dev"] and snap["issues"][1]["neighbor"] is True
|
||||||
|
assert snap["links"] == [{"source": "A-1", "target": "B-1", "link_type": "ASUPR_Связанные"}]
|
||||||
|
assert snap["last_sync"] is None
|
||||||
|
b = c.post("/refresh", {"full": True, "author": "viewer"})
|
||||||
|
cmd = a.post("/agent/claim", {})["command"]
|
||||||
|
a.post("/agent/state", {"command_id": cmd["id"], "issues": [{"key": "A-1", "summary": "Т2", "labels": []}], "links": []})
|
||||||
|
a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
|
||||||
|
assert c.get(f"/batches/{b['id']}")["author"] == "viewer"
|
||||||
|
last = c.get("/board")["last_sync"]
|
||||||
|
assert last["issues_count"] == 1 and last["finished_at"]
|
||||||
|
for client in (a, HubClient(hub["base"], "")):
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
client.get("/board")
|
||||||
|
assert e.value.status == 401
|
||||||
|
|||||||
@ -135,3 +135,48 @@ def test_rename_note_empty_string_does_not_overwrite():
|
|||||||
mirror.rename_note(conn, "new:6", "A-6")
|
mirror.rename_note(conn, "new:6", "A-6")
|
||||||
note = mirror.get_note(conn, "A-6")
|
note = mirror.get_note(conn, "A-6")
|
||||||
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано"
|
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано"
|
||||||
|
|
||||||
|
|
||||||
|
BASE = "project = AURORA AND labels = AWPCA"
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_run(conn, started, finished, kind, query, status="done", count=5):
|
||||||
|
"""Строка журнала синхронизаций с командой refresh в нужном статусе."""
|
||||||
|
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('t', 'test', ?)", (started,))
|
||||||
|
batch_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
|
||||||
|
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at) VALUES (?, 1, 'refresh', '{}', ?, ?)",
|
||||||
|
(batch_id, status, finished))
|
||||||
|
command_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
|
||||||
|
conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, issues_count, command_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(started, finished, kind, query, count, command_id))
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_snapshot_raw_mirror_data():
|
||||||
|
conn = db.connect(":memory:")
|
||||||
|
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
|
||||||
|
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
|
||||||
|
issue("AURORA-1621", epic_key="AURORA-1351")],
|
||||||
|
[{"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}],
|
||||||
|
neighbor_keys=["AURORA-1621"])
|
||||||
|
mirror.set_note(conn, "AURORA-1620", {"work_kind": "разработка"}, "test")
|
||||||
|
snap = mirror.board(conn, BASE)
|
||||||
|
assert [i["key"] for i in snap["issues"]] == ["AURORA-1351", "AURORA-1620", "AURORA-1621"]
|
||||||
|
a = snap["issues"][1]
|
||||||
|
assert set(a) == {"key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate",
|
||||||
|
"planned_start", "planned_end", "epic_key", "labels", "neighbor", "fact_status", "work_kind"}
|
||||||
|
assert a["labels"] == ["kind-dev"] and a["epic_key"] == "AURORA-1351"
|
||||||
|
assert a["work_kind"] == "разработка" and a["fact_status"] is None and a["neighbor"] is False
|
||||||
|
assert snap["issues"][2]["neighbor"] is True and snap["issues"][2]["work_kind"] is None
|
||||||
|
assert snap["links"] == [{"source": "AURORA-1620", "target": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}]
|
||||||
|
assert snap["last_sync"] is None and snap["generated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_last_sync_is_last_successful_full_refresh():
|
||||||
|
conn = db.connect(":memory:")
|
||||||
|
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
|
||||||
|
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
|
||||||
|
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
|
||||||
|
_sync_run(conn, "2026-10-14T12:00:00+00:00", "2026-10-14T12:01:00+00:00", "jql", BASE, status="failed", count=0)
|
||||||
|
_sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50)
|
||||||
|
assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00",
|
||||||
|
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user