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"}