diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 79a5d6a..3f10125 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,7 +21,7 @@ | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | -| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`), лента `changes` (`GET /changes`, по умолчанию — с начала суток); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) | +| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`), лента `changes` (`GET /changes`, по умолчанию — с начала суток); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета; `issue`/`comment` показывают вложения, инструмент `attachment` ставит скачивание (`POST /attachments/{id}/fetch`), опрашивает `GET /attachments/{id}` и кладёт копию в `<данные>/downloads//<имя>` (0700, через временный файл) | `client` (`get_bytes` — тело ответа как есть), `commands`, `paths`, `files` (только `MAX_SIZE`), `attachments` (только `fmt_size`) | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | @@ -63,7 +63,7 @@ - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). - `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`/`history` напрямую и ходит только через `client`; из `files` берёт только предел - `MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`). + `MAX_SIZE`, из `attachments` — только `fmt_size`; `downloads/` пишет только он (хаб эту папку лишь убирает) (файлы в хранилище кладёт хаб по `POST /files`). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. diff --git a/src/jira_hub/client.py b/src/jira_hub/client.py index 55f356f..bdd6f60 100644 --- a/src/jira_hub/client.py +++ b/src/jira_hub/client.py @@ -31,19 +31,26 @@ class HubClient: conn.close() def call(self, method: str, path: str, body=None, params=None): + raw = self._raw(method, path, body, params, "application/json") + return json.loads(raw.decode("utf-8")) if raw else {} + + def get_bytes(self, path: str) -> bytes: + """Тело ответа как есть (содержимое вложения); ошибки — как у call.""" + return self._raw("GET", path, None, None, "*/*") + + def _raw(self, method, path, body, params, accept) -> bytes: url = self.base + path if params: url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", "Bearer " + self.token) - req.add_header("Accept", "application/json") + req.add_header("Accept", accept) if data is not None: req.add_header("Content-Type", "application/json; charset=utf-8") try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: - raw = resp.read() - return json.loads(raw.decode("utf-8")) if raw else {} + return resp.read() except urllib.error.HTTPError as e: try: payload = json.loads(e.read().decode("utf-8")) diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 879d747..92c3bed 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -5,13 +5,17 @@ import base64 import functools import inspect import json +import os import stat +import tempfile import time from datetime import datetime, timedelta from pathlib import Path from mcp.server.fastmcp import FastMCP +from . import paths +from .attachments import fmt_size from .client import HubClient, HubError, HubUnavailable from .commands import describe, is_temp from .files import MAX_SIZE @@ -125,6 +129,40 @@ def fmt_issues(issues) -> str: 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'))}) — {_v(a.get('author'))}, {_v(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 "" + + +def safe_name(filename: str, attachment_id: str) -> str: + """Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; пусто, «.», «..» → attachment-.""" + name = "".join("_" if ch in "/\\:" or ord(ch) < 32 or ord(ch) == 127 else ch for ch in filename or "") + return name if name.strip() and name.strip() not in (".", "..") else f"attachment-{attachment_id}" + + def fmt_issue(d) -> str: head = [f"{d['key']} — {d.get('summary')}", f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; " @@ -138,11 +176,17 @@ def fmt_issue(d) -> str: 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 = [f" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" - for c in comments[:5]] + shown = [] + for c in comments[:5]: + shown.append(f" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_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]] + \ @@ -161,12 +205,18 @@ def _day(value, text) -> str: return _v(text) -def fmt_comment(key, c) -> str: - """Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили.""" +def fmt_comment(key, c, atts=None) -> str: + """Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили. + atts — вложения задачи: ссылки комментария на них выводятся блоком «вложения:».""" head = f"{key} #{c.get('comment_id')} {_v(c.get('author'))}, создан {_v(c.get('created'))}" if c.get("updated") and c.get("updated") != c.get("created"): head += f", изменён {c['updated']}" - return head + "\n" + _v(c.get("body")) + out = head + "\n" + _v(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: @@ -267,7 +317,8 @@ def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_statu @scoped(True) def issue(key: str) -> str: """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5 - последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру.""" + последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком + (номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment.""" return fmt_issue(_client().get(f"/issues/{key}")) @@ -277,12 +328,80 @@ def issue(key: str) -> str: def comment(key: str, comment_id: str) -> str: """Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»).""" cid = comment_id.lstrip("#") - for c in _client().get(f"/issues/{key}").get("comments") or []: + d = _client().get(f"/issues/{key}") + for c in d.get("comments") or []: if c.get("comment_id") == cid: - return fmt_comment(key, c) + return fmt_comment(key, c, d.get("attachments")) return f"у {key} нет комментария #{cid}" +def _store_copy(attachment_id: str, name: str, data: bytes) -> Path: + """Копия вложения в <каталог данных>/downloads//<имя>: каталоги 0700, запись через временный файл и + os.replace; файл того же размера уже на месте — не трогаем.""" + root = paths.db_path().parent / "downloads" + folder = root / attachment_id + for d in (root, folder): + d.mkdir(mode=0o700, parents=True, exist_ok=True) + d.chmod(0o700) + target = folder / name + if target.is_file() and target.stat().st_size == len(data): + return target + fd, tmp = tempfile.mkstemp(dir=folder, prefix=".part-") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + os.replace(tmp, target) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return target + + +@mcp.tool() +@guard +@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 = _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: + found = [a for a in atts if a["filename"] == attachment.strip()] # хаб отдаёт вложения новыми сверху + 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": 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"не удалось скачать: {_v(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'))}), {_v(att.get('mime_type'))}, задача {key}") + + def _local_if_naive(value: str) -> str: """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" if not value: diff --git a/tests/test_mcp.py b/tests/test_mcp.py index a2a4ad8..0c605b8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -647,3 +647,170 @@ def test_sync_log_history(live): live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}}) assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", mcp_server.sync_log()) + + +# --- вложения (#83) ----------------------------------------------------------------- + +ATT_KEY = "AURORA-1650" + + +def _att(aid, name, size, state="none", **over): + d = {"attachment_id": aid, "filename": name, "size": size, "mime_type": "image/png", "author": "golov", + "created": "2026-10-20T10:00:00.000+0300", "state": state, "error": None} + d.update(over) + return d + + +class FakeHub: + """Фейковый HubClient: журнал вызовов и сценарий ответов по маршрутам.""" + + def __init__(self, issue, fetch=None, polls=None, data=b"x" * 10, online=True): + self.issue, self.fetch, self.polls, self.data, self.online = issue, fetch, polls or [], data, online + self.calls = [] + + def get(self, path, **params): + self.calls.append(("GET", path)) + if path == f"/issues/{ATT_KEY}": + return self.issue + if path == "/agent": + return {"online": self.online} + if path.startswith("/attachments/"): + return self.polls.pop(0) if len(self.polls) > 1 else self.polls[0] + raise AssertionError(path) + + def post(self, path, body=None): + self.calls.append(("POST", path, body)) + if isinstance(self.fetch, Exception): + raise self.fetch + return self.fetch + + def get_bytes(self, path): + self.calls.append(("BYTES", path)) + return self.data + + +@pytest.fixture +def downloads(tmp_path, monkeypatch): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") + monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) + return tmp_path / "home" / "downloads" + + +def _use(monkeypatch, hub): + monkeypatch.setattr(mcp_server, "_client", lambda: hub) + monkeypatch.setattr(mcp_server, "_unbound_or_unknown", lambda required: None) + + +def _issue_with_att(): + return _detail(key=ATT_KEY, description="см. !скрин 1.png!", description_attachment_ids=["31337"], + comments=[{"comment_id": "101", "author": "golov", "created": "2026-10-20T10:00:00.000+0300", + "updated": None, "body": "!скрин 1.png!", "attachment_ids": ["31337"]}], + attachments=[_att("31338", "запись.mp4", 26214400, mime_type="video/mp4"), + _att("31337", "скрин 1.png", 239821, state="ready")]) + + +def test_issue_attachments_block(): + text = mcp_server.fmt_issue(_issue_with_att()) + assert "вложения (2):" in text and text.index("комментарии") < text.index("вложения (2):") + lines = {l.split()[0]: l for l in text.splitlines() if l.strip().startswith("#3133")} + assert "больше 10 МБ — только в Jira" in lines["#31338"] and "25,0 МБ" in lines["#31338"] + assert "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"] + assert "файлы: #31337 скрин 1.png" in text + assert "вложений нет" in mcp_server.fmt_issue(_detail(attachments=[])) + assert "вложений нет" in mcp_server.fmt_issue(_detail()) + + +def test_comment_attachments(): + d = _issue_with_att() + text = mcp_server.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"]) + assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)") + assert "вложения" not in mcp_server.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"]) + + +def test_safe_name(): + assert mcp_server.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png" + assert mcp_server.safe_name(" ", "7") == "attachment-7" + assert mcp_server.safe_name("..", "7") == "attachment-7" + assert mcp_server.safe_name("a\x7f", "7") == "a_" + + +def _ready_hub(size=239821, data=b"PNG" * 3): + att = _att("31337", "скрин 1.png", size, state="ready") + return FakeHub(_detail(key=ATT_KEY, attachments=[att]), + fetch={"state": "ready", "command_id": 5}, polls=[att], data=data) + + +def test_attachment_by_id_ready(downloads, monkeypatch): + hub = _ready_hub() + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "#31337")) + path = downloads / "31337" / "скрин 1.png" + lines = out.splitlines() + assert lines[0] == str(path) and lines[1] == "скрин 1.png (234,2 КБ), image/png, задача AURORA-1650" + assert path.read_bytes() == b"PNG" * 3 + assert (downloads.stat().st_mode & 0o777) == 0o700 and ((downloads / "31337").stat().st_mode & 0o777) == 0o700 + assert ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls + + +def test_attachment_by_name_ambiguous(downloads, monkeypatch): + a20, a10 = _att("20", "скрин.png", 3), _att("10", "скрин.png", 3) # новые сверху, как отдаёт хаб + hub = FakeHub(_detail(key=ATT_KEY, attachments=[a20, a10]), fetch={"state": "ready", "command_id": 1}, + polls=[{**a20, "state": "ready"}], data=b"abc") + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "скрин.png")) + assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out + assert str(downloads / "20" / "скрин.png") in out + + +def test_attachment_not_found(downloads, monkeypatch): + hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)])) + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "нет.png")) + assert out == "у AURORA-1650 нет вложения «нет.png»; список — issue AURORA-1650" + assert all(c[1] == f"/issues/{ATT_KEY}" for c in hub.calls) + + +def test_attachment_wait_timeout(downloads, monkeypatch): + att = _att("1", "a.png", 5) + fetching = {**att, "state": "fetching", "command_id": 9} + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching]) + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "1", wait_seconds=1)) + assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится" + + +def test_attachment_failed(downloads, monkeypatch): + att = _att("1", "a.png", 5) + failed = {**att, "state": "failed", "error": "HTTP 404", "command_id": 3} + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "fetching", "command_id": 3}, polls=[failed]) + _use(monkeypatch, hub) + assert asyncio.run(mcp_server.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404" + + +def test_attachment_413(downloads, monkeypatch): + from jira_hub.client import HubError + att = _att("5", "запись.mp4", 26214400) + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой")) + _use(monkeypatch, hub) + assert asyncio.run(mcp_server.attachment(ATT_KEY, "#5")) == \ + "файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»" + + +def test_attachment_409_passes_hub_text(downloads, monkeypatch): + from jira_hub.client import HubError + hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]), + fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14")) + _use(monkeypatch, hub) + assert "агент v9 не умеет" in asyncio.run(mcp_server.attachment(ATT_KEY, "5")) + + +def test_attachment_existing_copy_same_size(downloads, monkeypatch): + hub = _ready_hub(data=b"PNG" * 3) + _use(monkeypatch, hub) + path = downloads / "31337" / "скрин 1.png" + path.parent.mkdir(parents=True) + path.write_bytes(b"OLD" * 3) + os.utime(path, (1_000_000, 1_000_000)) + asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) + assert path.read_bytes() == b"OLD" * 3 and path.stat().st_mtime == 1_000_000