2026-10-26 02:20:17 +04:00

662 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MCP-инструменты модуля jira (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md).
Общие охрана, привязка к проекту и инструменты projects/project_create — в core/mcp.py; состояние (клиент хаба,
привязка сессии) читается через core в момент вызова."""
import asyncio
import base64
import json
import stat
import time
import unicodedata
from datetime import datetime, timedelta
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from ...core import mcp as core
from ...core.client import HubError
from ...core.files import MAX_SIZE
from .attachments import fmt_size
from .commands import describe, is_temp
POLL_SECONDS = 5
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
mcp = FastMCP("sdlc-jira")
fmt_agent = core.fmt_agent
def fmt_issues(issues) -> str:
if not issues:
return "задач нет"
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")), 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)
def fmt_attachment_line(a: dict, d: dict) -> str:
"""Строка вложения в issue: «#id имя (размер) — автор, создано» и через запятую, где оно упомянуто и скачано."""
aid = str(a["attachment_id"])
line = f" #{aid} {a['filename']} ({fmt_size(a.get('size'))}) — {core.value(a.get('author'))}, {core.value(a.get('created'))}"
marks = []
if aid in (d.get("description_attachment_ids") or []):
marks.append("в описании")
marks += [f"в #{c.get('comment_id')}" for c in d.get("comments") or [] if aid in (c.get("attachment_ids") or [])]
if a.get("state") == "ready":
marks.append("скачано")
if a.get("size") is not None and a["size"] > MAX_SIZE:
marks.append("больше 10 МБ — только в Jira")
return line + "".join(", " + m for m in marks)
def fmt_attachments(d: dict, atts: list) -> list[str]:
if not atts:
return ["вложений нет"]
return [f"вложения ({len(atts)}):"] + [fmt_attachment_line(a, d) for a in atts]
def _files_line(c: dict, atts: list) -> str:
"""«файлы: #id имя, …» для комментария; пусто, если ссылок на вложения нет."""
by_id = {str(a["attachment_id"]): a for a in atts}
parts = [f"#{i} {by_id[i]['filename']}" for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id]
return "файлы: " + ", ".join(parts) if parts else ""
safe_name = core.safe_name
def fmt_issue(d) -> str:
head = [f"{d['key']} — {d.get('summary')}",
f"тип: {core.value(d.get('issue_type'))}; статус: {core.value(d.get('status'))}; приоритет: {core.value(d.get('priority'))}; "
f"исполнитель: {core.value(d.get('assignee'))}; срок: {core.value(d.get('duedate'))}; план: {core.value(d.get('planned_start'))}…{core.value(d.get('planned_end'))}",
f"эпик: {core.value(d.get('epic_key'))}; спринт: {core.value(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; "
f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {core.value(d.get('jira_updated'))}; прочитано: {core.value(d.get('fetched_at'))}"]
links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \
[f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])]
note = d.get("note") or {}
notes = [f" {k}: {note[k]}" for k in ("work_kind", "done_text", "rest_text", "owner_note") if note.get(k)]
cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])]
comments = d.get("comments") or []
if comments:
shown = []
for c in comments[:5]:
shown.append(f" #{c.get('comment_id')} {core.value(c.get('author'))} {core.value(c.get('created'))}: {core.cut(c.get('body'), 300)}")
files_line = _files_line(c, d.get("attachments") or [])
if files_line:
shown.append(" " + files_line)
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
else:
cmts = ["комментариев нет"]
atts = d.get("attachments") or []
cmts += fmt_attachments(d, atts)
changes = d.get("changes") or []
if changes:
cmts += [f"изменения ({len(changes)}):"] + [" " + fmt_event(e, None) for e in changes[:5]] + \
([f" ещё {len(changes) - 5}"] if len(changes) > 5 else [])
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
+ ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"])
DATE_LABELS = {"duedate": "срок", "planned_start": "плановое начало", "planned_end": "плановое окончание"}
def _day(value, text) -> str:
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit():
return f"{value[8:10]}.{value[5:7]}"
return core.value(text)
def fmt_comment(key, c, atts=None) -> str:
"""Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили.
atts — вложения задачи: ссылки комментария на них выводятся блоком «вложения:»."""
head = f"{key} #{c.get('comment_id')} {core.value(c.get('author'))}, создан {core.value(c.get('created'))}"
if c.get("updated") and c.get("updated") != c.get("created"):
head += f", изменён {c['updated']}"
out = head + "\n" + core.value(c.get("body"))
by_id = {str(a["attachment_id"]): a for a in atts or []}
refs = [by_id[i] for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id]
if refs:
out += "\nвложения:" + "".join(f"\n #{a['attachment_id']} {a['filename']} ({fmt_size(a.get('size'))})" for a in refs)
return out
def fmt_event(e: dict, today) -> str:
"""Строка события ленты: «ЧЧ:ММ автор: что»; дата «ДД.ММ» перед временем, если событие не в сутках today
(today = None — дата всегда)."""
if e.get("at"):
local = datetime.fromisoformat(e["at"]).astimezone()
when = local.strftime("%H:%M") if today is not None and local.date() == today else local.strftime("%d.%m %H:%M")
else:
when = "—"
kind, frm, to = e.get("kind"), e.get("from"), e.get("to")
if kind == "comment":
what = "комментарий: " + core.cut(to, 200)
elif kind == "status":
what = f"статус «{core.value(frm)}» → «{core.value(to)}»"
elif kind == "description":
what = "описание изменено"
elif kind == "dates":
what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → "
f"{_day(e.get('to_value'), to)}")
else:
what = f"{e.get('field')}: «{core.cut(frm) if frm else '—'}» → «{core.cut(to) if to else '—'}»"
return f"{when} {core.value(e.get('author'))}: {what}"
def fmt_changes(data: dict, now: datetime) -> str:
"""Лента по задачам: задачи — по последнему событию (лента уже новые сверху), под задачей — её события."""
def span(at):
return datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M")
lines = [f"изменения с {span(data['since'])} по {span(data['until'])}"]
events = data.get("changes") or []
if not events:
return "\n".join(lines + ["за период изменений нет"])
groups: dict[str, list] = {}
for e in events:
groups.setdefault(e["key"], []).append(e)
for key, items in groups.items():
lines.append(f"{key} {core.cut(items[0].get('summary')) or '—'} — {core.value(items[0].get('status'))}")
lines += [" " + fmt_event(e, now.date()) for e in items]
if data.get("truncated"):
lines.append(f"показаны последние {len(events)} событий, сузьте период")
return "\n".join(lines)
def fmt_batch(b) -> str:
est = round((b.get("remaining_seconds") or 0) / 60)
lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; "
+ ", ".join(f"{k}: {v}" for k, v in (b.get("counts") or {}).items())]
for c in b.get("commands", []):
lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}"
+ (f" ({c['error']})" if c.get("error") else "") + (f" {json.dumps(c['result'], ensure_ascii=False)}" if c.get("result") else ""))
for s in b.get("still_claimed") or []:
lines.append(f" команда #{s['id']} уже у агента и будет выполнена; остановить — "
f"POST /commands/{s['id']}/cancel с force: true")
return "\n".join(lines)
# --- инструменты -----------------------------------------------------------------
@mcp.tool()
@core.guard
@core.scoped(True)
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", status: str = "",
label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус Jira, метка, эпик,
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица."""
data = core.client().get("/issues", project=core.PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind,
status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
return fmt_issues(data["issues"])
@mcp.tool()
@core.guard
@core.scoped(True)
def issue(key: str) -> str:
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5
последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком
(номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment."""
return fmt_issue(core.client().get(f"/issues/{key}"))
@mcp.tool()
@core.guard
@core.scoped(True)
def comment(key: str, comment_id: str) -> str:
"""Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»)."""
cid = comment_id.lstrip("#")
d = core.client().get(f"/issues/{key}")
for c in d.get("comments") or []:
if c.get("comment_id") == cid:
return fmt_comment(key, c, d.get("attachments"))
return f"у {key} нет комментария #{cid}"
_store_copy = core.store_copy
@mcp.tool()
@core.guard
@core.scoped(True)
async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
"""Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из
issue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое.
Хаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600,
по умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ
скачиваются только в Jira."""
client = core.client()
wait = min(max(int(wait_seconds), 1), 600)
d = await asyncio.to_thread(client.get, f"/issues/{key}")
atts = d.get("attachments") or []
ref = attachment.strip().lstrip("#")
found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else []
prefix = ""
if not found:
want = unicodedata.normalize("NFC", attachment.strip())
found = [a for a in atts if unicodedata.normalize("NFC", a["filename"]) == want] # хаб отдаёт вложения новыми сверху
if len(found) > 1:
prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n"
if not found:
return f"у {key} нет вложения «{attachment}»; список — issue {key}"
att = found[0]
aid = str(att["attachment_id"])
try:
st = await asyncio.to_thread(client.post, f"/attachments/{aid}/fetch", {"author": core.AUTHOR})
except HubError as e:
if e.status == 413:
return f"файл больше 10 МБ ({fmt_size(att.get('size'))}) — скачайте в Jira: задача {key}, вложение «{att['filename']}»"
raise
deadline = time.monotonic() + wait
while st.get("state") == "fetching" and time.monotonic() < deadline:
await asyncio.sleep(POLL_SECONDS / 2)
st = await asyncio.to_thread(client.get, f"/attachments/{aid}")
if st.get("state") == "fetching":
online = (await asyncio.to_thread(client.get, "/agent")).get("online")
return (f"скачивание ещё идёт (команда #{st.get('command_id')}, агент {'онлайн' if online else 'офлайн'}); "
"вызовите attachment ещё раз — вторая команда не поставится")
if st.get("state") != "ready":
return f"не удалось скачать: {core.value(st.get('error'))}"
data = await asyncio.to_thread(client.get_bytes, f"/attachments/{aid}/content")
path = await asyncio.to_thread(_store_copy, aid, safe_name(att["filename"], aid), data)
return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {core.value(att.get('mime_type'))}, задача {key}")
def _local_if_naive(value: str) -> str:
"""Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть."""
if not value:
return value
try:
d = datetime.fromisoformat(value)
except ValueError:
return value
return d.astimezone().isoformat() if d.tzinfo is None else value
@mcp.tool()
@core.guard
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str:
"""Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала
сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из
status, description, dates, comment, other или all; key — одна задача (без привязки к проекту)."""
if hours is not None and since:
return "либо hours, либо since"
if hours is not None and hours <= 0:
return "hours — число больше 0"
refusal = core.unbound_or_unknown(required=not key) # с key — любая задача, как issue
if refusal is not None:
return refusal
now = datetime.now().astimezone()
if hours is not None:
since = (now - timedelta(hours=hours)).isoformat()
elif not since:
since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
data = core.client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key,
project=None if key else core.PROJECT)
return fmt_changes(data, now)
@mcp.tool()
@core.guard
@core.scoped(True)
def notes_set(key: str, work_kind: str | None = None, done_text: str | None = None,
rest_text: str | None = None, owner_note: str | None = None) -> str:
"""Правка служебных заметок задачи (только переданные поля)."""
fields = {k: v for k, v in dict(work_kind=work_kind, done_text=done_text,
rest_text=rest_text, owner_note=owner_note).items() if v is not None}
fields["updated_by"] = core.AUTHOR
if not is_temp(key): # новая задача пакета проекта — своя по построению
if core.PROJECT not in (core.client().get(f"/issues/{key}").get("labels") or []):
return f"{key} не в проекте {core.PROJECT}"
note = core.client().put(f"/issues/{key}/note", fields)
return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("work_kind", "done_text", "rest_text", "owner_note") if note.get(k))
def _read_local(path) -> bytes | str:
"""Содержимое локального файла для ensure_attachment.path или текст причины с путём (spec mcp, решение 19)."""
if not isinstance(path, str) or not path:
return "path — строка с абсолютным путём к файлу"
p = Path(path).expanduser()
if not p.is_absolute():
return f"нужен абсолютный путь: {path}"
try:
st = p.stat()
if not stat.S_ISREG(st.st_mode):
return f"не файл: {path}"
if st.st_size > MAX_SIZE:
return f"файл больше 10 МБ ({st.st_size} байт): {path}"
data = p.read_bytes()
except FileNotFoundError:
return f"файл не найден: {path}"
except PermissionError:
return f"нет прав на чтение: {path}"
except OSError as e:
return f"файл не прочитан ({e.strerror or e}): {path}"
if not data:
return f"пустой файл: {path}"
if len(data) > MAX_SIZE: # вырос между stat и чтением
return f"файл больше 10 МБ ({len(data)} байт): {path}"
return data
def _attach_files(client, commands):
"""ensure_attachment с path: сначала прочитать все файлы пакета, затем загрузить их POST /files и подставить file и
filename (по умолчанию — имя файла из path). Ошибка любого файла — текст, в хаб ничего не загружено. Остальные
команды и форму пакета проверяет хаб."""
if not isinstance(commands, list):
return commands
read = {}
for i, cmd in enumerate(commands):
params = cmd.get("params") if isinstance(cmd, dict) and cmd.get("type") == "ensure_attachment" else None
if not isinstance(params, dict) or "path" not in params:
continue
if params.get("file") not in (None, ""):
return BOTH_FILE_AND_PATH
data = _read_local(params["path"])
if isinstance(data, str):
return data
read[i] = data
out = []
for i, cmd in enumerate(commands):
if i not in read:
out.append(cmd)
continue
params = {k: v for k, v in cmd["params"].items() if k != "path"}
up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")})
params["file"] = up["file"]
params["filename"] = params.get("filename") or unicodedata.normalize(
"NFC", Path(cmd["params"]["path"]).expanduser().name)
out.append({**cmd, "params": params})
return out
def _doc(text: str):
"""Описание инструмента, собранное в коде: FastMCP берёт его из __doc__ при регистрации."""
def deco(fn):
fn.__doc__ = text
return fn
return deco
@mcp.tool()
@core.guard
@core.scoped(True)
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
Параметры по типам (необязательные — в [ ]):
""" + describe() + """
Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta, ensure_sprint_state и ensure_fields со story_points выставляет хаб; 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:
client = core.client()
prepared = _attach_files(client, commands)
if isinstance(prepared, str):
return prepared
b = client.post("/batches", {"title": title, "author": core.AUTHOR, "project": core.PROJECT, "commands": prepared})
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)
def batch_status(id: int) -> str:
"""Состояние пакета и его команд."""
return fmt_batch(core.client().get(f"/batches/{id}"))
@mcp.tool()
@core.guard
@core.scoped(False)
async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
"""Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера."""
deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60
client = core.client()
while True:
b = await asyncio.to_thread(client.get, f"/batches/{id}")
if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline:
return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)")
await asyncio.sleep(POLL_SECONDS)
@mcp.tool()
@core.guard
@core.scoped(False)
def batches(limit: int = 10) -> str:
"""Последние пакеты со статусом."""
items = core.client().get("/batches", project=core.PROJECT, limit=limit)["batches"]
return "\n".join(fmt_batch(b) for b in items) or "пакетов нет"
@mcp.tool()
@core.guard
@core.scoped(True)
def command_retry(id: int) -> str:
"""Повторить команду — новый пакет с теми же параметрами."""
b = core.client().post(f"/commands/{id}/retry", {"author": core.AUTHOR, "project": core.PROJECT})
return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд"
@mcp.tool()
@core.guard
@core.scoped(False)
def batch_cancel(id: int) -> str:
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
в ответе подсказка (POST /commands/N/cancel с force)."""
return fmt_batch(core.client().post(f"/batches/{id}/cancel", {}))
@mcp.tool()
@core.guard
@core.scoped(True)
async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str:
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;
wait=true — дождаться результата (до 30 мин)."""
body = {"full": True} if full or not keys else {"keys": keys}
body["author"] = core.AUTHOR
b = await asyncio.to_thread(core.client().post, "/refresh", body)
return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен"
@mcp.tool()
@core.guard
@core.scoped(False)
def agent_status() -> str:
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
состояние самообновления и запускатель."""
return core.fmt_agent(core.client().get("/agent"))
@mcp.tool()
@core.guard
@core.scoped(False)
def sync_log(limit: int = 10) -> str:
"""Журнал обновлений зеркала."""
runs = core.client().get("/sync-runs", limit=limit)["runs"]
def journal(r):
at = r.get("history_at")
return "журнал: принят " + datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") if at else "журнал: нет"
return "\n".join(f"#{r['id']} {r['kind']} {core.cut(r.get('query'), 60)}: {r['started_at']} → {core.value(r.get('finished_at'))}, "
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}, "
f"{journal(r)}" for r in runs) or "обновлений не было"
@mcp.tool()
@core.guard
@core.scoped(False)
def settings_get(key: str = "") -> str:
"""Настройки хаба (все или одна по ключу)."""
data = core.client().get("/settings")
return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1)
@mcp.tool()
@core.guard
@core.scoped(False)
def settings_set(key: str, value: str) -> str:
"""Записать настройку: value — JSON. Поднимает settings_version."""
try:
parsed = json.loads(value)
except ValueError as e:
return f"значение не JSON: {e}"
out = core.client().put("/settings", {key: parsed})
return f"settings_version = {out['settings_version']}"
@core.guard
def project_create(label: str, name: str, board_id: int | None = None) -> str:
"""Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент
.mcp.json для репозитория проекта."""
return core.create_project(label, name, {} if board_id is None else {"board_id": board_id}, "jira")
PROJECT_COLUMNS = [("доска", lambda p: core.value(p.get("board_id"))),
("тип доски", lambda p: p.get("board_type") or "неизвестен"),
("задач", lambda p: str(p.get("issues_count", 0)))]
core.register_core_tools(
mcp,
projects_doc="Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен.",
columns=PROJECT_COLUMNS,
project_create=project_create)