diff --git a/README.md b/README.md index 173de8a..3b6fdae 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj | Инструмент | Параметры | Результат | |---|---|---| | `issues` | фильтры как у `GET /issues`, `limit` | таблица задач проекта сессии | -| `issue` | `key` | поля, связи, заметки, последние команды | +| `issue` | `key` | поля, связи, заметки, последние команды, комментарии, 5 последних изменений («изменения (N)») | +| `changes` | `hours`, `since`, `until` (ISO; без смещения — местное время), `kinds` (`status`, `description`, `dates`, `comment`, `other` или `all`), `key` | лента изменений по задачам: что закрылось, правки описания, сроки, новые комментарии; по умолчанию — с начала суток; без привязки — только с `key` | | `notes_set` | `key`, поля заметок | обновлённые заметки | | `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | | `batch_status` | `id` | статусы команд, ошибки, оценка остатка | @@ -89,7 +90,7 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj | `projects` | нет | проекты хаба: метка, название, доска, тип доски, число задач; проект сессии отмечен | | `project_create` | `label`, `name`, `board_id` (необязательно) | проект заведён, пакет обновления `#N`, готовый фрагмент `.mcp.json` для репозитория проекта | | `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки | -| `sync_log` | `limit` | последние загрузки состояния | +| `sync_log` | `limit` | последние загрузки состояния и «журнал: принят / нет» | | `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` | Автор команд из MCP: `claude`; из API без автора: `api`; из веб-приложения: `viewer`. @@ -170,12 +171,14 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `GET /agent/commands/{id}` | агент | команда по id | | `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) | | `POST /agent/state` | агент | загрузка состояния задач пачками | +| `POST /agent/history` | агент | журнал изменений Jira пачками (агент v13+, по `history` в `refresh`) → `{accepted, duplicates}`; повторы отбрасываются, неверная пачка — 422 `bad_history` | | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | | `GET /projects`, `POST /projects` | клиент | проекты `{projects: […]}` (метка, название, `board_id`, `board_type`, число задач) / завести `{label, name, board_id?, author?}` → 201 `{project, batch_id}` и пакет «Новый проект <метка>» (`refresh` по общей JQL); 409 `project_exists`, 422 `bad_project`, 422 `bad_batch` (постановка `refresh` отказана) | | `GET /issues` | клиент | фильтры `project` (метка; неизвестная — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | -| `GET /issues/{key}` | клиент | задача целиком | +| `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) | +| `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` | | `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `fact_status`/`work_kind`; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон | | `GET /`, `GET /links` | — | перенаправление на `/app/board` | | `PUT /issues/{key}/note` | клиент | заметки (частично) | @@ -184,7 +187,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет; `project` в теле — отказ, если команда не из этого проекта) / отмена (`force` для взятой) | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` (общая JQL по меткам всех проектов; проектов нет — 422 «нет проектов») | | `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | -| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | +| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок (`history_at` — когда принят журнал изменений полного прогона) | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 | ```bash @@ -194,7 +197,7 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint= ## Веб-приложение -Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board?project=<метка>`, задачи `/app/issues?project=<метка>`, карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и +Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board?project=<метка>`, задачи `/app/issues?project=<метка>`, лента изменений `/app/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и `/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: diff --git a/openspec/changes/web-feed/design.md b/openspec/changes/web-feed/design.md index 52b6675..4cb2947 100644 --- a/openspec/changes/web-feed/design.md +++ b/openspec/changes/web-feed/design.md @@ -37,3 +37,6 @@ Jira» — `shell/RefreshProvider.tsx`. Хаб: `GET /changes` (`since`/`until` `refetchOnWindowFocus: true` у ленты.** Обоснование: замороженная при открытии граница «до» не давала перечитыванию показать новые события, а фокус вкладки в приложении не перечитывал ничего. Цена ошибки: лишний запрос `/changes` при возврате на вкладку (не чаще раза в 30 с — `staleTime`). +6. 2026-10-22 — **R-5 (follow-up M-5 финального ревью): адрес без единого известного вида — набор по умолчанию, а не + пустой; пустой набор — только явное `kinds=none`.** Обоснование: ссылка с устаревшим видом не должна открывать + пустую ленту. Цена ошибки: нет. diff --git a/openspec/changes/web-feed/specs/web-app/spec.md b/openspec/changes/web-feed/specs/web-app/spec.md index d05cde1..185a462 100644 --- a/openspec/changes/web-feed/specs/web-app/spec.md +++ b/openspec/changes/web-feed/specs/web-app/spec.md @@ -51,7 +51,8 @@ Jira»; «Меню» открывает развёрнутую панель по до сейчас, «Вчера» — с начала прошлых суток до начала текущих) и храниться в адресе (`period=2h|today|yesterday|7d`; «Сегодня» в адрес не пишется). Виды событий SHALL выбираться флажками «Статусы», «Описание», «Сроки», «Комментарии», «Прочие поля» (по умолчанию все, кроме «Прочие поля») и храниться в адресе (`kinds=` через запятую; набор по умолчанию в -адрес не пишется); снятые все флажки — сообщение «выберите хотя бы один вид», запроса нет. +адрес не пишется; в адресе нет ни одного известного вида — набор по умолчанию); снятые все флажки — сообщение «выберите +хотя бы один вид», запроса нет. События SHALL быть сгруппированы по задаче; задачи — по времени последнего события, новые сверху. У группы: ключ — ссылка на карточку задачи (окно поверх ленты, требование «Карточка задачи»), тема и текущий статус; под ней события по diff --git a/web/src/feed/FeedPage.test.tsx b/web/src/feed/FeedPage.test.tsx index 4ae56f7..0c996d0 100644 --- a/web/src/feed/FeedPage.test.tsx +++ b/web/src/feed/FeedPage.test.tsx @@ -119,6 +119,23 @@ describe("лента изменений", () => { expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d"); }); + it("M-1: /app/feed без project — проект дописывается в адрес", async () => { + hub(); + const router = open("/app/feed"); + await page(); + await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); + }); + + it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => { + hub(() => ({ truncated: true, changes: [ev({ author: null })] })); + open("/app/feed?project=AWPCA"); + const notice = await screen.findByText("показаны последние 1 событий — выберите период короче"); + const group = screen.getByTestId("feed-group"); + expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(group).getByText("—", { selector: ".feed-event__author" })).toBeInTheDocument(); + expect(within(screen.getByTestId("page-feed")).queryAllByRole("region")).toHaveLength(0); + }); + it("пустой период, truncated, ошибка с «Повторить»", async () => { let mode: "empty" | "truncated" | "error" = "empty"; hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" }) diff --git a/web/src/feed/FeedPage.tsx b/web/src/feed/FeedPage.tsx index 7523c32..7315460 100644 --- a/web/src/feed/FeedPage.tsx +++ b/web/src/feed/FeedPage.tsx @@ -62,8 +62,9 @@ export function FeedPage({ project }: { project: string }) { )} {kinds.length > 0 && changes.isPending &&

