Правки по финальному ревью: диапазоны чисел, strip, устойчивые тесты (#108)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f5be375990
commit
26a4878782
@ -47,7 +47,7 @@
|
||||
| `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` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)), паузы, `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` |
|
||||
| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` |
|
||||
| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `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` (колонка SP), `issue`, `sprints`, `sprint`, `backlog` (читают `GET /agile`), `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` |
|
||||
|
||||
@ -341,9 +341,9 @@ make web
|
||||
`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment`, `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`
|
||||
30 с, `refresh` 2 с (настройка `pacing`).
|
||||
|
||||
Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) и `ensure_rank` ставятся только в пакете проекта (поле `project`):
|
||||
`board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»;
|
||||
доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. `ensure_rank` в пакете проекта — `key` своей задачи.
|
||||
Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) ставятся только в пакете проекта (поле `project`); `ensure_rank` — в любом пакете.
|
||||
У команд спринта `board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»;
|
||||
доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. В пакете проекта у `ensure_rank` `key` должен быть своей задачей.
|
||||
Даты проверяются хабом: настоящая дата, начало не позже конца.
|
||||
|
||||
`ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл <sha> не
|
||||
@ -362,7 +362,8 @@ make web
|
||||
(владелец; миграция схемы v9) → `make web` → `/mcp` reconnect → `agent publish` агента v15 (`uv run sdlc-hub agent publish
|
||||
../jira-agent/src/agent.py`) → проверка полей Jira банка (ранг, оценка, спринты) → `field_ids.story_points` в настройках,
|
||||
если агент поле оценки не нашёл. `min_agent_version` поднимает владелец после выкатки v15 и после того, как `agent_status`
|
||||
покажет новую версию.
|
||||
покажет новую версию. Ограничение (R-33): до публикации агента v15 `story_points` в `ensure_fields` агент v14 выполнит как
|
||||
`done` с пустым `changed`; v15 публикуется сразу после перезапуска хаба.
|
||||
- Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-<batch>.sqlite`
|
||||
и хранит последние `backup_keep` копий (по умолчанию 30).
|
||||
- Файлы вложений: `files/<sha256>`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый
|
||||
|
||||
@ -81,6 +81,13 @@ def temp_keys_used(cmd) -> list[str]:
|
||||
return [k for k in referenced_keys(cmd) if is_temp(k) and k != own]
|
||||
|
||||
|
||||
def _finite(value) -> bool:
|
||||
try:
|
||||
return math.isfinite(value)
|
||||
except OverflowError: # целое 10**400 не приводится к float
|
||||
return False
|
||||
|
||||
|
||||
def _type_error(ctype, name, value) -> str | None:
|
||||
"""Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500.
|
||||
null допустим везде — обязательность проверяется отдельно."""
|
||||
@ -91,7 +98,7 @@ def _type_error(ctype, name, value) -> str | None:
|
||||
if name in BOOL_PARAMS:
|
||||
return None if isinstance(value, bool) else f"{name} — true или false"
|
||||
if name in NUMBER_PARAMS: # конечное число ≥ 0; bool, NaN и Infinity — не число (R-15)
|
||||
ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0
|
||||
ok = isinstance(value, (int, float)) and not isinstance(value, bool) and _finite(value) and value >= 0
|
||||
return None if ok else f"{name} — число ≥ 0 или null"
|
||||
if ctype == "ensure_status" and name == "target":
|
||||
return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк"
|
||||
@ -122,23 +129,26 @@ def _rule_error(ctype, p) -> str | None:
|
||||
if start and end and start > end:
|
||||
return "start_date позже end_date"
|
||||
if ctype == "ensure_sprint_state":
|
||||
if not p["sprint"].strip():
|
||||
return "sprint — непустое имя"
|
||||
if p["state"] not in ("active", "closed"):
|
||||
return "state — active или closed"
|
||||
move = p.get("move_open_to")
|
||||
move = (p.get("move_open_to") or "").strip()
|
||||
if p["state"] == "closed":
|
||||
if move in (None, ""):
|
||||
if not move:
|
||||
return "move_open_to обязателен при state = closed"
|
||||
if move == p["sprint"]:
|
||||
if move == p["sprint"].strip():
|
||||
return "move_open_to — другой спринт"
|
||||
elif move not in (None, ""):
|
||||
elif move:
|
||||
return "move_open_to — только при state = closed"
|
||||
if ctype == "ensure_rank":
|
||||
before, after = p.get("before") or None, p.get("after") or None
|
||||
before = (p.get("before") or "").strip() or None # пустая и пробельная строка — «не задано» (R-31)
|
||||
after = (p.get("after") or "").strip() or None
|
||||
if bool(before) == bool(after):
|
||||
return "ровно одно из before, after"
|
||||
if before == p["key"]:
|
||||
if before == p["key"].strip():
|
||||
return "before — другая задача"
|
||||
if after == p["key"]:
|
||||
if after == p["key"].strip():
|
||||
return "after — другая задача"
|
||||
return None
|
||||
|
||||
|
||||
@ -55,6 +55,21 @@ def check_attachments(issues) -> None:
|
||||
raise ValueError(f"{where}.{f} — строка или null")
|
||||
|
||||
|
||||
_INT_MAX = 2**63 - 1 # диапазон SQLite INTEGER
|
||||
|
||||
|
||||
def _in_range(v) -> bool:
|
||||
return -2**63 <= v <= _INT_MAX
|
||||
|
||||
|
||||
def _finite(v) -> bool:
|
||||
"""Конечное число; огромные целые (10**400) не падают с OverflowError, а считаются неподходящими."""
|
||||
try:
|
||||
return math.isfinite(v)
|
||||
except OverflowError:
|
||||
return False
|
||||
|
||||
|
||||
def check_agile_fields(issues) -> None:
|
||||
"""Форма полей rank, story_points, sprint_ids у задач состояния (агент v15+); поля необязательны (не присланное
|
||||
не проверяется). Нарушение — ValueError с ключом и полем."""
|
||||
@ -64,16 +79,16 @@ def check_agile_fields(issues) -> None:
|
||||
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):
|
||||
if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not _finite(sp) or (isinstance(sp, int) and not _in_range(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):
|
||||
if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) or not _in_range(i) 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
|
||||
return isinstance(v, int) and not isinstance(v, bool) and 0 < v <= _INT_MAX
|
||||
|
||||
|
||||
def check_boards(boards) -> None:
|
||||
|
||||
@ -1228,6 +1228,8 @@ def test_sweep_files_downloads(hub):
|
||||
({"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"),
|
||||
({"story_points": 10**400}, "A-1: story_points"), ({"story_points": 2**63}, "A-1: story_points"),
|
||||
({"sprint_ids": [2**63]}, "A-1: sprint_ids"), ({"sprint_ids": [-2**63 - 1]}, "A-1: sprint_ids"),
|
||||
])
|
||||
def test_agent_state_bad_agile_fields_422(hub, over, detail):
|
||||
with pytest.raises(HubError) as e:
|
||||
@ -1242,16 +1244,19 @@ def test_agent_state_bad_agile_fields_422(hub, over, detail):
|
||||
"доска 4216: sprints[0].state — active, future или closed"),
|
||||
([{"id": 4216, "type": "sprint"}], "доска 4216: type"),
|
||||
([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"),
|
||||
([{"id": 2**63}], "boards[0].id"),
|
||||
([{"id": 4216, "sprints": [{"id": 2**63, "name": "S", "state": "active"}]}], "sprints[0].id"),
|
||||
])
|
||||
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})
|
||||
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": []}], "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()
|
||||
assert hub["client"].get("/issues")["issues"] == [] # пачка не применена
|
||||
|
||||
|
||||
def test_agent_state_empty_batch_with_boards(hub):
|
||||
|
||||
@ -298,11 +298,22 @@ def test_new_types_validate_ok():
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError
|
||||
({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "sprint — непустое имя"),
|
||||
(state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"),
|
||||
(state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"),
|
||||
(state(state="active", move_open_to="x"), "move_open_to — только при state = closed"),
|
||||
(rank(before=" ", after=" "), "ровно одно из before, after"),
|
||||
(rank(before=" AURORA-1612 "), "before — другая задача"),
|
||||
])
|
||||
def test_new_types_errors(cmd, msg):
|
||||
with pytest.raises(CommandError, match=re.escape(msg)):
|
||||
commands.validate([cmd])
|
||||
|
||||
def test_blank_state_move_open_to_is_unset_when_active(): # R-31: пробельная строка — «не задано»
|
||||
assert commands.validate([state(state="active", move_open_to=" ")])
|
||||
assert commands.validate([rank(before="A-2", after=" ")])
|
||||
|
||||
def test_rank_temp_keys():
|
||||
ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")]
|
||||
assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"]
|
||||
|
||||
@ -926,8 +926,9 @@ def test_fmt_backlog_limit(): # сценарий «
|
||||
|
||||
|
||||
def test_fmt_day_offsets():
|
||||
assert jmcp.fmt_day("2026-10-14T09:00:00.000+0300") == jmcp.fmt_day("2026-10-14T09:00:00.000+03:00") != "—"
|
||||
assert jmcp.fmt_day("2026-10-14T09:00:00.000Z") != "—"
|
||||
want = datetime(2026, 10, 14, 12, tzinfo=timezone.utc).astimezone().strftime("%d.%m") # один момент во всех записях
|
||||
for raw in ("2026-10-14T12:00:00.000Z", "2026-10-14T15:00:00.000+0300", "2026-10-14T15:00:00.000+03:00"):
|
||||
assert jmcp.fmt_day(raw) == want
|
||||
assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—"
|
||||
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)
|
||||
test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", async ({ page }) => {
|
||||
await login(page, "/app/jira/sprint?project=AWPCA");
|
||||
await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
|
||||
await expect(page.getByText("осталось 7 дн.")).toBeVisible();
|
||||
await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible();
|
||||
for (const name of ["К выполнению", "В работе", "Готово"]) {
|
||||
await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible();
|
||||
}
|
||||
|
||||
@ -22,7 +22,9 @@ const agile = (backlog: boolean | null): AgileResponse => ({
|
||||
board: { id: 5120, type: "kanban", columns: cols, kanban_backlog: backlog, fetched_at: "2026-10-24T10:00:00+00:00" },
|
||||
sprints: [],
|
||||
backlog: backlog ? [iss("AURORA-1"), iss("AURORA-2")] : null,
|
||||
board_issues: [iss("AURORA-1"), iss("AURORA-2"), iss("AURORA-3", { status: "В работе" }), iss("AURORA-4", { status: "Готово", done: true }), iss("AURORA-5", { status: "Странный" })],
|
||||
// как отвечает сервер: при kanban_backlog = true задачи первой колонки есть только в backlog, не в board_issues
|
||||
board_issues: [...(backlog ? [] : [iss("AURORA-1"), iss("AURORA-2")]), iss("AURORA-3", { status: "В работе" }),
|
||||
iss("AURORA-4", { status: "Готово", done: true }), iss("AURORA-5", { status: "Странный" })],
|
||||
});
|
||||
|
||||
function hub(data: AgileResponse, projects = [K]) {
|
||||
|
||||
@ -16,9 +16,7 @@ export function KanbanPage({ project }: { project: Project }) {
|
||||
{(d) => {
|
||||
const withBacklog = d.board?.kanban_backlog === true;
|
||||
const cols = d.board?.columns ?? null;
|
||||
// задачи первой колонки уходят в «Бэклог», а не в «Без колонки»
|
||||
const first = new Set(withBacklog && cols ? cols[0]?.statuses ?? [] : []);
|
||||
const issues = (d.board_issues ?? []).filter((i) => i.status === null || !first.has(i.status));
|
||||
const issues = d.board_issues ?? []; // сервер уже убрал задачи первой колонки, если бэклог включён
|
||||
const backlog = d.backlog ?? [];
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -19,7 +19,9 @@ const iss = (key: string, over: Partial<AgileIssue> = {}): AgileIssue => ({
|
||||
const iso = (offsetDays: number) => {
|
||||
const d = new Date(Date.now() + offsetDays * 86_400_000);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000+0300`;
|
||||
d.setHours(12, 0, 0, 0); // полдень местного времени и местное смещение: результат не зависит от пояса и часа суток
|
||||
const off = -d.getTimezoneOffset(), a = Math.abs(off);
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000${off < 0 ? "-" : "+"}${p(Math.floor(a / 60))}${p(a % 60)}`;
|
||||
};
|
||||
const sprint = (over: Partial<AgileSprint>): AgileSprint => ({
|
||||
id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: iso(3),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user