Спринты и бэклог в модуле jira (#108) #109

Merged
aep merged 21 commits from feat/108-sprints-backlog into main 2026-10-25 13:56:42 +03:00
6 changed files with 287 additions and 3 deletions
Showing only changes of commit 1f78d70a02 - Show all commits

View File

@ -47,7 +47,8 @@
| `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` |
| `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` |
| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` |
| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` |
| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` |
| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` |
| `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` |
| `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` |
@ -120,7 +121,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
Цепочки зависимостей (Python):
- ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`;
- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`;
- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `importer → mirror`;
- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `api → agile → history`; `importer → mirror`;
- веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта).
## Потоки данных

View File

@ -217,6 +217,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
| `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) |
| `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` |
| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `work_kind` из заметок; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон |
| `GET /agile` | клиент | `?project=<метка>` (без — 422, неизвестная — 404): доска проекта (`board`, тип из проекта; `null` — доски нет), все спринты доски со своими задачами и суммами оценок (`count`, `count_done`, `unestimated`, `points`, `points_done`), `backlog` (scrum — свои задачи вне активных и будущих спринтов, не выполненные; kanban — первая колонка, если бэклог доски включён), `board_issues` (kanban: остальные колонки, выполненные — за 14 дней); списки — по рангу |
| `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») |
| `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта, `?module=<имя>` — только пакеты модуля (по типу первой команды; неизвестный модуль — 404), `limit`; в каждом пакете поле `module` (`null` — не определён) |

View File

@ -0,0 +1,105 @@
"""Чтение для GET /agile: доска проекта, спринты со своими задачами и суммами оценок, бэклог. Только чтение зеркала;
время Jira сравнивается в UTC через history.jira_utc (смещения ±HHMM и ±HH:MM, Z)."""
import json
from datetime import datetime, timedelta, timezone
from . import history
KANBAN_DONE_DAYS = 14
_FIELDS = ("key", "summary", "issue_type", "status", "priority", "assignee", "epic_key", "story_points", "rank")
def _rank_key(row: dict):
return (row["rank"] is None, row["rank"] or "", row["key"])
def _sprint_order(sprints: list[dict]) -> list[dict]:
"""active — по началу (без даты в конце), future — по id, closed — по закрытию по убыванию (без даты в конце), при равенстве — id."""
far = "9999-12-31T23:59:59+00:00"
def ts(value):
return history.jira_utc(value)
active = sorted((s for s in sprints if s["state"] == "active"), key=lambda s: (ts(s["start_date"]) or far, s["id"]))
future = sorted((s for s in sprints if s["state"] == "future"), key=lambda s: s["id"])
closed = [s for s in sprints if s["state"] == "closed"]
dated = sorted((s for s in closed if ts(s["complete_date"])),
key=lambda s: (-datetime.fromisoformat(ts(s["complete_date"])).timestamp(), s["id"]))
undated = sorted((s for s in closed if not ts(s["complete_date"])), key=lambda s: s["id"])
other = [s for s in sprints if s["state"] not in ("active", "future", "closed")]
return active + future + dated + undated + sorted(other, key=lambda s: s["id"])
def view(conn, label: str, now: datetime | None = None) -> dict:
now = now or datetime.now(timezone.utc)
own = not conn.in_transaction
if own:
conn.execute("BEGIN")
try:
return _view(conn, label, now)
finally:
if own:
conn.execute("COMMIT")
def _view(conn, label: str, now: datetime) -> dict:
proj = conn.execute("SELECT board_id, board_type FROM jira_project WHERE label = ?", (label,)).fetchone()
if proj is None or proj["board_id"] is None:
return {"board": None, "sprints": [], "backlog": None, "board_issues": None}
board_id, btype = proj["board_id"], proj["board_type"]
brow = conn.execute("SELECT * FROM jira_board WHERE board_id = ?", (board_id,)).fetchone()
columns = json.loads(brow["columns"]) if brow and brow["columns"] else None
kb = brow["kanban_backlog"] if brow else None
board = {"id": board_id, "type": btype, "columns": columns,
"kanban_backlog": None if kb is None else bool(kb), "fetched_at": brow["fetched_at"] if brow else None}
if btype is None:
return {"board": board, "sprints": [], "backlog": None, "board_issues": None}
done_statuses = set(columns[-1]["statuses"]) if columns else set()
rows = []
for r in conn.execute(
"SELECT i.key, i.summary, i.issue_type, i.status, i.priority, i.assignee, i.epic_key, i.story_points, i.rank, "
"i.jira_updated FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)", (label,)):
d = dict(r)
d["done"] = d["status"] in done_statuses
rows.append(d)
rows.sort(key=_rank_key)
def out(r):
return {k: r[k] for k in _FIELDS} | {"done": r["done"]}
if btype == "kanban":
first = set(columns[0]["statuses"]) if columns else set()
cutoff = now - timedelta(days=KANBAN_DONE_DAYS)
backlog, board_issues = ([] if kb == 1 else None), []
for r in rows:
if kb == 1 and r["status"] in first:
backlog.append(out(r))
continue
if r["done"]:
u = history.jira_utc(r["jira_updated"])
if u is None or datetime.fromisoformat(u) < cutoff:
continue
board_issues.append(out(r))
return {"board": board, "sprints": [], "backlog": backlog, "board_issues": board_issues}
members: dict[int, list[dict]] = {}
for r in conn.execute("SELECT key, sprint_id FROM issue_sprint"):
members.setdefault(r["sprint_id"], []).append(r["key"])
by_key = {r["key"]: r for r in rows}
sprints = []
for s in conn.execute("SELECT * FROM jira_sprint WHERE board_id = ?", (board_id,)):
issues = [out(by_key[k]) for k in members.get(s["sprint_id"], []) if k in by_key]
issues.sort(key=_rank_key)
done = [i for i in issues if i["done"]]
pts = lambda xs: sum(i["story_points"] for i in xs if i["story_points"] is not None) # noqa: E731
sprints.append({"id": s["sprint_id"], "name": s["name"], "state": s["state"], "start_date": s["start_date"],
"end_date": s["end_date"], "complete_date": s["complete_date"], "goal": s["goal"],
"count": len(issues), "count_done": len(done),
"unestimated": sum(1 for i in issues if i["story_points"] is None),
"points": pts(issues), "points_done": pts(done), "issues": issues})
busy = {r["key"] for r in conn.execute(
"SELECT s.key FROM issue_sprint s JOIN jira_sprint p ON p.sprint_id = s.sprint_id WHERE p.state IN ('active', 'future')")}
backlog = [out(r) for r in rows if r["key"] not in busy and not r["done"]]
return {"board": board, "sprints": _sprint_order(sprints), "backlog": backlog, "board_issues": None}

View File

@ -9,7 +9,7 @@ from urllib.parse import quote
from ...core import db, files, queue, settings
from ...core.errors import CommandError, HttpMapped
from ...core.http import ApiError, Raw, int_param, mapped, not_found, project_param
from . import attachments, fetch, history, mirror, refresh
from . import agile, attachments, fetch, history, mirror, refresh
from . import projects as jira_projects
log = logging.getLogger(__name__)
@ -98,6 +98,13 @@ def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, jira_projects.base_jql(conn), project_param(conn, q))
def h_agile(hub, conn, m, q, body):
label = project_param(conn, q)
if label is None:
raise ApiError(422, "bad_request", "project — метка проекта")
return 200, agile.view(conn, label)
def h_note_put(hub, conn, m, q, body):
fields = {k: v for k, v in body.items() if k != "updated_by"}
try:
@ -180,6 +187,7 @@ ROUTES = (
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
("GET", r"^/agile$", h_agile, "client"),
("GET", r"^/changes$", h_changes, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),

View File

@ -83,6 +83,12 @@
"client",
"h_agents_get"
],
[
"GET",
"^/agile$",
"client",
"h_agile"
],
[
"GET",
"^/app(?:/(.*))?$",

163
tests/test_agile.py Normal file
View File

@ -0,0 +1,163 @@
from datetime import datetime, timezone
import pytest
from sdlc_hub.core import settings
from sdlc_hub.core.client import HubError
from sdlc_hub.modules.jira import agile, mirror
COLS = [{"name": "К выполнению", "statuses": ["Backlog", "Ожидает анализа"]}, {"name": "В работе", "statuses": ["В работе"]},
{"name": "Готово", "statuses": ["Готово", "Протестировано", "Снято"]}]
@pytest.fixture
def conn(open_db, add_project):
c = open_db()
settings.ensure_defaults(c)
add_project(c)
return c
def issue(key, status, **over):
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "", "status": status,
"priority": "Средний", "assignee": None, "reporter": "me", "duedate": None, "planned_start": None,
"planned_end": None, "epic_key": None, "labels": ["AWPCA"], "sprint": None,
"created": "2026-10-10T12:00:00.000+0300", "jira_updated": "2026-10-11T12:00:00.000+0300"}
return base | over
def kissue(key, status, **over):
return issue(key, status, labels=["K"], **over)
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 load(conn, issues, boards):
mirror.apply_state(conn, issues, [], boards=boards)
def keys(rows):
return [r["key"] for r in rows]
def test_sprint_and_backlog_sums(conn): # сценарий «Спринт и бэклог»
load(conn, [issue("A", "Готово", story_points=3, sprint_ids=[102], rank="0|a"), issue("B", "В работе", sprint_ids=[102], rank="0|b"),
issue("C", "Backlog", sprint_ids=[]), issue("D", "Готово", sprint_ids=[])],
[board(sprints=[sprint(102, "active")])])
v = agile.view(conn, "AWPCA")
s = v["sprints"][0]
assert (s["count"], s["count_done"], s["points"], s["points_done"], s["unestimated"]) == (2, 1, 3, 3, 1)
assert keys(s["issues"]) == ["A", "B"] and keys(v["backlog"]) == ["C"] and v["board_issues"] is None
def test_done_is_last_column(conn): # сценарий «Колонки доски»
load(conn, [issue("P", "Протестировано", sprint_ids=[102]), issue("Q", "Backlog", sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
assert {i["key"]: i["done"] for i in agile.view(conn, "AWPCA")["sprints"][0]["issues"]} == {"P": True, "Q": False}
def test_project_without_board(conn, add_project): # сценарий «Проект без доски»
add_project(conn, "STOCKS_DRRK", board_id=None, board_type=None)
assert agile.view(conn, "STOCKS_DRRK") == {"board": None, "sprints": [], "backlog": None, "board_issues": None}
def test_board_without_agent_data(conn): # design #23, состояние 1
load(conn, [issue("A", "Готово")], [])
v = agile.view(conn, "AWPCA")
assert v["board"] == {"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "fetched_at": None}
assert [i["done"] for i in v["backlog"]] == [False] # без колонок выполненных нет
def test_sprint_order_mixed_offsets(conn): # порядок спринтов; Review Focus 1
load(conn, [], [board(sprints=[
sprint(3, "active", start_date="2026-10-14T09:00:00.000+0300"), sprint(5, "active", start_date="2026-10-01T09:00:00.000+03:00"),
sprint(9, "future"), sprint(7, "future"),
sprint(1, "closed", complete_date="2026-10-01T02:00:00.000+0300"), sprint(2, "closed", complete_date="2026-09-30T23:30:00.000Z")])])
assert [s["id"] for s in agile.view(conn, "AWPCA")["sprints"]] == [5, 3, 7, 9, 2, 1] # 2 закрыт позже 1 по UTC
def test_rank_order_nulls_last_then_key(conn):
load(conn, [issue("Z-1", "Backlog", rank="0|i0002:"), issue("Z-2", "Backlog", rank="0|i0001:"),
issue("B-1", "Backlog", rank=None), issue("A-1", "Backlog")], [board()])
assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["Z-2", "Z-1", "A-1", "B-1"]
def test_only_own_issues_and_empty_sprint(conn): # design #8
load(conn, [issue("N", "Backlog", labels=["OTHER"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
s = agile.view(conn, "AWPCA")["sprints"][0]
assert s["issues"] == [] and s["count"] == 0
def test_shared_board_own_issues_only(conn, add_project): # Review Focus 3
add_project(conn, "C", board_id=4216)
load(conn, [issue("A", "Backlog", sprint_ids=[102]), issue("K", "Backlog", labels=["C"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
assert keys(agile.view(conn, "C")["sprints"][0]["issues"]) == ["K"] and keys(agile.view(conn, "AWPCA")["sprints"][0]["issues"]) == ["A"]
def test_closed_sprint_done_by_current_status(conn): # design #7
load(conn, [issue("X", "Готово", sprint_ids=[101])], [board(sprints=[sprint(101, "closed")])])
assert agile.view(conn, "AWPCA")["sprints"][0]["count_done"] == 1
def test_backlog_keeps_issue_of_closed_sprint(conn):
load(conn, [issue("X", "Backlog", sprint_ids=[101]), issue("Y", "Backlog", sprint_ids=[103])],
[board(sprints=[sprint(101, "closed"), sprint(103, "future")])])
assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["X"]
KCOLS = [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]},
{"name": "Готово", "statuses": ["Готово"]}]
NOW = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
def test_kanban_with_backlog(conn, add_project): # design #3, «Контракт данных»
add_project(conn, "K", board_id=5120, board_type="kanban")
now = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе"), kissue("K3", "Готово", jira_updated="2026-10-21T10:00:00.000+0300"),
kissue("K4", "Готово", jira_updated="2026-10-01T10:00:00.000+0300"), kissue("K5", "Странный"),
kissue("K6", "Готово", jira_updated=None)],
[board(id=5120, type="kanban", kanban_backlog=True, columns=[{"name": "Бэклог", "statuses": ["Backlog"]},
{"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}])])
v = agile.view(conn, "K", now=now)
assert v["sprints"] == [] and keys(v["backlog"]) == ["K1"] and sorted(keys(v["board_issues"])) == ["K2", "K3", "K5"]
assert v["board"]["kanban_backlog"] is True
def test_kanban_without_backlog(conn, add_project): # design #17
add_project(conn, "K", board_id=5120, board_type="kanban")
load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе")],
[board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)])
v = agile.view(conn, "K", now=NOW)
assert v["backlog"] is None and sorted(keys(v["board_issues"])) == ["K1", "K2"]
assert v["board"]["kanban_backlog"] is False
def test_kanban_done_cutoff_with_offsets(conn, add_project): # Review Focus 1: граница 14 дней по UTC
add_project(conn, "K", board_id=5120, board_type="kanban")
load(conn, [kissue("OLD", "Готово", jira_updated="2026-10-10T14:30:00.000+0300"), # 11:30Z — старше 14 дней
kissue("NEW", "Готово", jira_updated="2026-10-10T15:30:00.000+0300")], # 12:30Z — в пределах
[board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)])
assert keys(agile.view(conn, "K", now=NOW)["board_issues"]) == ["NEW"]
def test_agile_route(hub_server, add_project):
h = hub_server["hub"]
c = h.conn()
try:
add_project(c)
finally:
c.close()
client = hub_server["client"]
with pytest.raises(HubError) as e:
client.get("/agile")
assert e.value.status == 422 and e.value.error == "bad_request"
with pytest.raises(HubError) as e:
client.get("/agile", project="NOPE")
assert e.value.status == 404
assert set(client.get("/agile", project="AWPCA")) == {"board", "sprints", "backlog", "board_issues"}