From b5adfdf9b575eb1076076bd27ce519b0d675e4af Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 14:18:59 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8:=20=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B8=20=D0=B8=D0=B7=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D0=B0=20=D0=B8=20=D1=81=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D0=BD=D0=B8=D0=B5=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модуль attachments (fmt_size, ref_names, resolve, state, get); mirror.get_issue отдаёт attachments, description_attachment_ids, attachment_ids комментариев и скрывает fetch_attachment из commands. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 5 ++- src/jira_hub/api.py | 2 +- src/jira_hub/attachments.py | 77 +++++++++++++++++++++++++++++++++++ src/jira_hub/mirror.py | 17 ++++++-- tests/test_api.py | 12 ++++++ tests/test_attachments.py | 81 +++++++++++++++++++++++++++++++++++++ tests/test_mirror.py | 26 +++++++++++- 7 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 src/jira_hub/attachments.py create mode 100644 tests/test_attachments.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e8d10df..63e615d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,7 +10,8 @@ | `db` | схема SQLite (v6: + `issue_attachment`, `attachment_file`), миграции по `schema_version` (копия базы перед миграцией), `connect`, время Jira → UTC (`jira_utc`) | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | | `projects` | проекты-метки: таблица `project` (метка, название, `board_id`, `board_type`), проверка метки, базовая JQL из меток проектов (`base_jql`), доски (`boards`), миграция схемы v3→v4 (`migrate_v3`) | `db`, `settings` | -| `mirror` | зеркало задач, связей, комментариев (время комментария в UTC — `created_at`) и списка вложений `issue_attachment` (схема v6; `check_attachments`, пишет `apply_state`; `attachment_file` ему не принадлежит), разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверками `check_comments`, `check_attachments`), заметки, фильтры, снимок доски `board`, карточка `get_issue` (с историей задачи из `history`) | `db`, `history` | +| `attachments` | вложения для интерфейсов: ссылки на них в вики-разметке текста (`ref_names`, `resolve`), формат размера (`fmt_size`), состояние `ready`/`fetching`/`failed`/`none` по `attachment_file`, хранилищу и последней `fetch_attachment` (`state`, `get`) | `files` | +| `mirror` | зеркало задач, связей, комментариев (время комментария в UTC — `created_at`) и списка вложений `issue_attachment` (схема v6; `check_attachments`, пишет `apply_state`; `attachment_file` ему не принадлежит), разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверками `check_comments`, `check_attachments`), заметки, фильтры, снимок доски `board`, карточка `get_issue` (с историей задачи из `history`) | `db`, `history`, `attachments` | | `history` | журнал изменений задач `issue_change` (схема v5): проверка и запись пачек агента (`check_changes`, `record_changes`, логическое имя поля `logical_field`), параметр `history` для выдачи `refresh` (`delivery_param`: отметка — последний полный прогон с `sync_run.history_at`, минус 15 минут; стартовая JQL `backfill_jql`), признак принятого журнала `accepted`, лента `feed` (журнал + новые комментарии по `issue_comment.created_at`) и история задачи `issue_changes` | `db`, `settings`, `projects` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` | @@ -40,7 +41,7 @@ боковой панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только источник и события снимка. -Цепочки: `api → queue → commands/mirror/settings/projects/history → db`; `mirror → history → projects/settings`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. +Цепочки: `api → queue → commands/mirror/settings/projects/history → db`; `mirror → history → projects/settings`; `mirror → attachments → files`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index ffc32b3..2642c8f 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -249,7 +249,7 @@ def h_issues(hub, conn, m, q, body): def h_issue(hub, conn, m, q, body): - return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue") + return 200, _not_found(mirror.get_issue(conn, m.group(1), hub.files_dir), "issue") _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") diff --git a/src/jira_hub/attachments.py b/src/jira_hub/attachments.py new file mode 100644 index 0000000..bfddd85 --- /dev/null +++ b/src/jira_hub/attachments.py @@ -0,0 +1,77 @@ +"""Вложения задач для интерфейсов: ссылки на них в вики-разметке текста, формат размера и состояние (data-model +«Скачанные вложения», hub-api «Ссылки на вложения в тексте»). Состояние вычисляется при чтении; модуль только читает +базу и хранилище файлов, очередь и HTTP не знает.""" +import re + +from . import files + +_IMAGE = re.compile(r"!([^!|\n]+)(?:\|[^!\n]*)?!") +_LINK = re.compile(r"\[(?:[^\]|\n]*\|)?\^([^\]\n]+)\]") + + +def fmt_size(size: int | None) -> str: + if size is None: + return "размер неизвестен" + if size < 1024: + return f"{size} Б" + if size < 1024 ** 2: + return f"{size / 1024:.1f} КБ".replace(".", ",") + return f"{size / 1024 ** 2:.1f} МБ".replace(".", ",") + + +def ref_names(text: str | None) -> list[str]: + """Имена вложений из текста в порядке появления, без повторов; внешние картинки (`://`) пропускаются.""" + if not text: + return [] + found = [(m.start(), m.group(1)) for m in _IMAGE.finditer(text) if "://" not in m.group(1)] + found += [(m.start(), m.group(1)) for m in _LINK.finditer(text)] + names: list[str] = [] + for _, name in sorted(found, key=lambda f: f[0]): + if name not in names: + names.append(name) + return names + + +def _newest_key(item: dict): + aid = str(item["attachment_id"]) + return item.get("created_at") or "", int(aid) if aid.isdigit() else -1, aid + + +def resolve(names: list[str], items: list[dict]) -> list[str]: + """attachment_id по точному имени; при повторе имени — самое новое; имени нет среди вложений — пропуск.""" + out: list[str] = [] + for name in names: + same = [i for i in items if i["filename"] == name] + if same: + aid = str(max(same, key=_newest_key)["attachment_id"]) + if aid not in out: + out.append(aid) + return out + + +def state(conn, files_dir, attachment_id: str) -> dict: + """Состояние вложения: ready / fetching / failed / none. files_dir=None — файла в хранилище нет.""" + cmds = conn.execute( + "SELECT id, batch_id, status, error FROM command WHERE type = 'fetch_attachment' " + "AND json_extract(params, '$.attachment_id') = ? ORDER BY id DESC", (attachment_id,)).fetchall() + last = cmds[0] if cmds else None + row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (attachment_id,)).fetchone() + if row is not None and files_dir is not None and files.exists(files_dir, row["file"]): + return {"state": "ready", "error": None, "command_id": last["id"] if last else None, + "batch_id": last["batch_id"] if last else None} + live = next((c for c in cmds if c["status"] in ("queued", "claimed")), None) + if live is not None: + return {"state": "fetching", "error": None, "command_id": live["id"], "batch_id": live["batch_id"]} + if last is not None and last["status"] in ("failed", "cancelled"): + return {"state": "failed", "error": last["error"], "command_id": last["id"], "batch_id": last["batch_id"]} + return {"state": "none", "error": None, "command_id": last["id"] if last else None, + "batch_id": last["batch_id"] if last else None} + + +def get(conn, files_dir, attachment_id) -> dict | None: + row = conn.execute( + "SELECT attachment_id, key, filename, size, mime_type, author, created FROM issue_attachment WHERE attachment_id = ?", + (attachment_id,)).fetchone() + if row is None: + return None + return {**dict(row), **state(conn, files_dir, attachment_id)} diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index 4acac0a..c1631da 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -2,7 +2,7 @@ Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json -from . import db, history +from . import attachments, db, history ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] @@ -169,7 +169,7 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l return out -def get_issue(conn, key) -> dict | None: +def get_issue(conn, key, files_dir=None) -> dict | None: row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone() if row is None: return None @@ -185,10 +185,21 @@ def get_issue(conn, key) -> dict | None: d["comments"] = [dict(r) for r in conn.execute( "SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? " "ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))] + rows = [dict(r) for r in conn.execute( + "SELECT attachment_id, filename, size, mime_type, author, created, created_at FROM issue_attachment WHERE key = ? " + "ORDER BY created_at DESC, CAST(attachment_id AS INTEGER) DESC, attachment_id DESC", (key,))] + d["attachments"] = [] + for r in rows: + st = attachments.state(conn, files_dir, r["attachment_id"]) + d["attachments"].append({**{k: v for k, v in r.items() if k != "created_at"}, "state": st["state"], "error": st["error"]}) + d["description_attachment_ids"] = attachments.resolve(attachments.ref_names(d.get("description")), rows) + for c in d["comments"]: + c["attachment_ids"] = attachments.resolve(attachments.ref_names(c["body"]), rows) d["changes"] = history.issue_changes(conn, key) d["note"] = get_note(conn, key) d["commands"] = [dict(r) for r in conn.execute( - "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10", + "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? AND type != 'fetch_attachment' " + "ORDER BY id DESC LIMIT 10", (f'%"{key}"%',))] return d diff --git a/tests/test_api.py b/tests/test_api.py index 7f87bac..50fe134 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1018,3 +1018,15 @@ def test_changes_route_bad_params(hub, params, status, name): assert e.value.status == status if name: assert e.value.error == "bad_request" and name in e.value.detail + + +def test_issue_has_attachments(hub): + a, c = hub["agent"], hub["client"] + c.post("/batches", {"title": "x", "author": "claude", "commands": [{"type": "refresh", "params": {"keys": ["A-1"]}}]}) + cmd = a.post("/agent/claim", {})["command"] + a.post("/agent/state", {"command_id": cmd["id"], "page": 1, "last_page": True, "links": [], "issues": [ + {"key": "A-1", "summary": "Т", "labels": [], "description": "!a.png!", "created": "2026-10-10T10:00:00.000+0300", + "attachments": [{"id": "7", "filename": "a.png", "size": 10, "created": "2026-10-10T10:00:00.000+0300"}]}]}) + got = c.get("/issues/A-1") + assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none" + assert got["description_attachment_ids"] == ["7"] diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 0000000..bcd2bd8 --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,81 @@ +import json + +import pytest + +from jira_hub import attachments, db, files, mirror + + +def test_fmt_size(): + assert attachments.fmt_size(512) == "512 Б" + assert attachments.fmt_size(240000) == "234,4 КБ" + assert attachments.fmt_size(25_000_000) == "23,8 МБ" + assert attachments.fmt_size(None) == "размер неизвестен" + + +def test_ref_names(): + text = "см. !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!" + assert attachments.ref_names(text) == ["скрин 1.png", "ОСВ 60.xlsx"] + assert attachments.ref_names("[отчёт|^a.pdf]") == ["a.pdf"] + assert attachments.ref_names("!http://x/y.png!") == [] + assert attachments.ref_names(None) == [] + + +def test_resolve_newest(): + items = [{"attachment_id": "10", "filename": "скрин.png", "created_at": "2026-10-14T10:00:00+00:00"}, + {"attachment_id": "20", "filename": "скрин.png", "created_at": "2026-10-20T10:00:00+00:00"}] + assert attachments.resolve(["скрин.png", "нет.png", "скрин.png"], items) == ["20"] + + +@pytest.fixture +def conn(open_db): + c = open_db() + mirror.apply_state(c, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ + {"id": "31337", "filename": "a.png", "size": 3, "created": "2026-10-20T10:00:00.000+0300"}]}], []) + return c + + +def add_cmd(conn, status, error=None, att="31337"): + conn.execute("INSERT OR IGNORE INTO batch(id, title, author, created_at) VALUES (1, 't', 'a', ?)", (db.now(),)) + cur = conn.execute("INSERT INTO command(batch_id, seq, type, params, status, error) VALUES (1, (SELECT COUNT(*) FROM command), " + "'fetch_attachment', ?, ?, ?)", (json.dumps({"key": "A-1", "attachment_id": att}), status, error)) + return cur.lastrowid + + +def test_state_none(conn, tmp_path): + assert attachments.state(conn, tmp_path, "31337") == {"state": "none", "error": None, "command_id": None, "batch_id": None} + + +@pytest.mark.parametrize("status", ["queued", "claimed"]) +def test_state_fetching(conn, tmp_path, status): + cid = add_cmd(conn, status) + assert attachments.state(conn, tmp_path, "31337") == {"state": "fetching", "error": None, "command_id": cid, "batch_id": 1} + + +def put_ready(conn, root): + sha, size = files.put(root, b"abc") + conn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('31337', ?, ?, ?)", + (sha, size, db.now())) + return sha + + +def test_state_ready_and_removed(conn, tmp_path): + sha = put_ready(conn, tmp_path) + add_cmd(conn, "done") + assert attachments.state(conn, tmp_path, "31337")["state"] == "ready" + assert attachments.get(conn, tmp_path, "31337")["filename"] == "a.png" + (tmp_path / sha).unlink() + assert attachments.state(conn, tmp_path, "31337")["state"] == "none" + assert attachments.state(conn, None, "31337")["state"] == "none" + + +def test_state_failed_and_cancelled(conn, tmp_path): + add_cmd(conn, "failed", "нет доступа") + assert attachments.state(conn, tmp_path, "31337")["state"] == "failed" + assert attachments.state(conn, tmp_path, "31337")["error"] == "нет доступа" + add_cmd(conn, "cancelled", "отменена") + got = attachments.state(conn, tmp_path, "31337") + assert got["state"] == "failed" and got["error"] == "отменена" + + +def test_get_missing(conn, tmp_path): + assert attachments.get(conn, tmp_path, "999") is None diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 14345c3..5074efe 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -213,7 +213,7 @@ def test_comments_newest_first_and_shape(conn): got = mirror.get_issue(conn, "A-1")["comments"] assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300", - "updated": "2026-10-15T10:00:00.000+0300", "body": ""} + "updated": "2026-10-15T10:00:00.000+0300", "body": "", "attachment_ids": []} def test_issue_epic_summary_and_absent_link(conn): @@ -378,3 +378,27 @@ def test_check_attachments_errors(bad, field): def test_check_attachments_accepts_missing_and_valid(): mirror.check_attachments([issue("A-1"), issue("A-2", attachments=[attachment("1"), attachment("2", size=None, mime_type=None)])]) + + +def test_get_issue_attachments(conn): + mirror.apply_state(conn, [issue( + "A-1", description="см. !старый.png!", + attachments=[attachment("31337", filename="скрин 1.png", created="2026-10-14T12:00:00.000+0300"), + attachment("31338", filename="ОСВ 60.xlsx", created="2026-10-15T12:00:00.000+0300")], + comments=[comment("101", body="см. !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!"), + comment("102", body="без ссылок")])], []) + got = mirror.get_issue(conn, "A-1") + assert [a["attachment_id"] for a in got["attachments"]] == ["31338", "31337"] + assert got["attachments"][0]["state"] == "none" and got["attachments"][0]["error"] is None + assert got["description_attachment_ids"] == [] + by_id = {c["comment_id"]: c for c in got["comments"]} + assert by_id["101"]["attachment_ids"] == ["31337", "31338"] and by_id["102"]["attachment_ids"] == [] + + +def test_get_issue_commands_skip_fetch(conn): + mirror.apply_state(conn, [issue("A-1")], []) + conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 't', 'a', ?)", (db.now(),)) + for seq, typ in enumerate(["fetch_attachment", "ensure_fields"]): + conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (1, ?, ?, ?)", + (seq, typ, '{"key": "A-1", "attachment_id": "1"}')) + assert [c["type"] for c in mirror.get_issue(conn, "A-1")["commands"]] == ["ensure_fields"]