MCP: спринты, спринт и бэклог проекта, колонка SP (#108)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
07357732da
commit
f709e9b893
@ -49,7 +49,7 @@
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
### Модуль `wiki` (`modules/wiki/`)
|
||||
|
||||
11
README.md
11
README.md
@ -77,6 +77,17 @@ MCP вики — отдельный сервер `sdlc-wiki` (`sdlc-hub mcp wiki
|
||||
Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`. Пока у проекта нет корня
|
||||
вики (`root_set DOC-…` или `wiki_root` в `project_create`), они отвечают «у проекта <метка> нет корня вики».
|
||||
|
||||
Спринты и бэклог (нужна привязка к проекту; читают `GET /agile` проекта сессии): `sprints` — спринты доски с суммами
|
||||
задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски;
|
||||
`backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся
|
||||
через `batch_create`, например смена состояния спринта с переносом открытых задач (хвост пакета хаб заменит на `refresh` по JQL проекта):
|
||||
|
||||
```json
|
||||
{"title": "Закрыть спринт 2", "commands": [
|
||||
{"type": "ensure_sprint_meta", "params": {"sprint": "S2", "goal": "Цель", "start_date": "2026-10-14", "end_date": "2026-10-28"}},
|
||||
{"type": "ensure_sprint_state", "params": {"sprint": "S2", "state": "closed", "move_open_to": "S3"}}]}
|
||||
```
|
||||
|
||||
Сессия MCP работает в разрезе одного проекта-метки. Привязка — `--project <метка>` в `.mcp.json` репозитория проекта
|
||||
(метка сравнивается точно, с учётом регистра):
|
||||
|
||||
|
||||
@ -26,10 +26,126 @@ fmt_agent = core.fmt_agent
|
||||
def fmt_issues(issues) -> str:
|
||||
if not issues:
|
||||
return "задач нет"
|
||||
lines = ["ключ | тема | статус | срок | исполнитель | спринт"]
|
||||
lines = ["ключ | тема | статус | срок | исполнитель | спринт | SP"]
|
||||
for i in issues:
|
||||
lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")),
|
||||
core.value(i.get("assignee")), core.value(i.get("sprint"))]))
|
||||
core.value(i.get("assignee")), core.value(i.get("sprint")), fmt_sp(i.get("story_points"))]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fmt_sp(v) -> str:
|
||||
"""Оценка в story points: пусто — «—», целое без «.0»."""
|
||||
return "—" if v is None else f"{v:g}"
|
||||
|
||||
|
||||
def fmt_day(value) -> str:
|
||||
"""Время Jira (±HHMM, ±HH:MM, Z) → «ДД.ММ» в местном поясе; пусто или неразбираемо — «—»."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return "—"
|
||||
text = value.strip()
|
||||
if text.endswith(("Z", "z")):
|
||||
text = text[:-1] + "+00:00"
|
||||
if len(text) > 5 and text[-5] in "+-" and text[-4:].isdigit():
|
||||
text = text[:-2] + ":" + text[-2:]
|
||||
try:
|
||||
at = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return "—"
|
||||
if at.tzinfo is not None:
|
||||
at = at.astimezone()
|
||||
return at.strftime("%d.%m")
|
||||
|
||||
|
||||
NO_BOARD = "у проекта нет доски"
|
||||
NO_DATA = "данных доски нет — нужен агент v15 и обновление из Jira"
|
||||
KANBAN = "у проекта канбан-доска, спринтов нет"
|
||||
SPRINT_STATES = {"active": "активный", "future": "будущий", "closed": "закрыт"}
|
||||
CLOSED_SHOWN = 10
|
||||
|
||||
|
||||
def _agile_refusal(data: dict, sprints: bool) -> str | None:
|
||||
board = data.get("board")
|
||||
if board is None:
|
||||
return NO_BOARD
|
||||
if board.get("fetched_at") is None:
|
||||
return NO_DATA
|
||||
if sprints and board.get("type") == "kanban":
|
||||
return KANBAN
|
||||
return None
|
||||
|
||||
|
||||
def _sprint_dates(s: dict) -> str:
|
||||
return f"{fmt_day(s.get('start_date'))}–{fmt_day(s.get('end_date'))}"
|
||||
|
||||
|
||||
def fmt_sprints(data: dict) -> str:
|
||||
refusal = _agile_refusal(data, True)
|
||||
if refusal:
|
||||
return refusal
|
||||
items = data.get("sprints") or []
|
||||
if not items:
|
||||
return "спринтов нет"
|
||||
lines = ["спринт | состояние | даты | задачи | SP | без оценки"]
|
||||
closed = 0
|
||||
for s in items:
|
||||
if s["state"] == "closed":
|
||||
closed += 1
|
||||
if closed > CLOSED_SHOWN:
|
||||
continue
|
||||
lines.append(f"{s['name']} | {SPRINT_STATES.get(s['state'], s['state'])} | {_sprint_dates(s)} | {s['count_done']}/{s['count']} | "
|
||||
f"{fmt_sp(s['points_done'])}/{fmt_sp(s['points'])} | {s['unestimated']}")
|
||||
if closed > CLOSED_SHOWN:
|
||||
lines.append(f"ещё {closed - CLOSED_SHOWN} закрытых")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fmt_sprint(data: dict, name: str) -> str:
|
||||
refusal = _agile_refusal(data, True)
|
||||
if refusal:
|
||||
return refusal
|
||||
items = data.get("sprints") or []
|
||||
if name:
|
||||
s = next((x for x in items if x["name"] == name), None)
|
||||
if s is None:
|
||||
return f"спринта «{name}» нет на доске"
|
||||
else:
|
||||
s = next((x for x in items if x["state"] == "active"), None)
|
||||
if s is None:
|
||||
return "активного спринта нет"
|
||||
lines = [f"{s['name']} — {SPRINT_STATES.get(s['state'], s['state'])}; {_sprint_dates(s)}; цель: {core.value(s.get('goal'))}",
|
||||
f"задачи {s['count_done']}/{s['count']}; SP {fmt_sp(s['points_done'])}/{fmt_sp(s['points'])}; без оценки: {s['unestimated']}"]
|
||||
|
||||
def row(i):
|
||||
return f" {i['key']} {core.cut(i.get('summary'), 80)} — {core.value(i.get('assignee'))}, SP {fmt_sp(i.get('story_points'))}"
|
||||
rest = list(s.get("issues") or [])
|
||||
for col in data["board"].get("columns") or []:
|
||||
names = set(col.get("statuses") or [])
|
||||
mine = [i for i in rest if i.get("status") in names]
|
||||
rest = [i for i in rest if i.get("status") not in names]
|
||||
lines.append(f"{col['name']} ({len(mine)}):")
|
||||
lines.extend(row(i) for i in mine)
|
||||
if rest:
|
||||
lines.append(f"Без колонки ({len(rest)}):")
|
||||
lines.extend(row(i) for i in rest)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fmt_backlog(data: dict, limit: int) -> str:
|
||||
refusal = _agile_refusal(data, False)
|
||||
if refusal:
|
||||
return refusal
|
||||
rows = data.get("backlog")
|
||||
if rows is None:
|
||||
return "у доски нет бэклога"
|
||||
if not rows:
|
||||
return "бэклог пуст"
|
||||
shown = rows[:max(1, limit)]
|
||||
lines = ["# | ключ | тема | статус | исполнитель | SP"]
|
||||
for n, i in enumerate(shown, 1):
|
||||
lines.append(" | ".join([str(n), i["key"], core.cut(i.get("summary")), core.value(i.get("status")),
|
||||
core.value(i.get("assignee")), fmt_sp(i.get("story_points"))]))
|
||||
if len(rows) > len(shown):
|
||||
lines.append(f"ещё {len(rows) - len(shown)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@ -381,7 +497,7 @@ def _doc(text: str):
|
||||
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
|
||||
Параметры по типам (необязательные — в [ ]):
|
||||
""" + describe() + """
|
||||
Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.
|
||||
Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.
|
||||
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
|
||||
файл в хаб и подставит file, filename по умолчанию — имя файла.""")
|
||||
def batch_create(title: str, commands: list[dict]) -> str:
|
||||
@ -393,6 +509,30 @@ def batch_create(title: str, commands: list[dict]) -> str:
|
||||
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
def sprints() -> str:
|
||||
"""Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних"""
|
||||
return fmt_sprints(core.client().get("/agile", project=core.PROJECT))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
def sprint(name: str = "") -> str:
|
||||
"""Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски"""
|
||||
return fmt_sprint(core.client().get("/agile", project=core.PROJECT), name)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(True)
|
||||
def backlog(limit: int = 50) -> str:
|
||||
"""Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)"""
|
||||
return fmt_backlog(core.client().get("/agile", project=core.PROJECT), limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@core.guard
|
||||
@core.scoped(False)
|
||||
|
||||
41
tests/fixtures/baseline/mcp_tools.expected.json
vendored
41
tests/fixtures/baseline/mcp_tools.expected.json
vendored
@ -228,7 +228,7 @@
|
||||
"name": "notes_set"
|
||||
},
|
||||
{
|
||||
"description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:<n>), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:<n>), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:<n>), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.",
|
||||
"description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:<n>), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:<n>), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:<n>), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"commands": {
|
||||
@ -253,6 +253,45 @@
|
||||
},
|
||||
"name": "batch_create"
|
||||
},
|
||||
{
|
||||
"description": "Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних",
|
||||
"inputSchema": {
|
||||
"properties": {},
|
||||
"title": "sprintsArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "sprints"
|
||||
},
|
||||
{
|
||||
"description": "Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"default": "",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "sprintArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "sprint"
|
||||
},
|
||||
{
|
||||
"description": "Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"default": 50,
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"title": "backlogArguments",
|
||||
"type": "object"
|
||||
},
|
||||
"name": "backlog"
|
||||
},
|
||||
{
|
||||
"description": "Состояние пакета и его команд.",
|
||||
"inputSchema": {
|
||||
|
||||
@ -85,8 +85,8 @@ def test_formatters():
|
||||
"sprint": None, "fact_status": "В работе"}])
|
||||
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
|
||||
head, row = text.splitlines()
|
||||
assert head == "ключ | тема | статус | срок | исполнитель | спринт" # статуса по факту нет (fact-status-removal)
|
||||
assert len(row.split(" | ")) == 6 and "В работе" not in row
|
||||
assert head == "ключ | тема | статус | срок | исполнитель | спринт | SP" # статуса по факту нет (fact-status-removal)
|
||||
assert len(row.split(" | ")) == 7 and "В работе" not in row
|
||||
assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
|
||||
"rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None,
|
||||
"settings_version": None})
|
||||
@ -221,7 +221,7 @@ def test_fmt_batch_warns_about_claimed():
|
||||
def test_batch_create_mentions_labels():
|
||||
doc = jmcp.batch_create.__doc__ or ""
|
||||
assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc
|
||||
assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc
|
||||
assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб" in doc
|
||||
assert "AWPCA" not in doc
|
||||
|
||||
|
||||
@ -859,3 +859,95 @@ def test_attachment_existing_copy_same_size(downloads, monkeypatch):
|
||||
asyncio.run(jmcp.attachment(ATT_KEY, "31337"))
|
||||
assert path.read_bytes() == b"OLD" * 3
|
||||
assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет
|
||||
|
||||
|
||||
# --- спринты, спринт, бэклог, колонка SP (#108, Task 7) ---------------------------------------------------------
|
||||
|
||||
def _sp(id, name, state, **over):
|
||||
return {"id": id, "name": name, "state": state, "start_date": "2026-10-14T09:00:00.000+0300", "end_date": "2026-10-28T18:00:00.000+0300",
|
||||
"complete_date": None, "goal": None, "count": 0, "count_done": 0, "unestimated": 0, "points": 0, "points_done": 0, "issues": [], **over}
|
||||
|
||||
|
||||
BOARD = {"id": 4216, "type": "scrum", "columns": [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}],
|
||||
"kanban_backlog": None, "fetched_at": "2026-10-24T10:00:00+00:00"}
|
||||
|
||||
|
||||
def test_fmt_issues_sp_column():
|
||||
out = jmcp.fmt_issues([{"key": "A-1", "story_points": 3.0}, {"key": "A-2", "story_points": 2.5}, {"key": "A-3", "story_points": None}])
|
||||
assert out.splitlines()[0].endswith("| SP") and [l.rsplit("| ", 1)[1] for l in out.splitlines()[1:]] == ["3", "2.5", "—"]
|
||||
|
||||
|
||||
def test_fmt_sprints_order_and_sums(): # сценарий «Спринты проекта»
|
||||
data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints":
|
||||
[_sp(3, "AWPCA_Sprint_3_Corrections", "active", count=37, count_done=25, points=40, points_done=25), _sp(4, "AWPCA_Sprint_4", "future")]
|
||||
+ [_sp(100 - n, f"S{n}", "closed") for n in range(12)]}
|
||||
lines = jmcp.fmt_sprints(data).splitlines()
|
||||
assert lines[1].startswith("AWPCA_Sprint_3_Corrections | активный | ") and "| 25/37 | 25/40 |" in lines[1]
|
||||
assert lines[2].startswith("AWPCA_Sprint_4 | будущий")
|
||||
assert len([l for l in lines if "| закрыт |" in l]) == 10 and lines[-1] == "ещё 2 закрытых"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data, text", [
|
||||
({"board": None, "sprints": [], "backlog": None, "board_issues": None}, "у проекта нет доски"),
|
||||
({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "у проекта канбан-доска, спринтов нет"),
|
||||
({"board": {**BOARD, "fetched_at": None, "columns": None}, "sprints": [], "backlog": [], "board_issues": None}, "данных доски нет — нужен агент v15 и обновление из Jira"),
|
||||
])
|
||||
def test_fmt_sprints_refusals(data, text):
|
||||
assert jmcp.fmt_sprints(data) == text
|
||||
|
||||
|
||||
def test_fmt_sprint_by_columns():
|
||||
def it(key, status, summary="т", sp=None, assignee=None):
|
||||
return {"key": key, "summary": summary, "status": status, "assignee": assignee, "story_points": sp}
|
||||
issues = [it("A-1", "Backlog", "д" * 100, 2, "Иванов"), it("A-2", "Готово", sp=2.5), it("A-3", "Странный")]
|
||||
data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints": [
|
||||
_sp(5, "S5", "active", goal="Цель", count=3, count_done=1, points=4.5, points_done=2.5, unestimated=1, issues=issues),
|
||||
_sp(4, "S4", "future")]}
|
||||
out = jmcp.fmt_sprint(data, "").splitlines()
|
||||
assert out[0].startswith("S5 — активный; ") and out[0].endswith("; цель: Цель")
|
||||
assert out[1] == "задачи 1/3; SP 2.5/4.5; без оценки: 1"
|
||||
assert out[2:] == ["К выполнению (1):", out[3], "Готово (1):",
|
||||
" A-2 т — —, SP 2.5", "Без колонки (1):", " A-3 т — —, SP —"]
|
||||
assert out[3].startswith(" A-1 ") and out[3].endswith(" — Иванов, SP 2") and len(out[3].split(" — ")[0]) <= len(" A-1 ") + 80
|
||||
assert jmcp.fmt_sprint(data, "S9") == "спринта «S9» нет на доске"
|
||||
assert jmcp.fmt_sprint(data, "S4").splitlines()[0].startswith("S4 — будущий; ")
|
||||
data["sprints"] = [_sp(4, "S4", "future")]
|
||||
assert jmcp.fmt_sprint(data, "") == "активного спринта нет"
|
||||
assert jmcp.fmt_sprint({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "") \
|
||||
== "у проекта канбан-доска, спринтов нет"
|
||||
|
||||
|
||||
def test_fmt_backlog_limit(): # сценарий «Бэклог с лимитом»
|
||||
rows = [{"key": f"A-{n}", "summary": "т", "status": "Backlog", "assignee": None, "story_points": None} for n in range(188)]
|
||||
out = jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": rows, "board_issues": None}, 2).splitlines()
|
||||
assert out[1].startswith("1 | A-0 |") and out[2].startswith("2 | A-1 |") and out[-1] == "ещё 186" and len(out) == 4
|
||||
assert jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": [], "board_issues": None}, 5) == "бэклог пуст"
|
||||
assert jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": None, "board_issues": None}, 5) == "у доски нет бэклога"
|
||||
|
||||
|
||||
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") != "—"
|
||||
assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—"
|
||||
|
||||
|
||||
def test_fmt_sp():
|
||||
assert [jmcp.fmt_sp(v) for v in (None, 3.0, 2.5, 0)] == ["—", "3", "2.5", "0"]
|
||||
|
||||
|
||||
def test_agile_tools_live(live):
|
||||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Backlog", "labels": ["AWPCA"],
|
||||
"rank": "0|a", "story_points": 2, "sprint_ids": [102]}], "links": [],
|
||||
"boards": [{"id": 4216, "type": "scrum", "columns": BOARD["columns"], "kanban_backlog": None, "sprints": [_sp(102, "S2", "active")]}]})
|
||||
assert "S2 | активный" in jmcp.sprints() and "A-1 Тема" in jmcp.sprint() and "бэклог пуст" == jmcp.backlog()
|
||||
|
||||
|
||||
def test_agile_tools_unbound(live, monkeypatch):
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", None)
|
||||
assert jmcp.sprints() == jmcp.sprint() == jmcp.backlog() == jmcp.issues() # тот же отказ, что у прочих
|
||||
|
||||
|
||||
def test_batch_create_description_lists_sprint_types():
|
||||
doc = jmcp.batch_create.__doc__
|
||||
for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"):
|
||||
assert word in doc
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user