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

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

View File

@ -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/`)

View File

@ -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` репозитория проекта
(метка сравнивается точно, с учётом регистра):

View File

@ -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)

View File

@ -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": {

View File

@ -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