Загрузка…

} {data && !count && за период изменений нет} + {data?.truncated && {`показаны последние ${count} событий — выберите период короче`}} {groups.map((g) => ( - +
{g.summary ?? "нет в зеркале"} @@ -72,7 +73,6 @@ export function FeedPage({ project }: { project: string }) { ))} - {data?.truncated && {`показаны последние ${count} событий — выберите период короче`}} ); } diff --git a/web/src/feed/events.test.ts b/web/src/feed/events.test.ts index 35685ce..eee4757 100644 --- a/web/src/feed/events.test.ts +++ b/web/src/feed/events.test.ts @@ -28,7 +28,8 @@ describe("виды", () => { expect(parseKinds("status,foo")).toEqual(["status"]); expect(parseKinds(null)).toEqual(DEFAULT_KINDS); expect(parseKinds("")).toEqual(DEFAULT_KINDS); - expect(parseKinds("foo")).toEqual([]); + expect(parseKinds("foo")).toEqual(DEFAULT_KINDS); // M-5: ни одного известного — набор по умолчанию + expect(parseKinds("none")).toEqual([]); }); it("набор по умолчанию в адрес не пишется", () => { @@ -50,7 +51,8 @@ describe("текст события", () => { it("комментарий — 300 символов одной строкой", () => { const t = eventText(ev({ kind: "comment", field: "comment", from: null, to: "а\n" + "к".repeat(350) })); - expect(t).toBe("комментарий: " + ("а " + "к".repeat(350)).slice(0, 299) + "…"); + expect(t).toBe("комментарий: " + ("а " + "к".repeat(350)).slice(0, 300) + "…"); // M-4: 300 символов текста + expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: null }))).toBe("комментарий: —"); // M-6 }); it("прочие поля — подпись из словаря или имя поля", () => { diff --git a/web/src/feed/events.ts b/web/src/feed/events.ts index 19535c3..14a42d0 100644 --- a/web/src/feed/events.ts +++ b/web/src/feed/events.ts @@ -23,11 +23,12 @@ export const KINDS: { value: ChangeKind; label: string }[] = [ ]; export const DEFAULT_KINDS: ChangeKind[] = ["status", "description", "dates", "comment"]; -// виды из адреса: неизвестные отбрасываются, пусто — набор по умолчанию +// виды из адреса: неизвестные отбрасываются; пусто или ни одного известного — набор по умолчанию (M-5); «none» — пустой набор (R-2) export function parseKinds(v: string | null): ChangeKind[] { - if (!v) return [...DEFAULT_KINDS]; - const asked = v.split(",").map((s) => s.trim()); - return KINDS.map((k) => k.value).filter((k) => asked.includes(k)); + if (v === "none") return []; + const asked = (v ?? "").split(",").map((s) => s.trim()); + const known = KINDS.map((k) => k.value).filter((k) => asked.includes(k)); + return known.length ? known : [...DEFAULT_KINDS]; } // в адрес — в порядке KINDS; набор по умолчанию не пишется @@ -68,7 +69,10 @@ export function eventText(e: ChangeEvent): string { case "status": return `статус «${dash(e.from)}» → «${dash(e.to)}»`; case "description": return "описание изменено"; case "dates": return `${DATE_LABELS[e.field] ?? e.field} ${day(e.from_value, e.from)} → ${day(e.to_value, e.to)}`; - case "comment": return "комментарий: " + cut(oneLine(e.to), 300); + case "comment": { // первые 300 символов текста и «…» (M-4); без текста — «—» (M-6) + const s = oneLine(e.to); + return "комментарий: " + (!s ? "—" : s.length <= 300 ? s : s.slice(0, 300) + "…"); + } default: { const v = (s: string | null) => (s ? cut(oneLine(s), 80) : "—"); return `${FIELD_LABELS[e.field] ?? e.field}: «${v(e.from)}» → «${v(e.to)}»`; diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index d262f35..2c05957 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -60,7 +60,7 @@ export function Layout() { : resolveProject(urlLabel, rememberedProject(), projects.data), [projects.isPending, projects.error, projects.data, urlLabel]); const okLabel = state.kind === "ok" ? state.project.label : null; const fromUrl = state.kind === "ok" && state.fromUrl; - const onPage = at === "/board" || at === "/issues"; + const onPage = at === "/board" || at === "/issues" || at === "/feed"; useEffect(() => { if (okLabel) rememberProject(okLabel); }, [okLabel]); // выбранный проект записывается в адрес без новой записи истории; с открытой карточкой адрес не трогаем (design 10) useEffect(() => { diff --git a/web/src/shell/NotFound.tsx b/web/src/shell/NotFound.tsx index b7805c4..1d17bde 100644 --- a/web/src/shell/NotFound.tsx +++ b/web/src/shell/NotFound.tsx @@ -5,7 +5,7 @@ export function NotFound() { return (
Страница не найдена}> - Разделы приложения: Доска связей, Задачи. + Разделы приложения: Доска связей, Задачи, Лента.
); diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index b7cac14..6d09eed 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -72,6 +72,7 @@ describe("боковая панель", () => { const main = within(screen.getByRole("main")); expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board"); expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues"); + expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed"); // M-2 expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена"); });