Вложения в карточке задачи: ссылки из текста и состояние (#83)

Модуль 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 14:18:59 +04:00
parent 5dfa673f8e
commit b5adfdf9b5
7 changed files with 213 additions and 7 deletions

View File

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

View File

@ -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})$")

View File

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

View File

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

View File

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

81
tests/test_attachments.py Normal file
View File

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

View File

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