662 lines
34 KiB
Python
662 lines
34 KiB
Python
"""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)
|