diff --git a/.env.example b/.env.example index a0bb1381..3226a932 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,14 @@ POLAR_PRO_YEARLY_PRODUCT_ID=YOUR_POLAR_YEARLY_PRODUCT_ID # NOTE: MCP JWT は `BETTER_AUTH_SECRET` を署名鍵として共有する (audience で分離)。 # MCP JWTs are signed with `BETTER_AUTH_SECRET` (audience-scoped to `zedi-mcp`). +# Sentry DSNs (Epic #616). Each surface uses its own DSN so events are routed to +# the correct project. Leave unset locally — the SDKs no-op when DSN is empty. +# 各サーフェス(Web / 管理画面 / API)はそれぞれ別の DSN を使用する。 +# 未設定でも SDK は no-op のため、ローカルでは空のままで良い。 +# VITE_SENTRY_DSN_WEB=https://@o0.ingest.sentry.io/ +# VITE_ADMIN_SENTRY_DSN=https://@o0.ingest.sentry.io/ +# SENTRY_DSN_API=https://@o0.ingest.sentry.io/ + # Docker Compose (docker-compose.dev.yml) # Override defaults for local dev; required in shared/production. Do not commit real secrets. # POSTGRES_USER=zedi diff --git a/.github/actions/claude-analyze/README.md b/.github/actions/claude-analyze/README.md new file mode 100644 index 00000000..19959395 --- /dev/null +++ b/.github/actions/claude-analyze/README.md @@ -0,0 +1,295 @@ +# `claude-analyze` action + +Sentry が検知した API エラーを Claude (Anthropic) で解析し、構造化 JSON を Zedi +API のコールバックに `PUT` する composite action。Epic [#616](https://github.com/otomatty/zedi/issues/616) +Phase 2 ([#806](https://github.com/otomatty/zedi/issues/806)) と Phase 3 +(severity 判定による自動 Issue 起票・[#808](https://github.com/otomatty/zedi/issues/808)) +を併せて実装する。 + +Composite action that asks Claude to analyze a Sentry-reported API error, +PUTs the validated structured result back to the Zedi API callback, and +auto-files (or comments on) a GitHub Issue when AI severity is `high` or +`medium`. Implements Epic [#616](https://github.com/otomatty/zedi/issues/616) +Phase 2 ([#806](https://github.com/otomatty/zedi/issues/806)) and Phase 3 +([#808](https://github.com/otomatty/zedi/issues/808)). + +--- + +## ファイル構成 / Files + +| ファイル / file | 役割 / role | +| ------------------------------ | ---------------------------------------------------------------- | +| `action.yml` | composite action 定義 / composite action definition | +| `analyze.mjs` | Claude を呼んで JSON を生成するスクリプト / Claude orchestrator | +| `schema.mjs` | Zod 出力スキーマ / output schema (mirrors API) | +| `prompt.md` | Claude へのプロンプトテンプレ / prompt template | +| `autoIssue.mjs` | Issue 起票・コメント追記の純粋関数と HTTP 層 / Issue helpers | +| `autoIssueRunner.mjs` | `autoIssue.mjs` を action から呼ぶエントリ / runner entry | +| `__tests__/schema.test.mjs` | 出力スキーマ fixture テスト / fixture tests for the schema | +| `__tests__/autoIssue.test.mjs` | Issue 起票ロジックの単体テスト / unit tests for the Issue helper | +| `__tests__/fixtures/*.json` | 有効・無効ペイロードのサンプル / valid + invalid sample payloads | + +呼び出し元 / called from: `.github/workflows/analyze-error.yml`. + +--- + +## `repository_dispatch` の `client_payload` 契約 / Dispatch contract + +API 側 (`server/api/src/routes/webhooks/sentry.ts`) は `event_type: analyze-error` +で次のペイロードを発火する: + +```json +{ + "api_error_id": "uuid — api_errors.id", + "sentry_issue_id": "Sentry の group.id 文字列", + "title": "1〜2行のエラータイトル", + "route": "POST /api/... or null" +} +``` + +Action 側で必須なのは `api_error_id`, `sentry_issue_id`, `title` の 3 つ。`route` +は空でも構わない(API のスキーマ的にも nullable)。 + +The API webhook fires `event_type: analyze-error` with the payload shape above. +Only `api_error_id`, `sentry_issue_id`, and `title` are required on the action +side; `route` is allowed to be empty (the API column is nullable). + +--- + +## 必要な secrets / Required secrets + +ワークフロー (`analyze-error.yml`) が読み取るリポジトリ secrets: + +| name | 用途 / purpose | +| ---------------------------- | ----------------------------------------------------------------- | +| `ANTHROPIC_API_KEY` | Claude API 呼び出し | +| `GITHUB_APP_ID` | App ID (P2-1 と共有 / shared with P2-1) | +| `GITHUB_APP_PRIVATE_KEY` | App private key (PKCS#8 PEM) | +| `AI_ERROR_CALLBACK_BASE_URL` | API のベース URL (`https://api.example.com`) — 末尾スラッシュなし | + +`GITHUB_APP_INSTALLATION_ID` は Action 側では使わない(installation token は +`actions/create-github-app-token@v2` が App ID から自動解決する)。 +The action does not need `GITHUB_APP_INSTALLATION_ID` directly — the installation +token is resolved automatically by `actions/create-github-app-token@v2`. + +--- + +## 出力 JSON スキーマ / Output JSON schema + +Anthropic から得たテキストは `schema.mjs` の Zod スキーマで検証される。サーバ側 +`updateAiAnalysis` (`server/api/src/services/apiErrorService.ts`) と同じ境界を +持たせている。 + +```jsonc +{ + "severity": "high | medium | low | unknown", // 必須 / required + "ai_summary": "1-2 文 / one or two sentences", // 必須 / required + "ai_root_cause": "string | null", // 任意 / optional + "ai_suggested_fix": "string | null", // 任意 / optional + "ai_suspected_files": [ + // 任意・最大 5 件 / optional, max 5 + { "path": "repo-relative", "reason": "string?", "line": 42 }, + ], +} +``` + +スキーマに合わない応答(severity が enum 外、`ai_summary` 欠落、`ai_suspected_files` +の `path` 空、未知のトップレベルキー、等)は CI 段階で `parseAndValidate` が throw +してジョブが赤くなる。API には書き戻されない(fire-and-forget の "失敗" 扱い)。 + +Responses that violate the schema (out-of-enum severity, missing `ai_summary`, +empty suspected-file `path`, unknown top-level keys, …) cause `parseAndValidate` +to throw at the CI step. Nothing is written back to the API — Epic #616's +"AI failure must not affect end-user requests" guarantee is preserved by +treating these as hard CI failures rather than partial writes. + +--- + +## ローカルでスキーマ検証 / Validate the schema locally + +Node 24 の組み込みテストランナーで動く(vitest 不要)。 + +Runs on Node 24's built-in test runner — no vitest needed. + +```bash +node --test .github/actions/claude-analyze/__tests__/schema.test.mjs \ + .github/actions/claude-analyze/__tests__/autoIssue.test.mjs +``` + +新しいシナリオを追加するときは `__tests__/fixtures/` に JSON を置いて、`schema.test.mjs` +にケースを 1 つ足す。Issue 起票ロジックを変更したときは `autoIssue.test.mjs` の +純粋関数テスト(`shouldFileIssue`, `buildIssueTitle`, `buildIssueBody`, +`buildSentryIssueLabel`, `parseRepository`)と `runAutoIssue` の経路分岐に +ケースを追加する。 + +To add a schema scenario, drop a fixture JSON in `__tests__/fixtures/` and add +one case in `schema.test.mjs`. When changing the auto-issue logic, extend the +pure-function tests (`shouldFileIssue`, `buildIssueTitle`, `buildIssueBody`, +`buildSentryIssueLabel`, `parseRepository`) and `runAutoIssue`'s branch +coverage in `autoIssue.test.mjs`. + +--- + +## ローカルで analyze.mjs をドライラン / Local dry-run of analyze.mjs + +Anthropic 呼び出しを skip して固定 stub を返す。プロンプト生成と grep 抜粋の挙動を +確認できる。 + +Skips the Anthropic call and returns a fixed stub. Lets you eyeball the prompt +context and grep-keyword behavior without burning API credits. + +```bash +CLAUDE_ANALYZE_API_ERROR_ID=00000000-0000-0000-0000-000000000001 \ +CLAUDE_ANALYZE_SENTRY_ISSUE_ID=fixture-1 \ +CLAUDE_ANALYZE_TITLE="TypeError: Cannot read property 'note_id' of null in pageService" \ +CLAUDE_ANALYZE_ROUTE="GET /api/pages/:id" \ +CLAUDE_ANALYZE_REPOSITORY=otomatty/zedi \ +ANTHROPIC_API_KEY=unused-in-dry-run \ +CLAUDE_ANALYZE_DRY_RUN=true \ +CLAUDE_ANALYZE_OUTPUT=/tmp/analyze-dryrun.json \ +node .github/actions/claude-analyze/analyze.mjs + +cat /tmp/analyze-dryrun.json +``` + +実 API キーで Claude を呼び出して試したい場合は `CLAUDE_ANALYZE_DRY_RUN=false` に +して、`ANTHROPIC_API_KEY` に本物のキーを渡す。 + +To exercise the real Claude call, set `CLAUDE_ANALYZE_DRY_RUN=false` and use a +real `ANTHROPIC_API_KEY`. + +--- + +## CI で end-to-end を試す / End-to-end dry-run via workflow_dispatch + +`workflow_dispatch` 入力には `dry_run` (Anthropic 呼び出しスキップ) と `skip_callback` +(API への PUT スキップ) が用意されている。両方 true がデフォルトなので、secrets +未配備のリポジトリでもパイプラインが赤くならずに通るか確認できる。 + +The workflow exposes `dry_run` (skip Anthropic) and `skip_callback` (skip API +PUT) inputs, both defaulting to `true`. Useful in repositories where the +secrets are not yet provisioned — the action chain runs green end-to-end +without touching external services. + +GitHub UI からの手動起動例 / Manual run via GitHub UI: + +1. **Actions** → **Analyze API error** → **Run workflow** +2. 入力 / inputs: + - `api_error_id`: `00000000-0000-0000-0000-000000000001` + - `sentry_issue_id`: `fixture-1` + - `title`: `TypeError: Cannot read property 'note_id' of null in pageService` + - `route`: `GET /api/pages/:id` + - `dry_run`: ✅ true + - `skip_callback`: ✅ true +3. Run — analyze step が JSON を吐き、callback step がスキップされて緑になる。 + +`dry_run=false` + `skip_callback=true` にすると Claude を実際に呼ぶが、API への +PUT は行わない(プロンプト品質チェック用)。`dry_run=false` + `skip_callback=false` +は本番経路と同じで、`AI_ERROR_CALLBACK_BASE_URL` と GitHub App secrets が必要。 + +`dry_run=false` + `skip_callback=true` invokes Claude for real but does not +PUT to the API — useful for prompt-quality smoke tests. The fully live combo +(`dry_run=false` + `skip_callback=false`) requires `AI_ERROR_CALLBACK_BASE_URL` +and the GitHub App secrets to be configured. + +--- + +## 自動 Issue 起票 / Auto-file GitHub Issue (Phase 3) + +Epic [#616](https://github.com/otomatty/zedi/issues/616) Phase 3 / Issue +[#808](https://github.com/otomatty/zedi/issues/808) で実装した自動起票ステップ。 +`analyze` ステップの直後に走り、`severity` に応じて以下のように分岐する。 + +The auto-issue step (Phase 3) runs immediately after `analyze` and branches +on `severity` as follows. + +| severity | 挙動 / behavior | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `high` / `medium` | `sentry-issue:` ラベルでオープン Issue を検索 → あればコメント追記 / なければ新規 Issue を作成 / Search by label, then comment on the existing Issue or create a new one | +| `low` / `unknown` | 何もしない(DB / Sentry のみに残す)/ No-op (the record stays in the DB and Sentry only) | + +### 重複防止 / Dedup guarantee + +- 同一 `sentry_issue_id` で **オープン Issue は 1 件** に保つ。連続 100 回エラーが + 発生しても Issue は増えず、コメントが 100 件付く。Epic #616 の受け入れ条件 + 「同じエラーが連続 100 回発生しても Issue は 1 件 (もしくはコメント追記)」を満たす。 +- Maintains exactly one open Issue per `sentry_issue_id`. 100 recurrences of + the same error produce 1 Issue and 100 recurrence comments — meeting the + Epic #616 acceptance criterion "100 hits → 1 Issue". + +### 付与するラベル / Labels applied + +| label | 用途 / purpose | +| ------------------- | --------------------------------------------------------------------------- | +| `monitoring` | 監視関連 Issue の集約 / monitoring triage view | +| `auto-reported` | 自動起票であることのフラグ / "auto-filed by workflow" flag | +| `sentry-issue:` | 1:1 dedup key — 検索クエリのキーになる / dedup key used by the search query | + +未存在のラベルは Issue 起票時に `POST /repos/{o}/{r}/labels` で自動作成する。 +`auto-reported` も同様に未存在なら作成される(事前に `gh label create` する必要は +ない)。 + +Missing labels are created on the fly via `POST /repos/{o}/{r}/labels` — +including `auto-reported`, so no manual `gh label create` step is needed +before the first run. + +### PII 防衛 / PII guards + +- Issue 本文には Sentry URL を埋め込まない(org / project slug の漏洩防止)。 + `sentry_issue_id` と `sentry-issue:` ラベルがあれば Sentry 上のデータと + 相関できる。 +- AI 由来のテキスト(`ai_summary` 等)は Sentry の data scrubbing 後の入力で + 生成されているため、二段防御済み。 +- Sentry の data scrubbing 設定(Epic #616 §「Sentry 設定方針」)が一次防御で、 + この Issue 本文の構成が二次防御。 + +The Issue body never embeds a Sentry URL (would leak the org / project slug); +cross-reference via the `sentry-issue:` label instead. AI-derived text +inherits the Sentry data-scrubbing applied upstream. + +### 必要な権限 / Required permissions + +- ワークフローの `permissions` に `issues: write` を明示。 +- 実際の書き込みは GitHub App installation token (`actions/create-github-app-token@v2`) + 経由で行う。App の権限は `Issues: Read & Write` が必須。 + +The workflow declares `issues: write` for visibility; the actual writes use +the GitHub App installation token, which must hold `Issues: Read & Write`. + +### `workflow_dispatch` でのドライラン / Dry-running on workflow_dispatch + +`workflow_dispatch` 入力には `skip_issue` が追加されていて、デフォルトは `true`。 +`dry_run` / `skip_callback` と同じ感覚で、誤って Issue を起票しないように守る。 + +The `workflow_dispatch` form exposes a `skip_issue` toggle (default `true`) +mirroring `dry_run` / `skip_callback`. Set it to `false` only when you +explicitly want to exercise the live Issue-write path. + +### ローカルで `runAutoIssue` を試す / Local exercise of `runAutoIssue` + +`fetchImpl` を差し替えて GitHub API を叩かずにロジックを確認できる。 +`autoIssue.test.mjs` の `makeFetchStub` ヘルパが参考実装。 + +Inject a `fetch` stub to exercise `runAutoIssue` without hitting the real +GitHub API. The `makeFetchStub` helper in `autoIssue.test.mjs` is the +reference recipe. + +--- + +## リトライ・失敗時の挙動 / Retry & failure semantics + +| 失敗箇所 / failure point | 挙動 / behavior | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Anthropic API (5xx / network) | `analyze.mjs` 側で 2 試行まで(5 秒間隔)/ 2 attempts inside the script | +| 出力 JSON 検証失敗 | `parseAndValidate` が throw → ジョブ失敗、API には書き戻さない / job fails, no PUT | +| API callback 5xx / network | composite action の curl ループで 2 試行まで(5 秒間隔)/ 2 attempts in shell | +| API callback 4xx (auth / payload) | 即時失敗(リトライしない)/ immediate failure (no retry) | +| Issue 起票・コメント API 失敗 | `autoIssue.mjs` が throw → ステップ失敗。analyze 結果は既に PUT 済みなので DB は最新 / step fails after the analysis was PUT, so DB stays current | + +いずれの失敗もユーザーリクエストには影響しない(Epic #616 の不変条件)。Sentry +webhook 側は `triggerRepositoryDispatch().catch(log)` で発火しているので、本ワーク +フローが完全に未デプロイでも API はデグレしない。 + +None of these failures cascade to user-facing requests (Epic #616 invariant). +The Sentry webhook detaches `triggerRepositoryDispatch().catch(log)`, so even +a fully-undeployed workflow does not degrade the API. diff --git a/.github/actions/claude-analyze/__tests__/autoIssue.test.mjs b/.github/actions/claude-analyze/__tests__/autoIssue.test.mjs new file mode 100644 index 00000000..0525ec7f --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/autoIssue.test.mjs @@ -0,0 +1,557 @@ +/** + * Unit tests for the auto-issue helpers (Epic #616 Phase 3 / Issue #808). + * + * 実行方法 / How to run: + * `node --test .github/actions/claude-analyze/__tests__/autoIssue.test.mjs` + * + * 純粋関数(severity ゲート / ラベル生成 / 検索クエリ生成 / Issue 本文ビルダー)と、 + * `fetch` を差し替えた `runAutoIssue` の経路分岐をカバーする。実 GitHub API は + * 叩かない(fetch スタブを注入する)。 + * + * Covers the pure helpers (severity gate, label string, search query, body + * builders) plus the `runAutoIssue` orchestrator with an injected `fetch` stub. + * No real GitHub API calls — the HTTP boundary is mocked end-to-end. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + STATIC_LABELS, + buildIssueBody, + buildIssueTitle, + buildRecurrenceCommentBody, + buildSentryIssueLabel, + ensureLabel, + parseRepository, + runAutoIssue, + shouldFileIssue, +} from "../autoIssue.mjs"; + +test("STATIC_LABELS includes monitoring and auto-reported in deterministic order", () => { + assert.deepEqual(STATIC_LABELS, ["monitoring", "auto-reported"]); +}); + +test("shouldFileIssue is true for high and medium only", () => { + assert.equal(shouldFileIssue("high"), true); + assert.equal(shouldFileIssue("medium"), true); + assert.equal(shouldFileIssue("low"), false); + assert.equal(shouldFileIssue("unknown"), false); + assert.equal(shouldFileIssue(""), false); + assert.equal(shouldFileIssue(undefined), false); +}); + +test("buildSentryIssueLabel prefixes with sentry-issue:", () => { + assert.equal(buildSentryIssueLabel("abc-123"), "sentry-issue:abc-123"); +}); + +test("buildSentryIssueLabel rejects empty / non-string ids", () => { + assert.throws(() => buildSentryIssueLabel(""), /non-empty string/); + assert.throws(() => buildSentryIssueLabel(undefined), /non-empty string/); + assert.throws(() => buildSentryIssueLabel(42), /non-empty string/); +}); + +test("parseRepository splits owner/repo and rejects malformed values", () => { + assert.deepEqual(parseRepository("otomatty/zedi"), { owner: "otomatty", repo: "zedi" }); + assert.throws(() => parseRepository(""), /owner\/repo/); + assert.throws(() => parseRepository("only-one"), /owner\/repo/); + assert.throws(() => parseRepository("a/b/c"), /owner\/repo/); +}); + +test("buildIssueTitle prefixes severity, includes sentry id, and trims long titles", () => { + const title = buildIssueTitle({ + severity: "high", + title: "TypeError: Cannot read property 'note_id' of null", + sentryIssueId: "fixture-1", + }); + assert.match(title, /^\[high\]/); + assert.match(title, /TypeError/); + assert.match(title, /\(sentry:fixture-1\)$/); +}); + +test("buildIssueTitle truncates titles longer than the cap and falls back to placeholder", () => { + const long = "x".repeat(500); + const title = buildIssueTitle({ severity: "medium", title: long, sentryIssueId: "fixture-2" }); + // 256 は GitHub の Issue タイトル上限。本実装は body 部分を 180 で打ち切るので + // prefix `[medium] ` (10) + 180 + suffix ` (sentry:fixture-2)` (19) = 209 で + // 256 未満に収まる。回帰検知のためには 256 上限を assert すれば十分。 + // GitHub caps Issue titles at 256 chars; we slice the body at 180 so the + // total stays under 256 even with prefix/suffix overhead. Asserting the + // 256 ceiling is sufficient regression coverage. + assert.ok(title.length <= 256, `expected ≤256 chars, got ${title.length}`); + // 入力の `x` が 200 個以上残っていないこと(= 中で切られていること)を確認。 + // Confirm truncation actually happened (not just under the GitHub limit). + const xCount = (title.match(/x/g) ?? []).length; + assert.ok(xCount < 500, `expected truncation, ${xCount} 'x' chars remained`); + // 末尾に省略記号があり、切り捨てが視覚的に分かること。 + // Truncated titles should end with `...` so readers see at a glance that + // the title was cut. + assert.match(title, /\.\.\. \(sentry:fixture-2\)$/); + + // 上限ちょうど (TITLE_BODY_MAX) なら省略記号は付かない。 + // A title exactly at the cap should NOT receive the ellipsis. + const exactCap = "y".repeat(180); + const exactTitle = buildIssueTitle({ + severity: "high", + title: exactCap, + sentryIssueId: "fixture-cap", + }); + assert.ok(!/\.\.\./.test(exactTitle), "no ellipsis when title length is exactly the cap"); + + const placeholder = buildIssueTitle({ severity: "high", title: "", sentryIssueId: "fixture-3" }); + assert.match(placeholder, /\(no title\)/); +}); + +test("buildIssueBody includes AI fields and the sentry id but no Sentry URL", () => { + const body = buildIssueBody({ + severity: "high", + summary: "Database migration failed mid-flight.", + rootCause: "Migration 0042 added NOT NULL without backfill.", + suggestedFix: "Backfill then re-apply.", + suspectedFiles: [ + { path: "server/api/drizzle/0042_add_note_id.sql", reason: "Introduced NOT NULL." }, + { path: "server/api/src/services/pageService.ts", line: 42 }, + ], + route: "POST /api/pages", + sentryIssueId: "fixture-1", + apiErrorId: "00000000-0000-0000-0000-000000000001", + workflowRunUrl: "https://github.com/otomatty/zedi/actions/runs/123", + }); + assert.match(body, /Severity\s*\|\s*`high`/); + assert.match(body, /sentry_issue_id.*fixture-1/); + assert.match(body, /api_error_id.*00000000-0000-0000-0000-000000000001/); + assert.match(body, /Database migration failed mid-flight\./); + assert.match(body, /Migration 0042 added NOT NULL without backfill\./); + assert.match(body, /server\/api\/drizzle\/0042_add_note_id\.sql/); + assert.match(body, /actions\/runs\/123/); + // PII 防衛: Sentry URL は本文に含めない(`sentry-issue:` ラベルと id のみで参照可能)。 + // PII guard: do not embed a Sentry URL — the `sentry-issue:` label and id + // alone are enough to cross-reference, and including the URL would leak the + // org / project slug into a public-by-default Issue body. + assert.ok(!/sentry\.io/.test(body), "Sentry URL must not appear in the issue body"); +}); + +test("buildIssueBody escapes pipe characters in table cells so Markdown tables stay intact", () => { + // route に `|` が混入した場合 (例えば proxy 由来の奇妙なルート文字列など) でも + // 表組みが崩れないこと。エスケープ後は `\|` として表示される。 + // A `|` in the route (or any inline-rendered field) must be escaped to + // `\|` so the Markdown table doesn't get split into extra columns. + const body = buildIssueBody({ + severity: "medium", + summary: "summary", + rootCause: null, + suggestedFix: null, + suspectedFiles: null, + route: "GET /api/foo|bar", + sentryIssueId: "fixture-pipe", + apiErrorId: "id", + workflowRunUrl: "https://example.invalid/run/1", + }); + assert.match(body, /GET \/api\/foo\\\|bar/); +}); + +test("buildIssueBody escapes backslashes before pipes (CodeQL: Incomplete string escaping)", () => { + // 入力 `\|` を `\\|` ではなく `\\\\\|` (= リテラル `\\` + escaped `|`) に + // しなければ、Markdown では「リテラル `\` + 生 `|`」と解釈され表が崩れる。 + // バックスラッシュを先にエスケープすることでこの誤解釈を防ぐ。 + // Input `\|` must become `\\\|` (literal backslash + escaped pipe), not + // `\\|` — otherwise Markdown reads `\\` as a literal backslash and the + // bare `|` breaks the table cell. + const body = buildIssueBody({ + severity: "medium", + summary: "summary", + rootCause: null, + suggestedFix: null, + suspectedFiles: null, + route: "weird\\|route", + sentryIssueId: "fixture-bs", + apiErrorId: "id", + workflowRunUrl: "https://example.invalid/run/2", + }); + // ファイル中のリテラル文字列としては `weird\\\\\\|route` (= `weird\\\|route`). + // Literal in JS source: `weird\\\\\\|route` — represents `weird\\\|route`. + assert.match(body, /weird\\\\\\|route/); +}); + +test("buildIssueBody handles missing optional fields without showing 'null'", () => { + const body = buildIssueBody({ + severity: "medium", + summary: "Transient blip.", + rootCause: null, + suggestedFix: null, + suspectedFiles: null, + route: "", + sentryIssueId: "fixture-2", + apiErrorId: "00000000-0000-0000-0000-000000000002", + workflowRunUrl: "https://github.com/otomatty/zedi/actions/runs/124", + }); + assert.ok(!/null/.test(body), "raw 'null' should not appear in the body"); + assert.match(body, /Transient blip\./); +}); + +test("buildRecurrenceCommentBody references the workflow run and severity", () => { + const body = buildRecurrenceCommentBody({ + severity: "high", + summary: "Same migration crash hit again.", + apiErrorId: "00000000-0000-0000-0000-000000000003", + workflowRunUrl: "https://github.com/otomatty/zedi/actions/runs/200", + }); + assert.match(body, /Recurrence detected/i); + assert.match(body, /high/); + assert.match(body, /Same migration crash hit again\./); + assert.match(body, /actions\/runs\/200/); + assert.match(body, /00000000-0000-0000-0000-000000000003/); + assert.ok(!/sentry\.io/.test(body), "Sentry URL must not appear in the recurrence comment"); +}); + +/** + * fetch スタブ。`{ method, url, body }` を順に記録し、登録した応答を返す。 + * Mock fetch that records every call and returns scripted responses by URL+method. + * + * @param {Array<{ match: (url: string, init: { method?: string }) => boolean, status: number, json?: unknown }>} responses + */ +function makeFetchStub(responses) { + const calls = []; + /** + * @param {string} url + * @param {{ method?: string, headers?: Record, body?: string }} [init] + */ + async function fetchImpl(url, init = {}) { + calls.push({ url, method: init.method ?? "GET", body: init.body }); + const match = responses.find((r) => r.match(url, init)); + if (!match) { + throw new Error(`Unexpected fetch call: ${init.method ?? "GET"} ${url}`); + } + return { + ok: match.status >= 200 && match.status < 300, + status: match.status, + async json() { + return match.json ?? {}; + }, + async text() { + return JSON.stringify(match.json ?? {}); + }, + }; + } + return { fetchImpl, calls }; +} + +test("runAutoIssue skips entirely when severity is low", async () => { + const { fetchImpl, calls } = makeFetchStub([]); + const result = await runAutoIssue({ + analysis: { severity: "low", ai_summary: "noop" }, + sentryIssueId: "fixture-low", + apiErrorId: "id", + title: "noop", + route: "", + repository: "otomatty/zedi", + token: "tok", + workflowRunUrl: "https://example.invalid/run/1", + fetchImpl, + }); + assert.deepEqual(result, { action: "skipped", reason: "severity-not-actionable" }); + assert.equal(calls.length, 0, "no GitHub API calls should be made for low severity"); +}); + +test("runAutoIssue skips when severity is unknown", async () => { + const { fetchImpl, calls } = makeFetchStub([]); + const result = await runAutoIssue({ + analysis: { severity: "unknown", ai_summary: "no clue" }, + sentryIssueId: "fixture-unknown", + apiErrorId: "id", + title: "?", + route: "", + repository: "otomatty/zedi", + token: "tok", + workflowRunUrl: "https://example.invalid/run/2", + fetchImpl, + }); + assert.equal(result.action, "skipped"); + assert.equal(calls.length, 0); +}); + +test("runAutoIssue creates a new issue when no existing match is found", async () => { + const responses = [ + // ensure label: monitoring (exists) + { + match: (u, i) => i.method === "GET" && u.endsWith("/labels/monitoring"), + status: 200, + json: { name: "monitoring" }, + }, + // ensure label: auto-reported (missing → create) + { match: (u, i) => i.method === "GET" && u.endsWith("/labels/auto-reported"), status: 404 }, + { + match: (u, i) => i.method === "POST" && u.endsWith("/labels"), + status: 201, + json: { name: "auto-reported" }, + }, + // ensure label: sentry-issue:fixture-create (missing → create) + { match: (u, i) => i.method === "GET" && /\/labels\/sentry-issue/.test(u), status: 404 }, + // search → no existing issue + { match: (u, i) => i.method === "GET" && u.includes("/issues?"), status: 200, json: [] }, + // create issue + { + match: (u, i) => i.method === "POST" && u.endsWith("/issues"), + status: 201, + json: { number: 999, html_url: "https://github.com/otomatty/zedi/issues/999" }, + }, + ]; + const labelCreates = []; + // 2 つ目のラベル作成(sentry-issue:...) のレスポンスを上書き経路として追加。 + // POST /labels の 2 回目(sentry-issue: ラベル作成)は同じ matcher で 2 件目に + // hit させたいので、レスポンス配列を順番消費する形に拡張する。 + let labelPostCount = 0; + /** @type {ReturnType} */ + const stub = makeFetchStub([ + ...responses.filter( + (r) => !(r.match.toString().includes('endsWith("/labels")') && r.status === 201), + ), + ]); + // 上の filter で削った POST /labels を、複数回応答可能なエントリで挿入する。 + // Inject a multi-call POST /labels handler so both `auto-reported` and the + // dynamic `sentry-issue:` label creations succeed. + const originalFetch = stub.fetchImpl; + /** + * @param {string} url + * @param {{ method?: string, body?: string, headers?: Record }} [init] + */ + stub.fetchImpl = async (url, init = {}) => { + if (init.method === "POST" && url.endsWith("/labels")) { + labelPostCount += 1; + labelCreates.push(JSON.parse(init.body ?? "{}")); + return { + ok: true, + status: 201, + async json() { + return { name: labelCreates[labelCreates.length - 1].name }; + }, + async text() { + return "{}"; + }, + }; + } + return originalFetch(url, init); + }; + + const result = await runAutoIssue({ + analysis: { + severity: "high", + ai_summary: "DB migration crash.", + ai_root_cause: "NOT NULL without backfill.", + ai_suggested_fix: "Backfill first.", + ai_suspected_files: [{ path: "server/api/drizzle/0042.sql" }], + }, + sentryIssueId: "fixture-create", + apiErrorId: "00000000-0000-0000-0000-000000000010", + title: "TypeError: Cannot read property 'note_id' of null", + route: "POST /api/pages", + repository: "otomatty/zedi", + token: "tok", + workflowRunUrl: "https://github.com/otomatty/zedi/actions/runs/300", + fetchImpl: stub.fetchImpl, + }); + + assert.equal(result.action, "created"); + assert.equal(result.issueNumber, 999); + // auto-reported と sentry-issue:fixture-create の 2 ラベルを作成しているはず。 + assert.equal(labelPostCount, 2, "both missing labels should be created"); + assert.deepEqual(labelCreates.map((l) => l.name).sort(), [ + "auto-reported", + "sentry-issue:fixture-create", + ]); + // 作成 POST /issues に高 severity のラベル群が乗っているか。 + const createCall = stub.calls.find((c) => c.method === "POST" && c.url.endsWith("/issues")); + assert.ok(createCall, "issue create call should be present"); + const createPayload = JSON.parse(createCall.body ?? "{}"); + assert.deepEqual(createPayload.labels.sort(), [ + "auto-reported", + "monitoring", + "sentry-issue:fixture-create", + ]); + assert.match(createPayload.title, /^\[high\] TypeError/); +}); + +test("runAutoIssue comments on the existing issue when one is already open", async () => { + let labelPostCount = 0; + const responses = [ + { + match: (u, i) => i.method === "GET" && u.endsWith("/labels/monitoring"), + status: 200, + json: { name: "monitoring" }, + }, + { + match: (u, i) => i.method === "GET" && u.endsWith("/labels/auto-reported"), + status: 200, + json: { name: "auto-reported" }, + }, + { + match: (u, i) => i.method === "GET" && /\/labels\/sentry-issue/.test(u), + status: 200, + json: { name: "sentry-issue:fixture-recur" }, + }, + { + match: (u, i) => i.method === "GET" && u.includes("/issues?"), + status: 200, + json: [{ number: 555, html_url: "https://github.com/otomatty/zedi/issues/555" }], + }, + { + match: (u, i) => i.method === "POST" && /\/issues\/555\/comments$/.test(u), + status: 201, + json: { id: 7777, html_url: "https://github.com/otomatty/zedi/issues/555#issuecomment-7777" }, + }, + ]; + const { fetchImpl, calls } = makeFetchStub( + responses.concat([ + // POST /labels が呼ばれてしまった場合は失敗にして、未作成パスを保証する。 + // Trip the test if the orchestrator tries to create labels that already exist. + { + match: (u, i) => { + if (i.method === "POST" && u.endsWith("/labels")) { + labelPostCount += 1; + throw new Error("must not create existing label"); + } + return false; + }, + status: 0, + }, + ]), + ); + + const result = await runAutoIssue({ + analysis: { + severity: "medium", + ai_summary: "Same crash again.", + }, + sentryIssueId: "fixture-recur", + apiErrorId: "00000000-0000-0000-0000-000000000020", + title: "TypeError: ...", + route: "POST /api/pages", + repository: "otomatty/zedi", + token: "tok", + workflowRunUrl: "https://github.com/otomatty/zedi/actions/runs/400", + fetchImpl, + }); + + assert.equal(result.action, "commented"); + assert.equal(result.issueNumber, 555); + assert.equal(labelPostCount, 0, "no labels should be created when all already exist"); + // 検索クエリは sentry-issue: ラベル + state=open。 + const searchCall = calls.find((c) => c.url.includes("/issues?")); + assert.ok(searchCall, "search call should be present"); + assert.match(searchCall.url, /labels=sentry-issue%3Afixture-recur/); + assert.match(searchCall.url, /state=open/); + // コメント本文に「再発」が含まれている。 + const commentCall = calls.find((c) => c.method === "POST" && /\/comments$/.test(c.url)); + assert.ok(commentCall); + const commentBody = JSON.parse(commentCall.body ?? "{}").body; + assert.match(commentBody, /Recurrence detected/i); + assert.match(commentBody, /Same crash again\./); +}); + +test("runAutoIssue picks the lowest-numbered open issue when multiple match (defensive)", async () => { + const responses = [ + { + match: (u, i) => i.method === "GET" && u.endsWith("/labels/monitoring"), + status: 200, + json: {}, + }, + { + match: (u, i) => i.method === "GET" && u.endsWith("/labels/auto-reported"), + status: 200, + json: {}, + }, + { + match: (u, i) => i.method === "GET" && /\/labels\/sentry-issue/.test(u), + status: 200, + json: {}, + }, + { + match: (u, i) => i.method === "GET" && u.includes("/issues?"), + status: 200, + // 故意に降順で返す。実装側は number 昇順で最古を選ぶこと。 + json: [ + { number: 900, html_url: "https://github.com/otomatty/zedi/issues/900" }, + { number: 100, html_url: "https://github.com/otomatty/zedi/issues/100" }, + ], + }, + { + match: (u, i) => i.method === "POST" && /\/issues\/100\/comments$/.test(u), + status: 201, + json: { id: 1, html_url: "x" }, + }, + ]; + const { fetchImpl } = makeFetchStub(responses); + + const result = await runAutoIssue({ + analysis: { severity: "high", ai_summary: "..." }, + sentryIssueId: "fixture-multi", + apiErrorId: "id", + title: "t", + route: "", + repository: "otomatty/zedi", + token: "tok", + workflowRunUrl: "https://example.invalid/run/x", + fetchImpl, + }); + + assert.equal(result.action, "commented"); + assert.equal(result.issueNumber, 100); +}); + +test("ensureLabel tolerates 422 from POST /labels (race when another run created the label)", async () => { + // GET → 404 (未存在), POST → 422 (別 run が直前に作成) のシナリオ。 + // throw せず "existing" を返すこと。 + // GET → 404 (missing), POST → 422 (created by a concurrent run between our + // GET and POST). The function should not throw and should report + // "existing" so the caller treats it as success. + const responses = [ + { + match: (u, i) => i.method === "GET" && u.endsWith("/labels/auto-reported"), + status: 404, + }, + { match: (u, i) => i.method === "POST" && u.endsWith("/labels"), status: 422 }, + ]; + const { fetchImpl } = makeFetchStub(responses); + const outcome = await ensureLabel({ + owner: "otomatty", + repo: "zedi", + label: "auto-reported", + token: "tok", + fetchImpl, + logger: { log: () => {} }, + }); + assert.equal(outcome, "existing"); +}); + +test("ensureLabel returns 'existing' immediately when GET succeeds (no POST issued)", async () => { + let postCount = 0; + /** + * @param {string} url + * @param {{ method?: string }} init + */ + async function fetchImpl(url, init = {}) { + if (init.method === "GET") { + return { + ok: true, + status: 200, + async json() { + return { name: "monitoring" }; + }, + async text() { + return "{}"; + }, + }; + } + postCount += 1; + throw new Error("POST should not be called"); + } + const outcome = await ensureLabel({ + owner: "otomatty", + repo: "zedi", + label: "monitoring", + token: "tok", + fetchImpl, + logger: { log: () => {} }, + }); + assert.equal(outcome, "existing"); + assert.equal(postCount, 0); +}); diff --git a/.github/actions/claude-analyze/__tests__/fixtures/invalid-bad-severity.json b/.github/actions/claude-analyze/__tests__/fixtures/invalid-bad-severity.json new file mode 100644 index 00000000..f1dfaeaa --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/fixtures/invalid-bad-severity.json @@ -0,0 +1,4 @@ +{ + "severity": "critical", + "ai_summary": "Severity is not one of the allowed enum values." +} diff --git a/.github/actions/claude-analyze/__tests__/fixtures/invalid-missing-summary.json b/.github/actions/claude-analyze/__tests__/fixtures/invalid-missing-summary.json new file mode 100644 index 00000000..52abb617 --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/fixtures/invalid-missing-summary.json @@ -0,0 +1,4 @@ +{ + "severity": "medium", + "ai_root_cause": "ai_summary is missing" +} diff --git a/.github/actions/claude-analyze/__tests__/fixtures/invalid-suspected-file.json b/.github/actions/claude-analyze/__tests__/fixtures/invalid-suspected-file.json new file mode 100644 index 00000000..0739fdf5 --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/fixtures/invalid-suspected-file.json @@ -0,0 +1,9 @@ +{ + "severity": "high", + "ai_summary": "Suspected files entry is missing the required path.", + "ai_suspected_files": [ + { + "reason": "no path means this entry is invalid" + } + ] +} diff --git a/.github/actions/claude-analyze/__tests__/fixtures/invalid-too-many-files.json b/.github/actions/claude-analyze/__tests__/fixtures/invalid-too-many-files.json new file mode 100644 index 00000000..267d8e66 --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/fixtures/invalid-too-many-files.json @@ -0,0 +1,12 @@ +{ + "severity": "high", + "ai_summary": "Claude returned 6 suspected files, exceeding the documented 5-entry cap.", + "ai_suspected_files": [ + { "path": "src/a.ts" }, + { "path": "src/b.ts" }, + { "path": "src/c.ts" }, + { "path": "src/d.ts" }, + { "path": "src/e.ts" }, + { "path": "src/f.ts" } + ] +} diff --git a/.github/actions/claude-analyze/__tests__/fixtures/valid-high.json b/.github/actions/claude-analyze/__tests__/fixtures/valid-high.json new file mode 100644 index 00000000..227d500e --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/fixtures/valid-high.json @@ -0,0 +1,17 @@ +{ + "severity": "high", + "ai_summary": "Database migration failed mid-flight, leaving rows with NULL note_id and breaking page lookups.", + "ai_root_cause": "Migration 0042 added a NOT NULL constraint without backfilling. Existing rows pre-dating the migration have NULL and the SELECT path crashes.", + "ai_suggested_fix": "Backfill note_id from pages.owner_id where NULL, then re-apply the NOT NULL constraint in a follow-up migration.", + "ai_suspected_files": [ + { + "path": "server/api/drizzle/0042_add_note_id.sql", + "reason": "Introduced the NOT NULL constraint without a backfill step.", + "line": 12 + }, + { + "path": "server/api/src/services/pageService.ts", + "reason": "SELECT path that throws when note_id is NULL." + } + ] +} diff --git a/.github/actions/claude-analyze/__tests__/fixtures/valid-low-nulls.json b/.github/actions/claude-analyze/__tests__/fixtures/valid-low-nulls.json new file mode 100644 index 00000000..f0060c45 --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/fixtures/valid-low-nulls.json @@ -0,0 +1,7 @@ +{ + "severity": "low", + "ai_summary": "Transient network blip while contacting the third-party clipper service. Retried automatically.", + "ai_root_cause": null, + "ai_suggested_fix": null, + "ai_suspected_files": null +} diff --git a/.github/actions/claude-analyze/__tests__/schema.test.mjs b/.github/actions/claude-analyze/__tests__/schema.test.mjs new file mode 100644 index 00000000..d533814d --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/schema.test.mjs @@ -0,0 +1,176 @@ +/** + * Fixture-driven tests for the Claude analysis output schema. Issue #806. + * + * 実行方法 / How to run: + * `node --test .github/actions/claude-analyze/__tests__/schema.test.mjs` + * + * vitest を新たに追加するのは workspace の test:run が肥大化するので、 + * Node 24 の組み込みテストランナーを使う。CI への組み込みは README 参照。 + * + * Uses Node 24's built-in test runner instead of adding a new vitest workspace + * — keeps the action self-contained and avoids touching the monorepo's + * `test:run` aggregator. CI wiring guidance lives in the action README. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + analysisOutputSchema, + parseAndValidate, + SEVERITIES, + suspectedFileSchema, +} from "../schema.mjs"; + +const FIXTURES = path.join(import.meta.dirname, "fixtures"); + +/** + * @param {string} name + * @returns {Promise} + */ +async function loadFixtureRaw(name) { + return readFile(path.join(FIXTURES, name), "utf8"); +} + +test("SEVERITIES matches the server-side ApiErrorSeverity enum", () => { + assert.deepEqual([...SEVERITIES], ["high", "medium", "low", "unknown"]); +}); + +test("valid-high.json passes the schema and round-trips through parseAndValidate", async () => { + const raw = await loadFixtureRaw("valid-high.json"); + const parsed = JSON.parse(raw); + assert.equal(analysisOutputSchema.safeParse(parsed).success, true); + const validated = parseAndValidate(raw); + assert.equal(validated.severity, "high"); + assert.equal(Array.isArray(validated.ai_suspected_files), true); + assert.equal(validated.ai_suspected_files?.length, 2); + assert.equal(validated.ai_suspected_files?.[0]?.path.includes("0042_add_note_id"), true); +}); + +test("valid-low-nulls.json accepts explicit nulls for optional fields", async () => { + const raw = await loadFixtureRaw("valid-low-nulls.json"); + const validated = parseAndValidate(raw); + assert.equal(validated.severity, "low"); + assert.equal(validated.ai_root_cause, null); + assert.equal(validated.ai_suggested_fix, null); + assert.equal(validated.ai_suspected_files, null); +}); + +test("invalid-bad-severity.json is rejected with a severity-mention message", async () => { + const raw = await loadFixtureRaw("invalid-bad-severity.json"); + assert.throws(() => parseAndValidate(raw), /severity/i); +}); + +test("invalid-missing-summary.json is rejected when ai_summary is absent", async () => { + const raw = await loadFixtureRaw("invalid-missing-summary.json"); + assert.throws(() => parseAndValidate(raw), /ai_summary/); +}); + +test("invalid-suspected-file.json is rejected when an entry has no path", async () => { + const raw = await loadFixtureRaw("invalid-suspected-file.json"); + assert.throws(() => parseAndValidate(raw), /path/); +}); + +test("invalid-too-many-files.json is rejected when ai_suspected_files exceeds 5 entries", async () => { + const raw = await loadFixtureRaw("invalid-too-many-files.json"); + assert.throws(() => parseAndValidate(raw), /ai_suspected_files.*5|at most 5/); +}); + +test("parseAndValidate strips Claude's ```json``` fence and prose preamble", () => { + const wrapped = [ + "Sure, here is the analysis:", + "```json", + JSON.stringify({ + severity: "medium", + ai_summary: "wrapped in fence", + ai_root_cause: null, + ai_suggested_fix: null, + ai_suspected_files: null, + }), + "```", + ].join("\n"); + const validated = parseAndValidate(wrapped); + assert.equal(validated.severity, "medium"); + assert.equal(validated.ai_summary, "wrapped in fence"); +}); + +test("parseAndValidate does not strip a plain ``` fenced block without the json label", () => { + const payload = { + severity: "low", + ai_summary: "plain fence skipped", + ai_root_cause: null, + ai_suggested_fix: null, + ai_suspected_files: null, + }; + const wrapped = [ + "Here is a code sample:", + "```", + "not json", + "```", + "", + JSON.stringify(payload), + ].join("\n"); + const validated = parseAndValidate(wrapped); + assert.equal(validated.severity, "low"); + assert.equal(validated.ai_summary, "plain fence skipped"); +}); + +test("parseAndValidate throws when no JSON object is present", () => { + assert.throws(() => parseAndValidate("nope, no braces here"), /valid analysis JSON/i); +}); + +test("parseAndValidate finds schema-valid JSON when prose contains stray braces", () => { + const payload = { + severity: "low", + ai_summary: "brace noise tolerated", + ai_root_cause: null, + ai_suggested_fix: null, + ai_suspected_files: null, + }; + const raw = `Here is { "not": "the payload" } the real payload:\n${JSON.stringify(payload)}`; + const validated = parseAndValidate(raw); + assert.equal(validated.severity, "low"); +}); + +test("parseAndValidate fails fast when root-shaped JSON has invalid types (no later-object fallback)", () => { + const badRoot = { + severity: 999, + ai_summary: "severity has wrong type", + ai_root_cause: null, + ai_suggested_fix: null, + ai_suspected_files: null, + }; + const laterObject = { + severity: "low", + ai_summary: "would be wrongly picked without root-intent detection", + ai_root_cause: null, + ai_suggested_fix: null, + ai_suspected_files: null, + }; + const raw = `${JSON.stringify(badRoot)}\nTrailing prose ${JSON.stringify(laterObject)}`; + assert.throws(() => parseAndValidate(raw), /schema validation/i); +}); + +test("parseAndValidate throws on empty input", () => { + assert.throws(() => parseAndValidate(""), /empty/); +}); + +test("suspectedFileSchema requires a non-empty path", () => { + assert.equal(suspectedFileSchema.safeParse({ path: "" }).success, false); + assert.equal(suspectedFileSchema.safeParse({ path: "src/foo.ts" }).success, true); +}); + +test("suspectedFileSchema rejects non-integer line numbers", () => { + assert.equal(suspectedFileSchema.safeParse({ path: "src/foo.ts", line: 12.5 }).success, false); + assert.equal(suspectedFileSchema.safeParse({ path: "src/foo.ts", line: 12 }).success, true); +}); + +test("analysisOutputSchema rejects unknown top-level keys (strict mode)", () => { + const bad = { + severity: "low", + ai_summary: "ok", + extra_field: "should not be here", + }; + assert.equal(analysisOutputSchema.safeParse(bad).success, false); +}); diff --git a/.github/actions/claude-analyze/action.yml b/.github/actions/claude-analyze/action.yml new file mode 100644 index 00000000..e5c013ef --- /dev/null +++ b/.github/actions/claude-analyze/action.yml @@ -0,0 +1,194 @@ +# `.github/actions/claude-analyze` — Composite action that runs the Claude +# AI error analysis script and PUTs the validated result back to the API +# callback endpoint. Epic #616 Phase 2 / issue #806. +# +# This action is invoked by `.github/workflows/analyze-error.yml` on +# `repository_dispatch` (`event_type: analyze-error`). See README.md for the +# full client_payload contract and a workflow_dispatch dry-run recipe. +name: Claude analyze API error +description: > + Analyze a Sentry-reported API error with Claude and PUT the structured + result back to the Zedi API callback endpoint. / Sentry が検知した API エラーを + Claude で解析し、構造化結果を Zedi API のコールバックへ書き戻す。 + +inputs: + api_error_id: + description: "`api_errors.id` (UUID) for the row to update." + required: true + sentry_issue_id: + description: Sentry issue id from the dispatch client_payload. + required: true + title: + description: Short error title from Sentry. + required: true + route: + description: Route where the error fired (may be empty). + required: false + default: "" + callback_base_url: + description: > + Base URL for the API callback (e.g. https://api.example.com). + The action appends `/api/webhooks/github/ai-result/`. + required: true + installation_token: + description: GitHub App installation access token used as the bearer for the callback PUT. + required: true + anthropic_api_key: + description: Anthropic API key for the Claude call. + required: true + model: + description: Override Claude model id (defaults to claude-sonnet-4-6). + required: false + default: "" + dry_run: + description: When "true", skip the Anthropic call and emit a stub payload (for workflow_dispatch testing). + required: false + default: "false" + skip_callback: + description: When "true", validate locally but do not PUT to the API (pairs with dry_run for fixture validation). + required: false + default: "false" + skip_issue: + description: > + When "true", skip the auto-issue (create / comment) step. Pairs with + `dry_run` / `skip_callback` for fixture validation. Epic #616 Phase 3. + required: false + default: "false" + +outputs: + severity: + description: AI-assigned severity (high | medium | low | unknown). + value: ${{ steps.analyze.outputs.severity }} + output_path: + description: Path to the validated analysis JSON written by the script. + value: ${{ steps.analyze.outputs.output_path }} + issue_action: + description: > + Outcome of the auto-issue step: `skipped`, `created`, or `commented`. + Empty when `skip_issue=true`. Epic #616 Phase 3 / Issue #808. + value: ${{ steps.auto-issue.outputs.action }} + issue_number: + description: GitHub Issue number created or commented on (empty when skipped). + value: ${{ steps.auto-issue.outputs.issue_number }} + issue_html_url: + description: HTML URL of the created Issue or recurrence comment (empty when skipped). + value: ${{ steps.auto-issue.outputs.issue_html_url }} + +runs: + using: composite + steps: + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3" + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile + + - name: Run analyze script + id: analyze + shell: bash + env: + CLAUDE_ANALYZE_API_ERROR_ID: ${{ inputs.api_error_id }} + CLAUDE_ANALYZE_SENTRY_ISSUE_ID: ${{ inputs.sentry_issue_id }} + CLAUDE_ANALYZE_TITLE: ${{ inputs.title }} + CLAUDE_ANALYZE_ROUTE: ${{ inputs.route }} + CLAUDE_ANALYZE_REPOSITORY: ${{ github.repository }} + ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} + CLAUDE_MODEL: ${{ inputs.model }} + CLAUDE_ANALYZE_DRY_RUN: ${{ inputs.dry_run }} + CLAUDE_ANALYZE_OUTPUT: ${{ runner.temp }}/analyze-output.json + run: | + node "${{ github.action_path }}/analyze.mjs" + echo "output_path=${CLAUDE_ANALYZE_OUTPUT}" >> "$GITHUB_OUTPUT" + # severity を outputs に拾う。Node スクリプトに依存させず、jq で抜き出す。 + # Pull severity into outputs via jq — keeps the script's only contract + # the JSON file, no out-of-band stdout protocol to maintain. + SEVERITY=$(jq -r '.severity' "${CLAUDE_ANALYZE_OUTPUT}") + echo "severity=${SEVERITY}" >> "$GITHUB_OUTPUT" + echo "::notice title=AI severity::${SEVERITY}" + + - name: PUT analysis to API callback + if: inputs.skip_callback != 'true' + shell: bash + env: + CALLBACK_URL: ${{ inputs.callback_base_url }}/api/webhooks/github/ai-result/${{ inputs.api_error_id }} + INSTALLATION_TOKEN: ${{ inputs.installation_token }} + OUTPUT_PATH: ${{ steps.analyze.outputs.output_path }} + run: | + set -euo pipefail + # API のコールバックに PUT。失敗時は最大 2 回までリトライ(issue #806 の + # 「workflow 内で 1〜2 回まで」要件)。HTTP ステータスを判定し、 + # 5xx / ネットワークエラーのみリトライ、4xx は即失敗(auth 不正など)。 + # + # PUT to the API callback. Retry up to 2 attempts (issue #806's "1〜2 回 + # まで"). Only retry on transient 5xx / network errors; 4xx (auth, bad + # payload) is final so misconfiguration surfaces immediately. + attempt=1 + max_attempts=2 + while true; do + echo "PUT ${CALLBACK_URL} (attempt ${attempt}/${max_attempts})" + # `--connect-timeout` で TCP 接続待ちを 10 秒、`--max-time` でリクエスト + # 全体を 30 秒に制限する。ハングした session が retry ループの背圧になる + # のを防ぐ(Job timeout の 10 分を一発で食い潰すのを避ける目的)。 + # `--connect-timeout` caps TCP connect at 10 s; `--max-time` caps the + # total request at 30 s. Without these, a hung session would block the + # retry loop and could burn the entire 10-minute job timeout on a + # single PUT. + http_status=$(curl -sS -o /tmp/callback-response.txt -w "%{http_code}" \ + -X PUT "${CALLBACK_URL}" \ + -H "Authorization: Bearer ${INSTALLATION_TOKEN}" \ + -H "Content-Type: application/json" \ + --connect-timeout 10 \ + --max-time 30 \ + --data-binary "@${OUTPUT_PATH}" || echo "000") + echo "HTTP ${http_status}" + if [ "${http_status}" -ge 200 ] && [ "${http_status}" -lt 300 ]; then + cat /tmp/callback-response.txt + echo + echo "Callback succeeded." + break + fi + if [ "${http_status}" -ge 400 ] && [ "${http_status}" -lt 500 ]; then + CALLBACK_ERR_BODY="$(node "${{ github.action_path }}/escapeGithubAnnotation.mjs" /tmp/callback-response.txt)" + echo "::error title=Callback rejected (${http_status})::${CALLBACK_ERR_BODY}" + exit 1 + fi + if [ "${attempt}" -ge "${max_attempts}" ]; then + CALLBACK_ERR_BODY="$(node "${{ github.action_path }}/escapeGithubAnnotation.mjs" /tmp/callback-response.txt)" + echo "::error title=Callback failed after ${max_attempts} attempts (${http_status})::${CALLBACK_ERR_BODY}" + exit 1 + fi + attempt=$((attempt + 1)) + sleep 5 + done + + - name: Auto-file or comment GitHub Issue + # Epic #616 Phase 3 / Issue #808: severity が high / medium のときだけ + # Issue を起票し、同一 sentry_issue_id に既存オープン Issue があれば + # コメント追記で再発を表現する(連続 100 回でも 1 件に集約)。`skip_issue` + # は `workflow_dispatch` のドライラン用フラグ。 + # + # Files a GitHub Issue when AI severity is high / medium, or appends a + # recurrence comment when an open Issue with the matching + # `sentry-issue:` label already exists. Honors `skip_issue` so + # workflow_dispatch dry runs cannot accidentally create Issues. + if: inputs.skip_issue != 'true' + id: auto-issue + shell: bash + env: + AUTO_ISSUE_OUTPUT_PATH: ${{ steps.analyze.outputs.output_path }} + AUTO_ISSUE_SENTRY_ISSUE_ID: ${{ inputs.sentry_issue_id }} + AUTO_ISSUE_API_ERROR_ID: ${{ inputs.api_error_id }} + AUTO_ISSUE_TITLE: ${{ inputs.title }} + AUTO_ISSUE_ROUTE: ${{ inputs.route }} + AUTO_ISSUE_REPOSITORY: ${{ github.repository }} + AUTO_ISSUE_TOKEN: ${{ inputs.installation_token }} + AUTO_ISSUE_WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node "${{ github.action_path }}/autoIssueRunner.mjs" diff --git a/.github/actions/claude-analyze/analyze.mjs b/.github/actions/claude-analyze/analyze.mjs new file mode 100644 index 00000000..94e3aeb2 --- /dev/null +++ b/.github/actions/claude-analyze/analyze.mjs @@ -0,0 +1,478 @@ +#!/usr/bin/env node +/** + * Claude による API エラー解析エントリポイント。Epic #616 Phase 2 / Issue #806。 + * + * GitHub Actions の `repository_dispatch` (`event_type: analyze-error`) で + * 起動され、以下を実行する: + * + * 1. `client_payload`(`api_error_id`, `sentry_issue_id`, `title`, `route`) + * を環境変数から受け取る。 + * 2. `title` / `route` から推定キーワードを生成し、リポジトリ内を grep して + * 関連しそうなファイル抜粋を集める(プロンプトのコンテキスト化)。 + * 3. Anthropic SDK で Claude を呼び、`prompt.md` のテンプレートを埋めた + * 指示で構造化 JSON を返させる(最大 2 回までリトライ)。 + * 4. Zod スキーマ (`schema.mjs`) で出力を検証し、JSON ファイルへ書き出す。 + * + * Entry point for the Claude AI error-analysis step (Epic #616 Phase 2 / + * issue #806). Invoked from `action.yml` and ultimately from the + * `analyze-error.yml` workflow on `repository_dispatch`. Reads the dispatch + * `client_payload` via env, gathers light repo context, asks Claude for a + * structured JSON analysis, validates it with Zod, and writes the result to + * an output file. The HTTP `PUT` back to the API is performed by a later + * workflow step using the GitHub App installation token — this script never + * touches the network for the API callback to keep responsibilities split. + * + * 失敗時は非 0 で終了する(API には書き戻さない)。Epic #616 の方針通り、 + * 失敗してもユーザーリクエストには影響しない(fire-and-forget)。 + * + * Exits non-zero on failure so the workflow step turns red without writing a + * partial result. Per Epic #616, an analyze failure must not affect end-user + * requests; the Sentry webhook fires this dispatch with `.catch(log)` upstream. + */ +import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; +import Anthropic from "@anthropic-ai/sdk"; +import { parseAndValidate } from "./schema.mjs"; + +// `import.meta.dirname` は Node 20.11+ で利用可能。`new URL(import.meta.url).pathname` +// 経由よりも Windows 互換が良い(`/C:/...` 問題を踏まない)。 +// `import.meta.dirname` (Node 20.11+) is preferred over deriving the path from +// `import.meta.url` because it does not produce broken `/C:/...` paths on +// Windows. CI runs on Linux but the script is also exercised locally. +const HERE = import.meta.dirname; + +/** + * Claude モデル ID。最新の Sonnet 4.6 を既定にする。`CLAUDE_MODEL` 環境変数で + * 上書き可能(コスト調整 / モデル切替用)。 + * + * Default Claude model. Sonnet 4.6 balances cost and analysis quality for the + * per-error workload. Override via `CLAUDE_MODEL` env when tuning. + */ +const DEFAULT_MODEL = "claude-sonnet-4-6"; + +/** + * Anthropic API リトライ回数。issue #806 の「workflow 内で 1〜2 回まで」要件に + * 合わせて最大 2 試行(初回 + 1 回リトライ)。 + * + * Maximum Anthropic API attempts. Issue #806 specifies "1〜2 回まで" — so we + * allow one retry on top of the initial call (2 attempts total). + */ +const MAX_ATTEMPTS = 2; + +/** + * リトライ間の待機時間(ms)。固定 5 秒(指数バックオフは不要 — 試行回数が少ない)。 + * Backoff between attempts. Fixed 5 s — exponential backoff is overkill for the + * 2-attempt cap. + */ +const RETRY_DELAY_MS = 5_000; + +/** + * grep でリポジトリから抜粋する候補ファイルの最大数。プロンプトが肥大化して + * Claude のコンテキスト上限・コスト・レイテンシに跳ねないように上限を入れる。 + * + * Cap on grep-matched files included in the prompt. Prevents the prompt from + * ballooning past Claude's context window and keeps per-call cost predictable. + */ +const MAX_EXCERPT_FILES = 6; + +/** + * 1 ファイルあたりの抜粋上限(行数)。先頭からこの行数だけ含める。 + * Per-file excerpt cap (lines). We grab the head of each candidate file rather + * than full content to keep prompts bounded. + */ +const MAX_LINES_PER_FILE = 80; + +/** + * 出力 JSON が空欄しか含まなくても、`severity` と `ai_summary` が成立すれば + * `parseAndValidate` は通る(Zod 側がそうなっているので)。 + * フォールバック severity(Anthropic 呼び出し失敗時に書き戻したい場合用)。 + * + * Fallback severity used by the workflow if it ever needs to record an + * "analysis failed" placeholder. Currently unused — exported for callers that + * want to compose a degraded record without re-deriving the enum. + */ +export const FALLBACK_SEVERITY = "unknown"; + +/** + * 必須環境変数を読み出して dispatch payload に整形する。欠けていたら throw。 + * Read required env vars and assemble them into a normalized payload. Throws + * with a precise message identifying the missing variable so workflow logs + * point at the misconfiguration immediately. + * + * @returns {{ + * apiErrorId: string, + * sentryIssueId: string, + * title: string, + * route: string, + * repository: string, + * anthropicApiKey: string, + * model: string, + * outputPath: string, + * workspace: string, + * dryRun: boolean + * }} + */ +function readEnv() { + const must = (name) => { + const v = process.env[name]?.trim(); + if (!v) throw new Error(`required env var ${name} is missing`); + return v; + }; + // dryRun を先に決める。Anthropic 呼び出しを行わないドライラン経路では + // `ANTHROPIC_API_KEY` が未設定でも動作するようにし、secrets が未配備の fork や + // 検証用環境でも `workflow_dispatch` でパイプラインを通せるようにする。 + // + // Resolve `dryRun` first so the dry-run path tolerates a missing + // `ANTHROPIC_API_KEY`. Fork PRs and pre-secrets-rollout environments rely + // on this to exercise the analyze step end-to-end without the API key. + const dryRun = /^(1|true|yes)$/i.test(process.env.CLAUDE_ANALYZE_DRY_RUN?.trim() ?? ""); + return { + apiErrorId: must("CLAUDE_ANALYZE_API_ERROR_ID"), + sentryIssueId: must("CLAUDE_ANALYZE_SENTRY_ISSUE_ID"), + title: must("CLAUDE_ANALYZE_TITLE"), + // route は API 側でも null を許容しているので空文字を許す。 + // route is nullable on the server side, so we permit empty string here. + route: process.env.CLAUDE_ANALYZE_ROUTE?.trim() ?? "", + repository: must("CLAUDE_ANALYZE_REPOSITORY"), + anthropicApiKey: dryRun + ? (process.env.ANTHROPIC_API_KEY?.trim() ?? "") + : must("ANTHROPIC_API_KEY"), + model: process.env.CLAUDE_MODEL?.trim() || DEFAULT_MODEL, + outputPath: must("CLAUDE_ANALYZE_OUTPUT"), + workspace: process.env.GITHUB_WORKSPACE?.trim() || process.cwd(), + dryRun, + }; +} + +/** + * `title` / `route` から検索キーワードを抽出する。短すぎる語 (< 4 文字)、 + * よくある語 (`error`, `failed` など)、HTTP メソッドは除外。重複も除く。 + * Sentry のタイトルに日本語などの非 ASCII 文字が含まれても切り捨てないよう、 + * Unicode プロパティ(`\p{L}` 文字 / `\p{N}` 数字)で語境界を判定する。 + * + * Extract searchable tokens from `title` / `route`. Filters short words + * (< 4 chars), common error vocabulary, and HTTP verbs so grep lands on + * symbols/paths actually present in the codebase rather than every file + * containing the word "error". Splits on Unicode property classes so that + * non-ASCII titles (Japanese error messages, identifiers with accented + * characters, …) keep their tokens instead of getting stripped to empty. + * + * @param {string} title + * @param {string} route + * @returns {string[]} + */ +export function deriveKeywords(title, route) { + const stop = new Set([ + "error", + "failed", + "failure", + "exception", + "warning", + "post", + "get", + "put", + "delete", + "patch", + "head", + "null", + "undefined", + "true", + "false", + "from", + "with", + "this", + "that", + "into", + ]); + const tokens = `${title} ${route}` + .split(/[^\p{L}\p{N}_/.\-]+/u) + .map((t) => t.trim()) + .filter((t) => t.length >= 4 && !stop.has(t.toLowerCase())); + return Array.from(new Set(tokens)).slice(0, 8); +} + +/** + * `git grep` をワークスペース内で実行し、ヒットしたファイル名のユニーク集合を返す。 + * `git` が利用できない / リポジトリでない場合は空配列。`-l` でファイル名のみ取得し、 + * `-n` の行番号は使わない(後段で先頭抜粋に切り替えるため)。 + * + * Run `git grep -l` for each keyword and union the matching file paths. Returns + * an empty array if `git` is unavailable or the workspace is not a repo. Uses + * `-l` (filename-only) instead of `-n` because we'll grab the file head as + * excerpt rather than the precise hit line — keeps the prompt deterministic. + * + * @param {string[]} keywords + * @param {string} workspace + * @returns {string[]} + */ +export function grepCandidateFiles(keywords, workspace) { + if (keywords.length === 0) return []; + // 全キーワードをまとめて 1 回の `git grep -e KW1 -e KW2 ...` で検索する。 + // 個別呼び出しに比べてプロセス起動コストを N→1 に削減できる(OR 検索なので + // ファイル名集合の和は変わらない)。 + // + // Run a single `git grep` with `-e` for each keyword instead of spawning N + // processes. `git grep` with multiple `-e` flags performs an OR search, so + // the resulting filename set is identical to the previous loop's union but + // avoids per-keyword process startup overhead. + const patternFlags = keywords.flatMap((kw) => ["-e", kw]); + const res = spawnSync( + "git", + [ + "grep", + // `-F` でリテラルマッチに固定する。Sentry の title には `.` や `(` 等の + // 正規表現メタ文字が混じり得るため、デフォルトの正規表現マッチだと + // 関係ないファイルまで広く拾ってしまう。 + // Force literal (fixed-string) matching with `-F`. Sentry titles often + // contain regex metacharacters (`.`, `(`, …) which would otherwise + // broaden the search and dilute the candidate-file list. + "-F", + "-l", + ...patternFlags, + "--", + // 巨大な lockfile / 生成物 / バイナリは検索対象外。 + // Skip lockfiles, build outputs, and binaries. + ":!*.lock", + ":!*lock.json", + ":!dist/**", + ":!**/dist/**", + ":!node_modules/**", + ":!**/node_modules/**", + ":!**/*.png", + ":!**/*.jpg", + ":!**/*.svg", + ":!**/*.pdf", + ], + { cwd: workspace, encoding: "utf8", timeout: 15_000 }, + ); + /** @type {Set} */ + const hits = new Set(); + if (res.status === 0 && typeof res.stdout === "string") { + for (const line of res.stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed) hits.add(trimmed); + } + } + // 候補が多すぎるとプロンプトが肥大化するので、source パスらしいものを優先する。 + // Rank: prefer real source files; deprioritize tests, docs, snapshots. + const ranked = Array.from(hits).sort((a, b) => rankPath(a) - rankPath(b)); + return ranked.slice(0, MAX_EXCERPT_FILES); +} + +/** + * ソースっぽいパスほど低いスコアを返してソート上位に来るようにする。 + * Lower score = higher priority. Tests / docs / snapshots are deprioritized so + * the AI sees implementation files first when the prompt budget is tight. + * + * @param {string} p + * @returns {number} + */ +function rankPath(p) { + if (/(?:^|\/)__tests__\//.test(p)) return 5; + if (/\.test\.|\.spec\./.test(p)) return 5; + if (/\.snap$/.test(p)) return 9; + if (/(?:^|\/)docs?\//i.test(p)) return 4; + if (/\.md$/i.test(p)) return 3; + if (/^server\/api\/src\//.test(p)) return 0; + if (/^src\//.test(p)) return 1; + return 2; +} + +/** + * 候補ファイルを先頭 N 行だけ読み込んでプロンプト用テキストブロックにまとめる。 + * 読めないファイルは黙ってスキップする(生成物・バイナリ等)。 + * + * Read the first N lines of each candidate file and assemble them into a + * prompt-ready text block. Silently skips files that fail to read so a single + * unreadable artefact never aborts the whole analysis. + * + * @param {string[]} files + * @param {string} workspace + * @returns {Promise} + */ +export async function buildExcerpts(files, workspace) { + if (files.length === 0) return "(no candidate files matched the keyword search)"; + const blocks = []; + for (const rel of files) { + const abs = path.join(workspace, rel); + if (!existsSync(abs)) continue; + try { + const content = await readFile(abs, "utf8"); + const head = content.split("\n").slice(0, MAX_LINES_PER_FILE).join("\n"); + blocks.push(`### ${rel}\n\n\`\`\`\n${head}\n\`\`\`\n`); + } catch { + // unreadable / binary — skip silently + } + } + return blocks.length > 0 ? blocks.join("\n") : "(candidate files matched but were unreadable)"; +} + +/** + * `prompt.md` を読み込んで `{{key}}` プレースホルダを置換する。 + * Load `prompt.md` and substitute `{{key}}` placeholders. Unknown placeholders + * are left intact so a typo surfaces visibly in the rendered prompt rather + * than silently emitting an empty string. + * + * @param {Record} vars + * @returns {Promise} + */ +export async function renderPrompt(vars) { + const tmpl = await readFile(path.join(HERE, "prompt.md"), "utf8"); + return tmpl.replace(/\{\{(\w+)\}\}/g, (_, key) => + Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : `{{${key}}}`, + ); +} + +/** + * Anthropic API を呼び、Claude が返したテキストを返す。失敗時はリトライする。 + * Call the Anthropic API with retry. Surfaces the final error after + * `MAX_ATTEMPTS` so workflow logs reflect the actual upstream failure rather + * than a generic "no response" message. + * + * @param {Anthropic} client + * @param {string} model + * @param {string} prompt + * @returns {Promise} + */ +async function callClaudeWithRetry(client, model, prompt) { + /** @type {unknown} */ + let lastErr = null; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const response = await client.messages.create({ + model, + // 解析結果の JSON は数 KB を超えないので 2048 で十分。Claude の出力上限は + // 別途モデル側で決まるが、ここは「上限ヒットして截ち切られない」目的の値。 + // The analysis JSON tops out at a few KB; 2048 is comfortably above the + // expected ceiling and prevents truncation. + max_tokens: 2048, + messages: [{ role: "user", content: prompt }], + }); + // text ブロックを連結して返す(tool use は使っていない)。 + // Concatenate text blocks; we don't use tool_use here. + const text = response.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n") + .trim(); + if (!text) { + throw new Error("Claude returned an empty response"); + } + return text; + } catch (err) { + lastErr = err; + const msg = err instanceof Error ? err.message : String(err); + console.error(`[claude-analyze] attempt ${attempt}/${MAX_ATTEMPTS} failed: ${msg}`); + if (attempt < MAX_ATTEMPTS) { + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + } + } + throw lastErr instanceof Error ? lastErr : new Error("Claude call failed"); +} + +/** + * メイン処理。env を読み、context を集め、Claude に問い、結果を JSON ファイルに書く。 + * Orchestration entry point: read env, gather context, ask Claude, validate, + * write file. Any throw bubbles up to the top-level handler at the bottom of + * this module which logs and exits 1. + * + * @returns {Promise} + */ +export async function main() { + const env = readEnv(); + // 起動時ログでは title / route の生値を出さない。Sentry の data scrubbing が + // 一次防御だが、CI ログは別保管面なので二段防御として長さだけを残す。 + // api_error_id / sentry_issue_id はそれぞれ DB の id / Sentry 内の id で機密性が + // 低いのでそのまま出して相関を取れるようにする。 + // + // Avoid logging raw `title` / `route` at startup. Sentry's data scrubbing is + // the primary defense, but CI logs are a separate retention plane, so we + // emit metadata only here as a second line of defense. `api_error_id` and + // `sentry_issue_id` are bare ids (no PII) and stay verbatim so log lines + // can be cross-referenced with the admin UI / Sentry. + console.log( + `[claude-analyze] api_error_id=${env.apiErrorId} sentry_issue_id=${env.sentryIssueId} title_len=${env.title.length} route_present=${env.route.length > 0}`, + ); + + const keywords = deriveKeywords(env.title, env.route); + // keywords も title/route 由来なので個別の文字列は出さず件数だけ残す。 + // Keywords are derived from title/route, so log only the count (not the + // tokens themselves) to keep CI logs free of substring leaks. + console.log(`[claude-analyze] keywords_count=${keywords.length}`); + const candidateFiles = grepCandidateFiles(keywords, env.workspace); + console.log(`[claude-analyze] candidate_files=${JSON.stringify(candidateFiles)}`); + const excerpts = await buildExcerpts(candidateFiles, env.workspace); + + const prompt = await renderPrompt({ + repository: env.repository, + api_error_id: env.apiErrorId, + sentry_issue_id: env.sentryIssueId, + title: env.title, + route: env.route || "(unknown)", + repo_excerpts: excerpts, + }); + + /** @type {string} */ + let raw; + if (env.dryRun) { + // ドライラン: API 呼び出しを行わず、固定 stub を返してパイプラインだけ通す。 + // Dry-run: skip the API call and return a fixed stub so the pipeline can + // be exercised end-to-end (workflow_dispatch + fixture inputs) without + // burning Anthropic credits. + console.log("[claude-analyze] DRY RUN — skipping Anthropic call"); + raw = JSON.stringify({ + severity: "unknown", + ai_summary: `(dry-run) would analyze ${env.title}`, + ai_root_cause: null, + ai_suggested_fix: null, + // schema 上限 (max 5) と `prompt.md` の出力規約に合わせて先頭 5 件に絞る。 + // grep 上限 (`MAX_EXCERPT_FILES` = 6) より小さいため明示的に slice する。 + // Cap at the schema's 5-entry limit (the same cap documented in + // `prompt.md`). `MAX_EXCERPT_FILES` is 6 so an explicit slice is needed. + ai_suspected_files: candidateFiles + .slice(0, 5) + .map((p) => ({ path: p, reason: "grep candidate" })), + }); + } else { + // SDK の組み込みリトライ(既定 maxRetries=2)を無効化して、外側の + // `callClaudeWithRetry` (MAX_ATTEMPTS=2) だけがリトライ予算を握る。 + // 入れ子状態だと最悪 2*2=4 回呼ばれて issue #806 の「1〜2 回まで」を超える。 + // + // Disable the SDK's built-in retry (defaults to `maxRetries: 2`) so only + // the outer `callClaudeWithRetry` loop (MAX_ATTEMPTS=2) controls the + // retry budget. Without this, nested retries could fire 2×2=4 upstream + // calls, breaching issue #806's "1〜2 回まで" requirement. + const client = new Anthropic({ apiKey: env.anthropicApiKey, maxRetries: 0 }); + raw = await callClaudeWithRetry(client, env.model, prompt); + } + + const validated = parseAndValidate(raw); + console.log(`[claude-analyze] severity=${validated.severity}`); + + const outputJson = JSON.stringify(validated, null, 2); + await writeFile(env.outputPath, `${outputJson}\n`, "utf8"); + console.log(`[claude-analyze] wrote analysis to ${env.outputPath}`); +} + +// `import` for tests should not auto-run main(). Only run when invoked +// directly as a script (matches Node's pattern for ESM entry detection). +// `fileURLToPath` を使うことで Windows の `/C:/...` 形式が `C:\...` に正規化され、 +// `path.resolve(process.argv[1])` と正しくマッチする(HERE 側と一貫)。 +// Use `fileURLToPath` so the comparison stays correct on Windows +// (`new URL(...).pathname` would yield `/C:/...` and never match +// `path.resolve(process.argv[1])`). Mirrors the `import.meta.dirname` +// migration earlier in this file. +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + main().catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[claude-analyze] FATAL: ${msg}`); + process.exit(1); + }); +} diff --git a/.github/actions/claude-analyze/autoIssue.mjs b/.github/actions/claude-analyze/autoIssue.mjs new file mode 100644 index 00000000..bd7ce54a --- /dev/null +++ b/.github/actions/claude-analyze/autoIssue.mjs @@ -0,0 +1,702 @@ +/** + * Auto-issue helpers and orchestrator for the analyze-error workflow. + * Epic #616 Phase 3 / Issue #808. + * + * `analyze.mjs` の出力 JSON を受け取り、AI が判定した `severity` が `high` / + * `medium` のときに限り、GitHub Issue を新規起票するか、既存の重複 Issue に + * コメントを追記する。`sentry-issue:` ラベルを「同一 Sentry + * issue に対するオープン Issue は 1 件」の判定キーとして使う。 + * + * Reads the validated analysis JSON emitted by `analyze.mjs`. When AI-assigned + * `severity` is `high` or `medium`, files a fresh GitHub Issue or appends a + * recurrence comment if an open Issue already exists for the same Sentry issue + * id. The `sentry-issue:` label is the dedup key — there must be at most + * one open Issue per Sentry id (Epic #616 acceptance criterion: "same error + * 100 times → 1 Issue"). + * + * 設計上の分離 / Design split: + * - 純粋関数(`shouldFileIssue`, `buildIssueTitle`, `buildIssueBody`, + * `buildRecurrenceCommentBody`, `buildSentryIssueLabel`, + * `parseRepository`)は副作用ゼロでユニットテスト可能。 + * - HTTP 層は `runAutoIssue` 経由のみ。`fetch` を引数で差し替え可能にして + * テストでは GitHub API を叩かない。 + * + * - Pure helpers are side-effect-free and unit-tested in + * `__tests__/autoIssue.test.mjs`. + * - The HTTP layer goes through `runAutoIssue` only; tests inject a `fetch` + * stub so no real GitHub API call is ever made by the test runner. + * + * @see ./schema.mjs + * @see ./analyze.mjs + * @see https://github.com/otomatty/zedi/issues/808 + * @see https://github.com/otomatty/zedi/issues/616 + */ + +/** + * GitHub REST API ベース URL。GitHub Enterprise では差し替えが必要だが、 + * 本リポジトリは github.com 固定なので定数で良い。 + * + * GitHub REST API base URL. Hard-coded to github.com; if Zedi ever migrates + * to GHES this needs to read from the `GITHUB_API_URL` env exposed by Actions. + */ +const GITHUB_API = "https://api.github.com"; + +/** + * Issue 起票の対象になる severity 値。`low` / `unknown` はスキップ + * (Epic #616:「low は集約のみ」)。 + * + * Severity values that warrant filing an Issue. `low` and `unknown` are + * intentionally skipped per Epic #616 ("low はノイズ抑制のため起票しない"). + */ +const ISSUE_SEVERITIES = new Set(["high", "medium"]); + +/** + * 新規 Issue に必ず付与する静的ラベル。`sentry-issue:` は別途追加する。 + * 順序は決定論的にしておくとスナップショット系テストが安定する。 + * + * Static labels applied to every auto-filed Issue. The dynamic + * `sentry-issue:` label is added separately. Ordering is deterministic so + * snapshot-style assertions stay stable. + */ +export const STATIC_LABELS = Object.freeze(["monitoring", "auto-reported"]); + +/** + * 自動付与するラベルのメタデータ。リポジトリに未登録の場合に + * `POST /repos/{o}/{r}/labels` で生成する際に使う。 + * + * Metadata for labels we auto-create when missing. Used by `ensureLabel` when + * `GET /labels/{name}` returns 404. Colors are intentionally muted so they do + * not visually drown out human-curated labels. + */ +const LABEL_METADATA = { + monitoring: { + color: "5319e7", + description: "Production monitoring & on-call surface (Epic #616).", + }, + "auto-reported": { + color: "ededed", + description: "Auto-filed by the analyze-error workflow (Epic #616 Phase 3).", + }, + // sentry-issue: 系は description にラベルの趣旨だけ書く。色は黄系。 + // sentry-issue: labels share a yellow-ish color and a single description. + __sentryIssue: { + color: "fbca04", + description: "1:1 dedup key for a Sentry issue id (sentry-issue:).", + }, +}; + +/** + * Issue タイトルに含めるエラー本文の最大長。GitHub の Issue タイトルは 256 文字 + * までだが、severity prefix と sentry suffix を足した上で 256 を割らないよう、 + * 本文部分は控えめに 180 で打ち切る。 + * + * Cap on the embedded error-title portion of the Issue title. GitHub allows up + * to 256 chars; reserving headroom for the `[severity]` prefix and + * `(sentry:)` suffix keeps the result safely under the limit even with + * long Sentry ids. + */ +const TITLE_BODY_MAX = 180; + +/** + * AI 判定 severity が Issue 起票対象(high / medium)か判定する純粋関数。 + * + * Pure predicate: returns `true` when the AI-assigned severity warrants + * creating or commenting on a GitHub Issue. + * + * @param {unknown} severity - The `severity` field from the analysis JSON. + * @returns {boolean} + */ +export function shouldFileIssue(severity) { + return typeof severity === "string" && ISSUE_SEVERITIES.has(severity); +} + +/** + * `sentry-issue:` ラベル名を生成する。空文字 / 非文字列は早期に弾いて + * 呼び出し側のバグ(payload 欠落など)を可視化する。 + * + * Build the `sentry-issue:` label string. Throws on empty / non-string + * input so a missing payload field surfaces as a CI failure rather than as a + * silently-malformed label. + * + * @param {unknown} sentryIssueId + * @returns {string} + */ +export function buildSentryIssueLabel(sentryIssueId) { + if (typeof sentryIssueId !== "string" || sentryIssueId.length === 0) { + throw new Error("sentryIssueId must be a non-empty string"); + } + return `sentry-issue:${sentryIssueId}`; +} + +/** + * `owner/repo` 形式の文字列を分解する。`github.repository` env からの値を + * 想定。スラッシュが 1 つでないものは弾く。 + * + * Parse an `owner/repo` slug (typically `process.env.GITHUB_REPOSITORY`). + * Throws unless the input has exactly one `/`. + * + * @param {unknown} repository + * @returns {{ owner: string, repo: string }} + */ +export function parseRepository(repository) { + if (typeof repository !== "string") { + throw new Error("repository must be a string in 'owner/repo' form"); + } + const parts = repository.split("/"); + if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) { + throw new Error(`repository must be in 'owner/repo' form, got: ${repository}`); + } + return { owner: parts[0], repo: parts[1] }; +} + +/** + * Issue タイトルを生成する。`[severity] (sentry:)` 形式。 + * 本文タイトルは `TITLE_BODY_MAX` で打ち切る(GitHub の 256 文字上限対策)。 + * + * Build the GitHub Issue title in `[severity] (sentry:<id>)` form. + * The error-title segment is truncated to `TITLE_BODY_MAX` to leave room for + * the prefix / suffix under GitHub's 256-character cap. Empty titles fall + * back to `(no title)` so the issue list stays readable. + * + * @param {{ severity: string, title: string, sentryIssueId: string }} input + * @returns {string} + */ +export function buildIssueTitle({ severity, title, sentryIssueId }) { + const trimmed = (title ?? "").trim(); + let body; + if (trimmed.length === 0) { + body = "(no title)"; + } else if (trimmed.length <= TITLE_BODY_MAX) { + body = trimmed; + } else { + // 切り捨ての可視化のため末尾 3 文字を `...` に置き換える。文字数は + // `TITLE_BODY_MAX` 内に収める(合計サイズは GitHub の 256 文字上限を侵さない)。 + // Replace the last 3 chars with `...` so readers see at a glance that the + // title was truncated. Keeps total length ≤ TITLE_BODY_MAX so the + // prefix/suffix stay safely under GitHub's 256-char Issue-title cap. + body = `${trimmed.slice(0, TITLE_BODY_MAX - 3)}...`; + } + return `[${severity}] ${body} (sentry:${sentryIssueId})`; +} + +/** + * 抜粋 Markdown 用にユーザー由来の任意文字列をエスケープして 1 行表示する。 + * 空文字列・null / undefined は `(none)` を返す。改行は半角スペースに畳み、 + * 表組みのセル区切り `|` はバックスラッシュでエスケープする(route や AI 出力に + * `|` が混入したときに表が崩れないようにする)。 + * + * Inline-safe formatter for free-form strings dropped into the Issue body + * tables. Returns `(none)` for empty / null / undefined input. Collapses + * newlines to a single space and escapes `|` as `\|` so a stray pipe in a + * route segment or AI-generated string cannot break the surrounding Markdown + * table. + * + * @param {unknown} value + * @returns {string} + */ +function inlineOrNone(value) { + if (value === null || value === undefined) return "(none)"; + if (typeof value !== "string") return "(none)"; + // バックスラッシュを先にエスケープしてから pipe をエスケープする。順序を + // 逆にすると入力 `\|` が `\\|` になり、Markdown 上は「リテラル `\` + 生 `|`」 + // と解釈されて表が壊れる(CodeQL: Incomplete string escaping)。 + // Escape backslashes first, then pipes. Reversing the order would turn an + // input of `\|` into `\\|`, which Markdown renders as a literal `\` plus a + // raw `|` — and the raw pipe would break the surrounding table cell. + const escaped = value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); + const collapsed = escaped.replace(/\s+/g, " ").trim(); + return collapsed.length === 0 ? "(none)" : collapsed; +} + +/** + * 複数行文字列をブロック表示用に整形する。空 / null は `(none)` を返す。 + * Markdown のコードフェンスではなく blockquote にして、AI 出力の Markdown + * 構文(リスト等)が壊れないようにする。 + * + * Block-style formatter for multi-line strings. Returns `(none)` for empty + * input. Uses blockquotes (`>`) rather than fenced code so the AI's Markdown + * (lists, links) renders inline. + * + * @param {unknown} value + * @returns {string} + */ +function blockOrNone(value) { + if (typeof value !== "string" || value.trim().length === 0) return "(none)"; + return value + .trim() + .split(/\r?\n/) + .map((line) => `> ${line}`) + .join("\n"); +} + +/** + * `ai_suspected_files` を bullet list に整形する。空 / null は `(none)` を返す。 + * + * Render `ai_suspected_files` as a bullet list. Returns `(none)` for empty / + * null. Each entry is `path` (with optional `line` and `reason`). + * + * @param {ReadonlyArray<{ path: string, reason?: string, line?: number }> | null | undefined} files + * @returns {string} + */ +function renderSuspectedFiles(files) { + if (!Array.isArray(files) || files.length === 0) return "(none)"; + return files + .map((f) => { + const lineSuffix = typeof f.line === "number" ? `:${f.line}` : ""; + const reasonSuffix = f.reason ? ` — ${inlineOrNone(f.reason)}` : ""; + return `- \`${f.path}${lineSuffix}\`${reasonSuffix}`; + }) + .join("\n"); +} + +/** + * Issue 本文を組み立てる。PII を含めない方針: + * - Sentry URL を埋め込まない(org/project slug 漏洩防止)。`sentry_issue_id` + * と `sentry-issue:<id>` ラベルだけで相関可能。 + * - `route` は構造情報なので埋め込んでも PII にならないが、欠落時は `(none)`。 + * - AI 由来文字列(summary 等)は Sentry の data scrubbing 後の入力で生成 + * されているため二段防御済み。 + * + * Build the GitHub Issue body. PII guards: + * - No Sentry URL is embedded (would leak the org / project slug). + * Cross-reference via the `sentry-issue:<id>` label and id instead. + * - `route` is structural metadata (e.g. `POST /api/pages`), safe to embed; + * missing values render as `(none)`. + * - AI-derived strings come from prompts that already operate on + * Sentry-scrubbed input, so they inherit the upstream PII filtering. + * + * @param {{ + * severity: string, + * summary: string, + * rootCause?: string | null, + * suggestedFix?: string | null, + * suspectedFiles?: ReadonlyArray<{ path: string, reason?: string, line?: number }> | null, + * route?: string, + * sentryIssueId: string, + * apiErrorId: string, + * workflowRunUrl: string, + * }} input + * @returns {string} + */ +export function buildIssueBody(input) { + const lines = [ + "<!-- Auto-filed by analyze-error workflow (Epic #616 Phase 3 / Issue #808) -->", + "", + "| Field | Value |", + "| --- | --- |", + `| Severity | \`${input.severity}\` |`, + `| Route | ${inlineOrNone(input.route)} |`, + `| sentry_issue_id | \`${input.sentryIssueId}\` |`, + `| api_error_id | \`${input.apiErrorId}\` |`, + `| Workflow run | ${input.workflowRunUrl} |`, + "", + "## AI summary", + blockOrNone(input.summary), + "", + "## Suspected root cause", + blockOrNone(input.rootCause), + "", + "## Suggested fix", + blockOrNone(input.suggestedFix), + "", + "## Suspected files", + renderSuspectedFiles(input.suspectedFiles), + "", + "---", + "", + "_This issue was filed automatically. Reopen with the `monitoring` triage flow / 自動起票された Issue。`monitoring` トリアージで再オープンしてください。_", + ]; + return lines.join("\n"); +} + +/** + * 再発時のコメント本文を組み立てる。サマリは AI が再判定した `ai_summary` を + * 1 行だけ載せ、詳細は workflow run のリンクを辿らせる方針(Issue 本文を + * 何度も肥大化させない)。 + * + * Build the recurrence comment body. Keeps the comment short — only the new + * `ai_summary` plus a link to the workflow run — to avoid bloating the Issue + * thread when the same error recurs many times. + * + * @param {{ + * severity: string, + * summary: string, + * apiErrorId: string, + * workflowRunUrl: string, + * }} input + * @returns {string} + */ +export function buildRecurrenceCommentBody(input) { + return [ + `**Recurrence detected** (severity \`${input.severity}\`)`, + "", + blockOrNone(input.summary), + "", + `- api_error_id: \`${input.apiErrorId}\``, + `- Workflow run: ${input.workflowRunUrl}`, + ].join("\n"); +} + +/** + * GitHub REST API への共通リクエスト。Bearer に GitHub App installation token + * を載せる。失敗時は `Error` を throw する(呼び出し側で処理)。 + * + * Thin REST helper. Bearer token is the GitHub App installation token. Throws + * on non-2xx (except for the 404-tolerated paths handled by `ensureLabel`). + * + * @param {{ + * url: string, + * method?: string, + * token: string, + * body?: unknown, + * fetchImpl: typeof fetch, + * acceptStatuses?: ReadonlyArray<number>, + * }} args + * @returns {Promise<{ status: number, data: unknown }>} + */ +async function ghRequest({ url, method = "GET", token, body, fetchImpl, acceptStatuses = [] }) { + const init = { + method, + headers: { + authorization: `Bearer ${token}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "user-agent": "zedi-analyze-error/0.1", + ...(body !== undefined ? { "content-type": "application/json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }; + const res = await fetchImpl(url, init); + if (!res.ok && !acceptStatuses.includes(res.status)) { + let detail = ""; + try { + detail = await res.text(); + } catch { + detail = "(no body)"; + } + throw new Error(`GitHub ${method} ${url} failed: ${res.status} ${detail}`); + } + let data = null; + try { + data = await res.json(); + } catch { + data = null; + } + return { status: res.status, data }; +} + +/** + * ラベルが存在することを保証する。404 の場合は作成する。既存の場合は no-op。 + * `sentry-issue:<id>` 系は `LABEL_METADATA.__sentryIssue` を使う。 + * + * Ensure a label exists on the repo. If `GET /labels/{name}` returns 404, + * create it via `POST /labels`. No-op when the label already exists. The + * dynamic `sentry-issue:<id>` family uses the shared `__sentryIssue` metadata. + * + * @param {{ + * owner: string, + * repo: string, + * label: string, + * token: string, + * fetchImpl: typeof fetch, + * logger?: Pick<Console, "log">, + * }} args + * @returns {Promise<"existing" | "created">} + */ +export async function ensureLabel({ owner, repo, label, token, fetchImpl, logger = console }) { + // GET /labels/{name} は 404 が「未存在」のシグナル。tolerate しないと throw に + // なるので acceptStatuses で許容。 + const get = await ghRequest({ + url: `${GITHUB_API}/repos/${owner}/${repo}/labels/${encodeURIComponent(label)}`, + method: "GET", + token, + fetchImpl, + acceptStatuses: [404], + }); + if (get.status === 200) { + return "existing"; + } + const meta = LABEL_METADATA[label] ?? LABEL_METADATA.__sentryIssue; + // 422 は「同名ラベルが既に存在」を意味する(GitHub API の検証エラー)。 + // GET と POST の間に別 workflow run が同じラベルを作った場合に発生し得る + // ので、ここでは throw せず "existing" として扱う。同一 sentry_issue_id への + // 並行起動は workflow の `concurrency` で 1 本に絞っているが、別 sentry_issue_id + // が共通の `auto-reported` を初回作成するレースは残るためこのガードを付ける。 + // + // 422 means "label already exists" (GitHub API validation error). Can happen + // if another workflow run created the same label between our GET and POST. + // The workflow's `concurrency` group serializes per `sentry_issue_id`, but + // different ids racing to create the shared `auto-reported` label is still + // possible — tolerate 422 so the recurrent path doesn't fail spuriously. + const post = await ghRequest({ + url: `${GITHUB_API}/repos/${owner}/${repo}/labels`, + method: "POST", + token, + body: { name: label, color: meta.color, description: meta.description }, + fetchImpl, + acceptStatuses: [422], + }); + if (post.status === 422) { + logger.log?.(`[auto-issue] label already existed (race tolerated): ${label}`); + return "existing"; + } + logger.log?.(`[auto-issue] created missing label: ${label}`); + return "created"; +} + +/** + * `sentry-issue:<id>` ラベルが付いた **オープン** Issue を検索する。重複防止 + * のため、最古 (number 昇順最小) を 1 件選ぶ(GitHub API の sort=created+asc + * を使うと安定)。 + * + * Search for **open** Issues carrying a given `sentry-issue:<id>` label. To + * stay deterministic if multiple matches exist (race during initial rollout + * or manual edits), pick the issue with the smallest `number` — the original + * file. The `?sort=created&direction=asc` query is just a hint; we still + * defensively re-sort client-side. + * + * @param {{ + * owner: string, + * repo: string, + * label: string, + * token: string, + * fetchImpl: typeof fetch, + * }} args + * @returns {Promise<{ number: number, html_url: string } | null>} + */ +export async function findOpenIssueByLabel({ owner, repo, label, token, fetchImpl }) { + const params = new URLSearchParams({ + state: "open", + labels: label, + per_page: "100", + sort: "created", + direction: "asc", + }); + const { data } = await ghRequest({ + url: `${GITHUB_API}/repos/${owner}/${repo}/issues?${params.toString()}`, + method: "GET", + token, + fetchImpl, + }); + if (!Array.isArray(data) || data.length === 0) return null; + // GitHub の `/issues` 一覧には Pull Request も含まれる(`pull_request` プロパティで + // 識別)。PR は除外して純粋な Issue だけを残す。 + // GitHub's `/issues` listing includes Pull Requests too (distinguishable by + // the `pull_request` property). Filter them out so we never comment on a PR. + const issues = data + .filter((entry) => entry && typeof entry === "object" && !("pull_request" in entry)) + .map((entry) => ({ + number: Number(entry.number), + html_url: typeof entry.html_url === "string" ? entry.html_url : "", + })) + .filter((e) => Number.isFinite(e.number)); + if (issues.length === 0) return null; + issues.sort((a, b) => a.number - b.number); + return issues[0]; +} + +/** + * Issue を作成する。`labels` は事前に `ensureLabel` で存在確認済みである前提。 + * + * Create a new Issue. Assumes all labels in `labels` already exist (call + * `ensureLabel` first). Returns the created issue number / html_url. + * + * @param {{ + * owner: string, + * repo: string, + * title: string, + * body: string, + * labels: ReadonlyArray<string>, + * token: string, + * fetchImpl: typeof fetch, + * }} args + * @returns {Promise<{ number: number, html_url: string }>} + */ +export async function createIssue({ owner, repo, title, body, labels, token, fetchImpl }) { + const { data } = await ghRequest({ + url: `${GITHUB_API}/repos/${owner}/${repo}/issues`, + method: "POST", + token, + body: { title, body, labels: [...labels] }, + fetchImpl, + }); + return { + number: Number(data?.number), + html_url: typeof data?.html_url === "string" ? data.html_url : "", + }; +} + +/** + * 既存 Issue にコメントを追加する。 + * + * Append a comment to an existing Issue. + * + * @param {{ + * owner: string, + * repo: string, + * issueNumber: number, + * body: string, + * token: string, + * fetchImpl: typeof fetch, + * }} args + * @returns {Promise<{ id: number, html_url: string }>} + */ +export async function addIssueComment({ owner, repo, issueNumber, body, token, fetchImpl }) { + const { data } = await ghRequest({ + url: `${GITHUB_API}/repos/${owner}/${repo}/issues/${issueNumber}/comments`, + method: "POST", + token, + body: { body }, + fetchImpl, + }); + return { + id: Number(data?.id), + html_url: typeof data?.html_url === "string" ? data.html_url : "", + }; +} + +/** + * @typedef {{ + * action: "skipped", + * reason: "severity-not-actionable", + * } | { + * action: "created", + * issueNumber: number, + * html_url: string, + * } | { + * action: "commented", + * issueNumber: number, + * commentId: number, + * html_url: string, + * }} AutoIssueResult + */ + +/** + * オーケストレータ。`analyze.mjs` の出力 + dispatch payload を受け取り、 + * severity ゲート → ラベル整備 → 既存検索 → 作成 or コメント の順で実行する。 + * + * Top-level orchestrator. Given the validated analysis JSON and the dispatch + * payload, runs: + * 1. severity gate (skip `low` / `unknown`), + * 2. ensure required labels exist, + * 3. search for an open Issue with the `sentry-issue:<id>` label, + * 4. comment on the existing Issue, or create a new one if none. + * + * Throws on any HTTP failure so the workflow step turns red and the operator + * sees the error in CI logs. + * + * @param {{ + * analysis: { severity: string, ai_summary: string, ai_root_cause?: string | null, ai_suggested_fix?: string | null, ai_suspected_files?: ReadonlyArray<{ path: string, reason?: string, line?: number }> | null }, + * sentryIssueId: string, + * apiErrorId: string, + * title: string, + * route: string, + * repository: string, + * token: string, + * workflowRunUrl: string, + * fetchImpl?: typeof fetch, + * logger?: Pick<Console, "log">, + * }} args + * @returns {Promise<AutoIssueResult>} + */ +export async function runAutoIssue({ + analysis, + sentryIssueId, + apiErrorId, + title, + route, + repository, + token, + workflowRunUrl, + fetchImpl = globalThis.fetch, + logger = console, +}) { + if (!shouldFileIssue(analysis?.severity)) { + logger.log?.( + `[auto-issue] severity=${analysis?.severity} — skipping issue creation (Epic #616: low/unknown は集約のみ)`, + ); + return { action: "skipped", reason: "severity-not-actionable" }; + } + if (typeof token !== "string" || token.length === 0) { + throw new Error("token must be a non-empty GitHub installation token"); + } + const { owner, repo } = parseRepository(repository); + const sentryLabel = buildSentryIssueLabel(sentryIssueId); + const allLabels = [...STATIC_LABELS, sentryLabel]; + + // 並列で 3 ラベル確認。GitHub の secondary rate limit を踏みやすいケースは + // 「1 リクエストあたり〜100ms 以下の連続発行」なので、3 並列なら問題ない。 + // Run the three label checks in parallel — well below GitHub's secondary + // rate-limit threshold for short concurrent bursts. + await Promise.all( + allLabels.map((label) => ensureLabel({ owner, repo, label, token, fetchImpl, logger })), + ); + + const existing = await findOpenIssueByLabel({ + owner, + repo, + label: sentryLabel, + token, + fetchImpl, + }); + + if (existing) { + const commentBody = buildRecurrenceCommentBody({ + severity: analysis.severity, + summary: analysis.ai_summary, + apiErrorId, + workflowRunUrl, + }); + const created = await addIssueComment({ + owner, + repo, + issueNumber: existing.number, + body: commentBody, + token, + fetchImpl, + }); + logger.log?.( + `[auto-issue] commented on existing issue #${existing.number} (sentry_issue_id=${sentryIssueId})`, + ); + return { + action: "commented", + issueNumber: existing.number, + commentId: created.id, + html_url: created.html_url, + }; + } + + const issueTitle = buildIssueTitle({ + severity: analysis.severity, + title, + sentryIssueId, + }); + const issueBody = buildIssueBody({ + severity: analysis.severity, + summary: analysis.ai_summary, + rootCause: analysis.ai_root_cause ?? null, + suggestedFix: analysis.ai_suggested_fix ?? null, + suspectedFiles: analysis.ai_suspected_files ?? null, + route, + sentryIssueId, + apiErrorId, + workflowRunUrl, + }); + const created = await createIssue({ + owner, + repo, + title: issueTitle, + body: issueBody, + labels: allLabels, + token, + fetchImpl, + }); + logger.log?.( + `[auto-issue] created new issue #${created.number} for sentry_issue_id=${sentryIssueId} (severity=${analysis.severity})`, + ); + return { + action: "created", + issueNumber: created.number, + html_url: created.html_url, + }; +} diff --git a/.github/actions/claude-analyze/autoIssueRunner.mjs b/.github/actions/claude-analyze/autoIssueRunner.mjs new file mode 100644 index 00000000..6afea7b3 --- /dev/null +++ b/.github/actions/claude-analyze/autoIssueRunner.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * `autoIssue.mjs` を GitHub Actions のステップから呼び出すための薄いラッパ。 + * Epic #616 Phase 3 / Issue #808。 + * + * 期待する環境変数 / Required env (set by `action.yml`): + * - `AUTO_ISSUE_OUTPUT_PATH` : `analyze.mjs` が書き出した解析結果 JSON のパス + * - `AUTO_ISSUE_SENTRY_ISSUE_ID` : Sentry の issue id + * - `AUTO_ISSUE_API_ERROR_ID` : `api_errors.id` (UUID) + * - `AUTO_ISSUE_TITLE` : Sentry 由来の短いエラータイトル + * - `AUTO_ISSUE_ROUTE` : ルート(空でも可) + * - `AUTO_ISSUE_REPOSITORY` : `${{ github.repository }}` 形式 + * - `AUTO_ISSUE_TOKEN` : GitHub App installation token (issues: write) + * - `AUTO_ISSUE_WORKFLOW_RUN_URL`: 当該 workflow run の URL + * + * Thin wrapper that loads the analysis JSON, then calls `runAutoIssue`. Exits + * non-zero on failure so the workflow step turns red. Intentionally minimal — + * all of the testable logic lives in `autoIssue.mjs`. + */ +import { readFile } from "node:fs/promises"; +import process from "node:process"; + +import { runAutoIssue } from "./autoIssue.mjs"; + +/** + * Required env var を読み出す。欠落は `Error` で即時失敗させる。 + * + * Read a required env var; throw if missing so misconfiguration surfaces at + * the start of the step rather than midway through HTTP calls. + * + * @param {string} name + * @returns {string} + */ +function requireEnv(name) { + const raw = process.env[name]; + const value = typeof raw === "string" ? raw.trim() : ""; + if (value.length === 0) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +async function main() { + const outputPath = requireEnv("AUTO_ISSUE_OUTPUT_PATH"); + const sentryIssueId = requireEnv("AUTO_ISSUE_SENTRY_ISSUE_ID"); + const apiErrorId = requireEnv("AUTO_ISSUE_API_ERROR_ID"); + const title = requireEnv("AUTO_ISSUE_TITLE"); + const repository = requireEnv("AUTO_ISSUE_REPOSITORY"); + const token = requireEnv("AUTO_ISSUE_TOKEN"); + const workflowRunUrl = requireEnv("AUTO_ISSUE_WORKFLOW_RUN_URL"); + // route は空文字を許容。 + const route = process.env.AUTO_ISSUE_ROUTE ?? ""; + + const raw = await readFile(outputPath, "utf8"); + /** @type {unknown} */ + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to parse analysis JSON at ${outputPath}: ${msg}`); + } + + // analyze.mjs が `parseAndValidate` 経由で出力しているので、ここでは型ナローイング + // のみ行う(再検証はしない — 二重バリデーションは責務分離を曖昧にするため)。 + // The analyze step already runs `parseAndValidate`; we only narrow the type + // here. Re-validating would muddy the responsibility split (schema lives + // with the analyze script). + if (!parsed || typeof parsed !== "object") { + throw new Error("Analysis JSON did not parse to an object"); + } + const analysis = /** @type {{ severity: string, ai_summary: string }} */ (parsed); + + const result = await runAutoIssue({ + analysis, + sentryIssueId, + apiErrorId, + title, + route, + repository, + token, + workflowRunUrl, + }); + + // GITHUB_OUTPUT に結果を書き出して、後続ステップから参照できるようにする。 + // Emit an `outcome` annotation + GITHUB_OUTPUT so reviewers can spot the + // outcome in the workflow run summary at a glance. + const summary = JSON.stringify(result); + console.log(`[auto-issue] result=${summary}`); + console.log(`::notice title=Auto-issue outcome::${summary}`); + + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + const { appendFile } = await import("node:fs/promises"); + const lines = [`action=${result.action}`]; + if (result.action !== "skipped") { + lines.push(`issue_number=${result.issueNumber}`); + lines.push(`issue_html_url=${result.html_url}`); + } + await appendFile(githubOutput, `${lines.join("\n")}\n`); + } +} + +main().catch((err) => { + const msg = err instanceof Error ? err.stack || err.message : String(err); + console.error(`[auto-issue] failed: ${msg}`); + process.exit(1); +}); diff --git a/.github/actions/claude-analyze/escapeGithubAnnotation.mjs b/.github/actions/claude-analyze/escapeGithubAnnotation.mjs new file mode 100644 index 00000000..6bf9ba3e --- /dev/null +++ b/.github/actions/claude-analyze/escapeGithubAnnotation.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * GitHub Actions のワークフローコマンド(例: ::error)へ埋め込む本文をエスケープする。 + * `%` / CR / LF / `:` がそのままだと注釈が壊れたり追加コマンドとして解釚される。 + * + * Escape text embedded in GitHub Actions workflow commands (e.g. ::error). + * Raw `%`, CR/LF, and `:` can corrupt annotations or inject extra commands. + * + * @see https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands + */ +import fs from "node:fs"; + +const path = process.argv[2]; +if (!path) { + console.error("usage: escapeGithubAnnotation.mjs <file>"); + process.exit(1); +} +const s = fs.readFileSync(path, "utf8"); +process.stdout.write( + s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A"), +); diff --git a/.github/actions/claude-analyze/prompt.md b/.github/actions/claude-analyze/prompt.md new file mode 100644 index 00000000..2e765ae9 --- /dev/null +++ b/.github/actions/claude-analyze/prompt.md @@ -0,0 +1,92 @@ +# Claude API エラー解析プロンプト / Claude API error-analysis prompt + +> 本ファイルは `.github/actions/claude-analyze/analyze.mjs` から読み込まれ、 +> リクエスト時に `{{...}}` プレースホルダが置換される(Mustache 等は使わず単純置換)。 +> +> Read by `.github/actions/claude-analyze/analyze.mjs`. Each `{{key}}` token is +> replaced verbatim at runtime — there is no Mustache / Handlebars layer, just +> a plain string substitution. + +--- + +あなたはこのリポジトリ (`{{repository}}`) の運用エンジニアです。Sentry が検知した +新規 API エラーについて、以下の情報をもとに **構造化 JSON のみ** を返してください。 +余計な前置きや解説、コードフェンスは書かないでください。 + +You are an operations engineer for the repository `{{repository}}`. Analyze the +new Sentry-reported API error described below and respond with **structured +JSON only**. Do not include any prose, preamble, or code fences. + +## エラー情報 / Error context + +- `sentry_issue_id`: `{{sentry_issue_id}}` +- `api_error_id`: `{{api_error_id}}` +- `title`: `{{title}}` +- `route`: `{{route}}` + +## リポジトリ抜粋 / Repository excerpts + +タイトル・ルートから推定した関連ファイルを抜粋した。網羅的ではないので、必要なら +推測でファイルを挙げてもよい(その場合は `reason` に「推測」と明記する)。 + +The following snippets were grep'd from the checkout based on the error +title / route. They are best-effort, not exhaustive — you may name additional +files if they are likely involved (mark them with `reason: "speculative"`). + +```text +{{repo_excerpts}} +``` + +## 重大度判定基準 / Severity rubric + +Epic #616 の運用方針に従う: + +- **`high`**: データ破壊・データ漏洩・全ユーザー影響・新規発生したクラッシュ・ + 認証/課金ブロック。即時対応が必要。 + Data corruption, data leak, all-user impact, brand-new crash, or auth/billing + blocker. Requires immediate attention. + +- **`medium`**: 特定機能の継続的な失敗、リトライで回復しない 5xx、新たに頻発し始めた + エラー。同日中の対応が望ましい。 + Persistent failure of a specific feature, non-retryable 5xx, or a regression + that has just started firing repeatedly. Should be addressed same-day. + +- **`low`**: 一過性のネットワーク・ユーザー入力起因・既知の rate limit・3rd party + API の一時的な失敗。集約のみで自動起票は行わない。 + Transient network blip, user-input error, known rate limit, or third-party + outage. Aggregated only; no auto-issue is opened. + +- **`unknown`**: 上記いずれにも自信を持って分類できない場合のみ使用する。可能な限り + `low` を選び、`ai_root_cause` に判断保留の理由を書く。 + Use only when you cannot confidently classify the error. Prefer `low` and + explain the uncertainty in `ai_root_cause`. + +## 出力スキーマ / Output schema + +以下のキーを **すべて** 含む単一の JSON オブジェクトを返す。`ai_suspected_files` は +最大 5 件まで。確信のないフィールドは `null` にしてよいが、`severity` と +`ai_summary` は必須。 + +Return a single JSON object containing **all** of the following keys. +`ai_suspected_files` is capped at 5 entries. Use `null` for any field where +confidence is low — except `severity` and `ai_summary`, which are required. + +```json +{ + "severity": "high | medium | low | unknown", + "ai_summary": "1-2 文の要約 / one or two sentence summary", + "ai_root_cause": "原因仮説 (or null) / root-cause hypothesis (or null)", + "ai_suggested_fix": "修正方針 (or null) / fix direction (or null)", + "ai_suspected_files": [ + { + "path": "server/api/src/...", + "reason": "なぜ関連すると判断したか / why this file is suspected", + "line": 42 + } + ] +} +``` + +`reason` と `line` は省略可。`path` はリポジトリルートからの相対パスにすること。 + +`reason` and `line` are optional; `path` MUST be repository-relative. diff --git a/.github/actions/claude-analyze/schema.mjs b/.github/actions/claude-analyze/schema.mjs new file mode 100644 index 00000000..17ad8c89 --- /dev/null +++ b/.github/actions/claude-analyze/schema.mjs @@ -0,0 +1,190 @@ +/** + * Claude による API エラー解析結果の出力 JSON スキーマ。Epic #616 Phase 2 / + * Issue #806 のコールバック (`PUT /api/webhooks/github/ai-result/:id`) が + * 受け取る形と 1:1 で対応する。 + * + * Output JSON schema for the Claude AI error-analysis step (Epic #616 Phase 2 / + * issue #806). Mirrors the shape accepted by the API callback at + * `PUT /api/webhooks/github/ai-result/:id` so the workflow can `PUT` the + * validated payload directly. + * + * The server-side service `updateAiAnalysis` (server/api/src/services/apiErrorService.ts) + * is the canonical validator; this schema must stay aligned with that + * function's expectations. + * + * @see ../../../server/api/src/services/apiErrorService.ts + * @see ../../../server/api/src/routes/webhooks/githubAiCallback.ts + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/806 + */ +import { z } from "zod"; + +/** + * AI が判定する重大度。サーバ側 `ApiErrorSeverity` と完全一致させる。 + * Severity enum kept in lockstep with the server's `ApiErrorSeverity`. + */ +export const SEVERITIES = /** @type {const} */ (["high", "medium", "low", "unknown"]); + +/** + * AI が「関連しそう」と判断したファイルエントリ。サーバ側 `ApiErrorSuspectedFile` + * の境界バリデーションと一致させる(`path` 必須、`reason` / `line` は任意)。 + * + * Suspected file entry. Matches the server's `validateSuspectedFiles` boundary + * checks: `path` is required and non-empty; `reason` and `line` are optional. + */ +export const suspectedFileSchema = z + .object({ + path: z.string().min(1, "path must be a non-empty string"), + reason: z.string().optional(), + line: z.number().int().finite().optional(), + }) + .strict(); + +/** + * AI 解析結果ペイロードのスキーマ。コールバックは部分更新を許容するが、ここでは + * 「ワークフローが生成した完全な解析」を返す前提なので、`severity` と `ai_summary` + * は必須にしておき、欠落を CI 段階で弾く。 + * + * Full analysis payload schema. The callback endpoint accepts partial updates + * for resilience, but the workflow always emits a complete analysis, so we + * require `severity` and `ai_summary` here to fail fast on a malformed Claude + * response rather than silently posting a half-empty record. + */ +export const analysisOutputSchema = z + .object({ + severity: z.enum(SEVERITIES), + ai_summary: z.string().min(1, "ai_summary must be a non-empty string"), + ai_root_cause: z.string().nullable().optional(), + ai_suggested_fix: z.string().nullable().optional(), + // 最大 5 件は `prompt.md` と README に明示している契約。Claude が指示を無視して + // 大量に返してきた場合に CI で弾く(API に 6 件以上を書き戻さない)。 + // The 5-entry cap is a contract documented in `prompt.md` and the README. + // Enforce it at the schema layer so a Claude response that ignores the + // instruction (and returns 6+ files) fails CI rather than being PUT to + // the API with an oversized list. + ai_suspected_files: z + .array(suspectedFileSchema) + .max(5, "ai_suspected_files must have at most 5 entries") + .nullable() + .optional(), + }) + .strict(); + +/** + * @typedef {z.infer<typeof analysisOutputSchema>} AnalysisOutput + */ + +/** + * @param {string} str + * @param {number} startIdx - index of `{` + * @returns {string | null} balanced JSON object substring or null + */ +function extractBalancedJsonObject(str, startIdx) { + if (str[startIdx] !== "{") return null; + let depth = 0; + let inString = false; + let stringEscape = false; + for (let i = startIdx; i < str.length; i++) { + const c = str[i]; + if (stringEscape) { + stringEscape = false; + continue; + } + if (inString) { + if (c === "\\") { + stringEscape = true; + continue; + } + if (c === '"') { + inString = false; + } + continue; + } + if (c === '"') { + inString = true; + continue; + } + if (c === "{") depth++; + else if (c === "}") { + depth--; + if (depth === 0) return str.slice(startIdx, i + 1); + } + } + return null; +} + +/** + * トップレベル解析ペイロードの「形」か(severity / ai_summary のキーが両方あるか)。 + * 型までは見ない。誤った型のオブジェクトでもキーが揃っていればルート意図とみなし、 + * 後続の別オブジェクトへフォールスルーしない(誤採択防止)。 + * + * Whether `value` looks like the intended root analysis object (both `severity` and + * `ai_summary` own keys). Types are not checked: malformed values still fail fast via + * schema throw instead of scanning for a later unrelated JSON object. + * + * @param {unknown} value + * @returns {boolean} + */ +function looksLikeAnalysisPayload(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const o = /** @type {Record<string, unknown>} */ (value); + return Object.hasOwn(o, "severity") && Object.hasOwn(o, "ai_summary"); +} + +/** + * ```json ... ``` フェンスがあれば内側だけを返す(なければそのまま)。 + * 言語ラベル無しの ``` ... ``` は JSON とみなさずそのままにする(誤ってコードブロックを剥がさない)。 + * + * If an explicit ```json ... ``` fence exists, return the inner body; otherwise raw. + * Plain ``` ... ``` fences are left intact so non-JSON fenced blocks are not stripped. + * + * @param {string} raw + * @returns {string} + */ +function stripMarkdownJsonFence(raw) { + const m = raw.match(/```json\s*([\s\S]*?)```/im); + return m ? m[1].trim() : raw; +} + +/** + * Claude の生応答 (テキスト) から JSON を抽出して `analysisOutputSchema` で + * 検証する。フェンスや前置きに加え、本文中の `{}` がバランスしない場合でも、 + * 各 `{` 起点で括弧バランスを取った候補を順に試して最初にスキーマ合格したものを採用する。 + * + * Extract and validate analysis JSON from Claude's raw text. Besides fences and + * prose, stray `{}` pairs in preambles are handled by scanning each `{` start, + * taking the balanced span, and accepting the first candidate that passes the + * Zod schema (instead of slicing first `{` to last `}`). + * + * @param {string} raw - The raw text returned by Claude. + * @returns {AnalysisOutput} + */ +export function parseAndValidate(raw) { + if (typeof raw !== "string" || raw.length === 0) { + throw new Error("Claude response was empty"); + } + const normalized = stripMarkdownJsonFence(raw); + /** @type {string | null} */ + let lastJsonError = null; + for (let i = 0; i < normalized.length; i++) { + if (normalized[i] !== "{") continue; + const slice = extractBalancedJsonObject(normalized, i); + if (!slice) continue; + /** @type {unknown} */ + let parsed; + try { + parsed = JSON.parse(slice); + } catch (err) { + lastJsonError = err instanceof Error ? err.message : String(err); + continue; + } + const result = analysisOutputSchema.safeParse(parsed); + if (result.success) return result.data; + lastJsonError = result.error.message; + if (looksLikeAnalysisPayload(parsed)) { + throw new Error(`Claude response failed schema validation: ${lastJsonError}`); + } + } + const suffix = lastJsonError ? `: ${lastJsonError}` : ""; + throw new Error(`Could not locate valid analysis JSON in Claude response${suffix}`); +} diff --git a/.github/workflows/analyze-error.yml b/.github/workflows/analyze-error.yml new file mode 100644 index 00000000..806dc7da --- /dev/null +++ b/.github/workflows/analyze-error.yml @@ -0,0 +1,138 @@ +# `analyze-error.yml` — Sentry が検知した API エラーを Claude で解析し、結果を +# Zedi API のコールバックに書き戻す。Epic #616 Phase 2 / Issue #806。 +# +# 起動経路: +# - `repository_dispatch` (`event_type: analyze-error`): +# Zedi API の Sentry webhook ハンドラから自動発火される。 +# client_payload は `api_error_id`, `sentry_issue_id`, `title`, `route`。 +# - `workflow_dispatch`: +# 手動入力 / fixture 入力で end-to-end のドライランを行う。 +# 詳しくは `.github/actions/claude-analyze/README.md` を参照。 +# +# Trigger paths: +# - `repository_dispatch` (`event_type: analyze-error`): fired by the Zedi +# API's Sentry webhook handler when a brand-new `sentry_issue_id` lands. +# The `client_payload` carries `api_error_id`, `sentry_issue_id`, `title`, +# and `route`. +# - `workflow_dispatch`: manual / fixture input for end-to-end dry runs. +# See `.github/actions/claude-analyze/README.md` for the recipe. +name: Analyze API error + +on: + repository_dispatch: + types: [analyze-error] + workflow_dispatch: + inputs: + api_error_id: + description: "`api_errors.id` (UUID)" + required: true + type: string + sentry_issue_id: + description: Sentry issue id + required: true + type: string + title: + description: Short error title + required: true + type: string + route: + description: "Route (e.g. `POST /api/ingest`)" + required: false + type: string + default: "" + dry_run: + description: Skip Anthropic call and emit a stub payload + required: false + type: boolean + default: true + skip_callback: + description: Validate locally but do not PUT to the API + required: false + type: boolean + default: true + skip_issue: + description: Skip GitHub Issue create / comment (Epic #616 Phase 3) + required: false + type: boolean + default: true + +# API callback と Issue 起票はどちらも GitHub App の installation token 経由で行う +# ため、デフォルト `GITHUB_TOKEN` には書き込みを与えない。`issues: write` を +# 明示するのは Epic #616 Phase 3 (Issue #808) の要件: フォールバックで `gh` +# CLI を使えるようにし、ジョブの権限境界を YAML 上で読み取れるようにする目的。 +# +# Both the API callback and the auto-issue step use the GitHub App installation +# token (minted just below). `issues: write` is declared explicitly per Issue +# #808 so the workflow's intent is visible in YAML and `gh` CLI fallbacks are +# usable; the App token is still the actual writer. +permissions: + contents: read + issues: write + +# 同一 sentry_issue_id で複数 run が積まれたとき、古い run をキャンセルして +# Anthropic 呼び出しと repository_dispatch の二重課金を避ける。 +# +# When multiple runs queue for the same `sentry_issue_id`, cancel older runs so +# we do not pay for duplicate Claude analyses while a newer dispatch waits. +concurrency: + group: >- + analyze-error-${{ + github.event.client_payload.sentry_issue_id || + github.event.inputs.sentry_issue_id + }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze with Claude + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 1 + + - name: Mint GitHub App installation token + # callback の Bearer + 自動 Issue 起票の bearer に使う。`actions/create-github-app-token` + # は App ID + Private Key から短命 installation token を発行し、自動で revoke する。 + # ワークフロー外には漏れない。`skip_callback` と `skip_issue` の両方が true の + # ドライランではこのステップをスキップし、secrets 未設定環境(fork PR など) + # でもパイプラインが回るようにする。 + # + # Mint the bearer used by both the API callback and the auto-issue + # step. Skipped only when both `skip_callback` and `skip_issue` are + # `true` (the workflow_dispatch dry-run default), so repositories + # without GitHub App secrets configured can still validate the + # pipeline end-to-end. + if: >- + github.event_name == 'repository_dispatch' || + github.event.inputs.skip_callback != 'true' || + github.event.inputs.skip_issue != 'true' + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.GITHUB_APP_ID }} + private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + + - name: Run Claude analyze action + uses: ./.github/actions/claude-analyze + with: + api_error_id: ${{ github.event.client_payload.api_error_id || github.event.inputs.api_error_id }} + sentry_issue_id: ${{ github.event.client_payload.sentry_issue_id || github.event.inputs.sentry_issue_id }} + title: ${{ github.event.client_payload.title || github.event.inputs.title }} + route: ${{ github.event.client_payload.route || github.event.inputs.route }} + callback_base_url: ${{ secrets.AI_ERROR_CALLBACK_BASE_URL }} + installation_token: ${{ steps.app-token.outputs.token }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + dry_run: ${{ github.event.inputs.dry_run || 'false' }} + skip_callback: ${{ github.event.inputs.skip_callback || 'false' }} + # `repository_dispatch` 経由(本番経路)では常に Issue 起票判定を回す。 + # `workflow_dispatch` では入力 `skip_issue` を尊重し、デフォルトは true で + # ローカルのドライラン中に誤って Issue を作らないようにする。 + # On `repository_dispatch` (production path) always run the auto-issue + # gate. On `workflow_dispatch` honor the `skip_issue` input (default + # true) so ad-hoc dry runs cannot accidentally file Issues. + skip_issue: ${{ github.event.inputs.skip_issue || 'false' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cee625da..9ef3145b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,52 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "**Total: $TOTAL**" >> $GITHUB_STEP_SUMMARY + e2e-web: + name: E2E (web) + # PDF 知識化フローの Web (非 Tauri) 側を Playwright で守るための job。 + # 現状は issue #863 で追加した `pdf-knowledge.spec.ts` のみを実行する。 + # 既存の他 spec (web-clipper, page-editor, ...) は CI 未通過のものが + # 混ざっている可能性が高いため、別 PR で順次取り込む。 + # + # Phase 2 (issue #863 follow-up): Tauri デスクトップ E2E は `tauri-driver` + # を要するため、本 job ではなく専用の job (`e2e-tauri`) として追加予定。 + # + # Playwright job guarding the web-side (non-Tauri) of the PDF knowledge + # ingestion flow added by #863. Limited to the new `pdf-knowledge.spec.ts` + # for now; the existing e2e specs (web-clipper, page-editor, …) have not + # been wired into CI before and may regress separately — they are queued + # for a follow-up PR rather than risking a noisy first integration. + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3" + + - run: bun install --frozen-lockfile + + # Playwright のブラウザバイナリ + 必要な OS 依存を入れる。 + # Install Playwright's browser binaries plus the matching OS deps. + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Run PDF knowledge E2E + run: bunx playwright test e2e/pdf-knowledge.spec.ts + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-report-pdf-knowledge + path: playwright-report/ + retention-days: 7 + api-typecheck: name: API Type Check if: github.event_name != 'pull_request' || !github.event.pull_request.draft @@ -217,6 +263,33 @@ jobs: PR_BODY: ${{ github.event.pull_request.body }} run: node scripts/check-drizzle-migrations.mjs + drizzle-schema-drift-check: + name: Drizzle Schema Drift Check + # `pgTable("name", ...)` で宣言された全テーブルが、checked-in な + # `server/api/drizzle/*.sql` の `CREATE TABLE` に対応しているかをリポジトリ + # 全体で検査する。`drizzle-migration-check` は同一 PR 内のペア漏れしか + # 拾えなかったため、本ジョブは push / PR 双方で実行し、過去から放置されて + # いた drift(例: Issue #878 の `page_snapshots`)も検出する。 + # + # Repo-wide audit ensuring every `pgTable("name", ...)` is actually created + # by a checked-in migration. `drizzle-migration-check` only covers + # within-PR pair omissions, so this job runs on push + PR to also catch + # historical drift like the `page_snapshots` regression in Issue #878. + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - name: Unit-test drift extractors (regex + allowlist logic) + run: node --test scripts/check-drizzle-schema-drift.test.mjs + + - name: Audit repository schema ↔ migration drift + run: node scripts/check-drizzle-schema-drift.mjs + mcp-test: name: MCP Server Tests if: github.event_name != 'pull_request' || !github.event.pull_request.draft diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 21974397..dafa01b8 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -110,7 +110,7 @@ jobs: VITE_POLAR_PRO_MONTHLY_PRODUCT_ID: ${{ vars.POLAR_MONTHLY_ID }} VITE_POLAR_PRO_YEARLY_PRODUCT_ID: ${{ vars.POLAR_YEARLY_ID }} - name: Deploy to Cloudflare Pages - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.gitignore b/.gitignore index 4c8e5aaa..0004bbd1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ dist dist-ssr *.local +# pdfjs-dist assets mirrored by scripts/copy-pdfjs-assets.mjs into public/pdfjs/. +# Re-generated by the `predev` / `prebuild` hooks; never edited by hand. +# scripts/copy-pdfjs-assets.mjs が public/pdfjs/ にミラーする pdf.js のアセット。 +# predev / prebuild フックで再生成されるためコミットしない。 +public/pdfjs/ + # Environment files (sensitive data) .env.development .env.production diff --git a/.gitleaksignore b/.gitleaksignore index aac83682..8f51d3f1 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -10,3 +10,15 @@ # The TEST_TOKEN at invite.test.ts:38 is a hard-coded mock string used only for # invitation API unit tests — it is not a real API key or secret. b1e5dab7fa191b9f317b066099ca3fbb6450c5a7:server/api/src/__tests__/routes/invite.test.ts:generic-api-key:38 + +# server/api/src/lib/githubAppAuth.test.ts:23 のコミット d10d11f に含まれる +# RSA 秘密鍵は、PR #814 (issue #805) のテスト用に生成された使い捨ての鍵で、 +# 後続のコミット f6825d7 で `jose` モックに置き換えて削除済み。実際の署名には +# 一度も使われていないが、Git 履歴には残るため fingerprint を追加して +# 誤検知扱いにする。 +# The RSA private key in commit d10d11f at githubAppAuth.test.ts:23 was a +# disposable test key generated solely for PR #814 (issue #805). The follow-up +# commit f6825d7 replaced the JWT path with a `jose` mock and removed the key +# from HEAD; it was never used to sign anything outside this test file. Git +# history retains the blob, so we suppress the historical match here. +d10d11f5fa92743bb5a8214ca5c088d7cb4b01dd:server/api/src/lib/githubAppAuth.test.ts:private-key:23 diff --git a/admin/.env.example b/admin/.env.example index 1675692d..51e502cb 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -9,3 +9,7 @@ # Main app URL for "Sign in" link on login page (default: https://zedi-note.app) # VITE_MAIN_APP_URL=https://zedi-note.app + +# Sentry DSN for the admin SPA (Epic #616). Leave unset locally — SDK no-ops when blank. +# 管理画面用 Sentry DSN。未設定なら SDK は no-op となる。 +# VITE_ADMIN_SENTRY_DSN=https://<public-key>@o0.ingest.sentry.io/<project> diff --git a/admin/e2e/errors-page.spec.ts b/admin/e2e/errors-page.spec.ts new file mode 100644 index 00000000..dd1c5f60 --- /dev/null +++ b/admin/e2e/errors-page.spec.ts @@ -0,0 +1,71 @@ +/** + * 管理画面 `/errors` の最小 E2E。`AdminGuard` と一覧 API を `page.route` で + * モックし、ネットワーク到達不要で UI のレンダリングのみを検証する。 + * + * Minimum E2E for the admin `/errors` page. Uses `page.route` to mock both the + * `AdminGuard` auth probe and the list API so the test does not depend on a + * running backend. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +import { test, expect } from "@playwright/test"; + +const MOCK_ERROR = { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-1", + fingerprint: null, + title: "TypeError: cannot read properties of null", + route: "GET /api/users/:id", + statusCode: 500, + occurrences: 7, + firstSeenAt: "2026-05-01T00:00:00Z", + lastSeenAt: "2026-05-04T00:00:00Z", + severity: "high", + status: "open", + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", +}; + +test.describe("Admin /errors page", () => { + test.beforeEach(async ({ page }) => { + // AdminGuard が呼ぶ `getAdminMe` を満たすモック。 + // Mock the admin auth probe so AdminGuard renders its children. + await page.route("**/api/admin/me", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: "admin-1", email: "admin@example.com", role: "admin" }), + }); + }); + + // 一覧 API:ステータス指定の有無に関わらずモック行を返す。 + // Errors list API: serve the same mock row regardless of filter params. + await page.route("**/api/admin/errors**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + errors: [MOCK_ERROR], + total: 1, + limit: 50, + offset: 0, + }), + }); + }); + }); + + test("renders the errors list with the mocked row", async ({ page }) => { + await page.goto("/errors"); + + // ページ見出しと、モック行のタイトル・ルートが描画されることを確認。 + // Verify the page heading and the mocked row's title/route are rendered. + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await expect(page.getByText(MOCK_ERROR.title)).toBeVisible(); + await expect(page.getByText(MOCK_ERROR.route)).toBeVisible(); + }); +}); diff --git a/admin/package.json b/admin/package.json index 6e6fed36..08f12122 100644 --- a/admin/package.json +++ b/admin/package.json @@ -8,9 +8,11 @@ "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test --config playwright.config.ts" }, "dependencies": { + "@sentry/react": "^10.51.0", "@zedi/ui": "workspace:*", "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", diff --git a/admin/playwright.config.ts b/admin/playwright.config.ts new file mode 100644 index 00000000..2afa6340 --- /dev/null +++ b/admin/playwright.config.ts @@ -0,0 +1,39 @@ +/** + * 管理画面 SPA 用の Playwright 設定。ルート (`playwright.config.ts`) はメインアプリ + * (ポート 5173)を起動するため、admin 用に別ポート (30001) を独立して立ち上げる。 + * + * Playwright config dedicated to the admin SPA. The root config boots the main + * app on port 5173, so the admin needs its own server on port 30001 with + * separate test selection so the two suites don't collide. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: "http://localhost:30001", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "bun run dev -- --port 30001", + url: "http://localhost:30001", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 04be6ee6..f39dc1c4 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -7,6 +7,7 @@ import Users from "./pages/users"; import AuditLogs from "./pages/audit-logs"; import WikiHealth from "./pages/wiki-health"; import ActivityLog from "./pages/ActivityLog"; +import Errors from "./pages/errors"; /** * Root component for the admin SPA: sets up routing and the admin auth guard. @@ -33,6 +34,7 @@ function App() { <Route path="audit-logs" element={<AuditLogs />} /> <Route path="wiki-health" element={<WikiHealth />} /> <Route path="activity-log" element={<ActivityLog />} /> + <Route path="errors" element={<Errors />} /> </Route> <Route path="*" element={<Navigate to="/" replace />} /> </Routes> diff --git a/admin/src/api/admin.test.ts b/admin/src/api/admin.test.ts index 57ec2003..a0f243c9 100644 --- a/admin/src/api/admin.test.ts +++ b/admin/src/api/admin.test.ts @@ -9,6 +9,10 @@ import { patchAiModelsBulk, previewSyncAiModels, syncAiModels, + getApiErrors, + getApiErrorById, + patchApiErrorStatus, + type ApiErrorRow, } from "./admin"; // `adminFetch` だけモックし、`getErrorMessage` は実装をそのまま使う @@ -215,3 +219,114 @@ describe("syncAiModels", () => { }); }); }); + +const sampleErrorRow: ApiErrorRow = { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-1", + fingerprint: null, + title: "TypeError", + route: "GET /api/x", + statusCode: 500, + occurrences: 1, + firstSeenAt: "2026-05-01T00:00:00Z", + lastSeenAt: "2026-05-04T00:00:00Z", + severity: "high", + status: "open", + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", +}; + +describe("getApiErrors", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("status / severity / limit / offset をクエリ文字列に渡す", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [sampleErrorRow], total: 1, limit: 10, offset: 0 }), { + status: 200, + }), + ); + const out = await getApiErrors({ status: "open", severity: "high", limit: 10, offset: 0 }); + expect(out.errors).toHaveLength(1); + expect(out.total).toBe(1); + expect(adminFetch).toHaveBeenCalledWith( + "/api/admin/errors?status=open&severity=high&limit=10&offset=0", + ); + }); + + it("パラメータ無しのときはクエリ文字列を付けない", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [], total: 0, limit: 50, offset: 0 }), { + status: 200, + }), + ); + const out = await getApiErrors(); + expect(out.errors).toEqual([]); + expect(out.total).toBe(0); + expect(adminFetch).toHaveBeenCalledWith("/api/admin/errors"); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "boom" }), { status: 500 }), + ); + await expect(getApiErrors()).rejects.toThrow(/boom/); + }); +}); + +describe("getApiErrorById", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら row を返し、id を URL エンコードする", async () => { + const rowNeedsEncoding = { + ...sampleErrorRow, + id: "550e8400-e29b-41d4-a716-446655440000/with+reserved", + }; + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ apiError: rowNeedsEncoding }), { status: 200 }), + ); + const out = await getApiErrorById(rowNeedsEncoding.id); + expect(out).toEqual(rowNeedsEncoding); + expect(adminFetch).toHaveBeenCalledWith( + `/api/admin/errors/${encodeURIComponent(rowNeedsEncoding.id)}`, + ); + }); +}); + +describe("patchApiErrorStatus", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("PATCH に status を載せ、更新後の row を返す", async () => { + const updated = { ...sampleErrorRow, status: "investigating" as const }; + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ apiError: updated }), { status: 200 }), + ); + const out = await patchApiErrorStatus(sampleErrorRow.id, "investigating"); + expect(out.status).toBe("investigating"); + expect(adminFetch).toHaveBeenCalledWith(`/api/admin/errors/${sampleErrorRow.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "investigating" }), + }); + }); + + it("409 で throw する(並行更新競合)", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "status changed concurrently; refetch and retry" }), { + status: 409, + }), + ); + await expect(patchApiErrorStatus(sampleErrorRow.id, "resolved")).rejects.toThrow( + /status changed concurrently/, + ); + }); +}); diff --git a/admin/src/api/admin.ts b/admin/src/api/admin.ts index 8f712220..14d30afa 100644 --- a/admin/src/api/admin.ts +++ b/admin/src/api/admin.ts @@ -338,6 +338,157 @@ export async function deleteUser(id: string): Promise<{ user: UserAdminBase }> { return res.json(); } +/** API エラーのワークフロー状態 / Workflow status for an API error */ +export type ApiErrorStatus = "open" | "investigating" | "resolved" | "ignored"; + +/** + * UI のセレクト・タブ等で参照する `ApiErrorStatus` の全値。型定義から逸脱しない + * よう Single source of truth として `@/api/admin` に置く。 + * + * Single source of truth for the full set of `ApiErrorStatus` values consumed + * by UI (select dropdowns, tabs, status badges). + */ +export const API_ERROR_STATUS_VALUES: readonly ApiErrorStatus[] = [ + "open", + "investigating", + "resolved", + "ignored", +]; + +/** API エラーの重大度 / Severity assigned by AI analysis */ +export type ApiErrorSeverity = "high" | "medium" | "low" | "unknown"; + +/** + * `ApiErrorSeverity` の全値(UI フィルターで使用)。 + * Full set of `ApiErrorSeverity` values used by UI filters. + */ +export const API_ERROR_SEVERITY_VALUES: readonly ApiErrorSeverity[] = [ + "high", + "medium", + "low", + "unknown", +]; + +/** + * 一覧でアクティブ(未対応扱い)として件数バッジに数えるステータス。 + * ナビバッジ表示やフィルター初期値の Single source of truth。 + * + * Statuses considered "active" (untriaged work) by the navigation badge. + * Single source of truth shared between Layout and the errors page. + */ +export const ACTIVE_API_ERROR_STATUSES: readonly ApiErrorStatus[] = ["open", "investigating"]; + +/** + * AI が推定した「関連しそうなファイル」のエントリ。 + * Suspected file entry produced by the AI analysis step. + */ +export interface ApiErrorSuspectedFile { + path: string; + reason?: string; + line?: number; +} + +/** + * `api_errors` 行のクライアント表現。タイムスタンプは ISO 文字列で扱う。 + * Client-side shape of an `api_errors` row. Timestamps are ISO strings. + */ +export interface ApiErrorRow { + id: string; + sentryIssueId: string; + fingerprint: string | null; + title: string; + route: string | null; + statusCode: number | null; + occurrences: number; + firstSeenAt: string; + lastSeenAt: string; + severity: ApiErrorSeverity; + status: ApiErrorStatus; + aiSummary: string | null; + aiSuspectedFiles: ApiErrorSuspectedFile[] | null; + aiRootCause: string | null; + aiSuggestedFix: string | null; + githubIssueNumber: number | null; + createdAt: string; + updatedAt: string; +} + +/** `GET /api/admin/errors` のクエリパラメータ / Query params for error list API */ +export interface GetApiErrorsParams { + status?: ApiErrorStatus; + severity?: ApiErrorSeverity; + limit?: number; + offset?: number; +} + +/** API エラー一覧のレスポンス / Response shape for error list API */ +export interface GetApiErrorsResponse { + errors: ApiErrorRow[]; + total: number; + limit: number; + offset: number; +} + +/** + * 管理画面用の API エラー一覧を取得する。 + * Fetches the `api_errors` list for the admin errors page. + * + * @param params - フィルタ・ページネーション / Filters and pagination + * @returns 行配列・総件数・適用された limit/offset / Rows, total count, and applied limit/offset + */ +export async function getApiErrors(params?: GetApiErrorsParams): Promise<GetApiErrorsResponse> { + const sp = new URLSearchParams(); + if (params?.status) sp.set("status", params.status); + if (params?.severity) sp.set("severity", params.severity); + if (params?.limit != null) sp.set("limit", String(params.limit)); + if (params?.offset != null) sp.set("offset", String(params.offset)); + const qs = sp.toString(); + const res = await adminFetch(`/api/admin/errors${qs ? `?${qs}` : ""}`); + if (!res.ok) { + throw new Error(await getErrorMessage(res, "Failed to fetch API errors")); + } + return res.json(); +} + +/** + * 単一の API エラー詳細を取得する。 + * Fetches a single `api_errors` row by id. + * + * @param id - 行 ID / Row id (UUID) + * @returns 詳細行 / Detail row + */ +export async function getApiErrorById(id: string): Promise<ApiErrorRow> { + const res = await adminFetch(`/api/admin/errors/${encodeURIComponent(id)}`); + if (!res.ok) { + throw new Error(await getErrorMessage(res, "Failed to fetch API error")); + } + const data: { apiError: ApiErrorRow } = await res.json(); + return data.apiError; +} + +/** + * API エラーのワークフロー状態を更新する。 + * Updates the workflow `status` of an `api_errors` row. + * + * @param id - 行 ID / Row id + * @param status - 遷移先の状態 / Next status + * @returns 更新後の行 / Updated row + */ +export async function patchApiErrorStatus( + id: string, + status: ApiErrorStatus, +): Promise<ApiErrorRow> { + const res = await adminFetch(`/api/admin/errors/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify({ status }), + }); + if (!res.ok) { + throw new Error(await getErrorMessage(res, "Failed to update API error status")); + } + const data: { apiError: ApiErrorRow } = await res.json(); + return data.apiError; +} + /** * 監査ログ 1 行。 * A single admin audit log row as returned by `GET /api/admin/audit-logs`. diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts index 5d439aee..df55cadc 100644 --- a/admin/src/api/client.ts +++ b/admin/src/api/client.ts @@ -5,7 +5,22 @@ const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; -function getApiUrl(path: string): string { +/** + * パスを `VITE_API_BASE_URL` と結合して絶対 URL(または相対パス)を返す。 + * 本番では admin が Cloudflare Pages、API が `https://api.zedi-note.app` + * など別オリジンになるため、`fetch` だけでなく `EventSource` などの + * リクエスト URL もこの関数で組み立てる必要がある。 + * + * Resolve a path against `VITE_API_BASE_URL`. Production splits the admin + * origin (Cloudflare Pages) from the API origin + * (`https://api.zedi-note.app`), so EventSource and other non-`adminFetch` + * consumers must use this helper or they'll hit the wrong host. + * + * @param path - API パス(先頭の `/` は任意)/ API path (leading `/` optional) + * @returns 絶対 URL(base 設定時)または同一オリジンのパス / + * Absolute URL when a base is set, otherwise the same-origin path + */ +export function getApiUrl(path: string): string { const normalized = path.startsWith("/") ? path : `/${path}`; return baseUrl ? `${baseUrl.replace(/\/$/, "")}${normalized}` : normalized; } diff --git a/admin/src/components/ErrorBoundary.test.tsx b/admin/src/components/ErrorBoundary.test.tsx new file mode 100644 index 00000000..81f78006 --- /dev/null +++ b/admin/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ErrorBoundary } from "./ErrorBoundary"; + +const captureException = vi.fn(); +vi.mock("@/lib/sentry", () => ({ + captureException: (error: unknown, context?: { extra?: Record<string, unknown> }) => + captureException(error, context), +})); + +/** + * 任意の `Error` を render 時に投げるテスト用コンポーネント。 + * Helper that throws on render so the boundary's catch path executes. + */ +function Boom({ error }: { error: Error }): never { + throw error; +} + +describe("ErrorBoundary", () => { + beforeEach(() => { + captureException.mockReset(); + // React ボイラープレートのコンソール出力を抑制(テスト出力を読みやすく)。 + // Suppress React's expected error log so test output stays readable. + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("renders children when no error is thrown", () => { + render( + <ErrorBoundary> + <div>safe content</div> + </ErrorBoundary>, + ); + expect(screen.getByText("safe content")).toBeInTheDocument(); + }); + + it("unmounts the failing subtree and renders the fallback when a child throws", () => { + const error = new Error("boom!"); + render( + <ErrorBoundary + fallback={({ error: caught }) => <div role="alert">caught: {caught.message}</div>} + > + <Boom error={error} /> + </ErrorBoundary>, + ); + + // フォールバックが描画され、子ツリーは unmount されている。 + // The fallback renders and the failing subtree is gone. + expect(screen.getByRole("alert")).toHaveTextContent("caught: boom!"); + }); + + it("forwards the caught exception and component stack to Sentry", () => { + const error = new Error("explode"); + render( + <ErrorBoundary fallback={() => <div role="alert">fallback</div>}> + <Boom error={error} /> + </ErrorBoundary>, + ); + + expect(captureException).toHaveBeenCalledTimes(1); + const [forwardedError, context] = captureException.mock.calls[0]; + expect(forwardedError).toBe(error); + // componentStack は React が提供する文字列。中身は React のバージョンに + // 依存するので具体値ではなく型のみ検証する。 + // The component stack content depends on React internals; verify shape only. + expect(context).toMatchObject({ extra: { componentStack: expect.any(String) } }); + }); + + it("renders a default fallback when no fallback prop is provided", () => { + render( + <ErrorBoundary> + <Boom error={new Error("default-fallback")} /> + </ErrorBoundary>, + ); + + expect(screen.getByRole("alert")).toHaveTextContent(/Something went wrong/); + expect(screen.getByText("default-fallback")).toBeInTheDocument(); + }); +}); diff --git a/admin/src/components/ErrorBoundary.tsx b/admin/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..291d420c --- /dev/null +++ b/admin/src/components/ErrorBoundary.tsx @@ -0,0 +1,81 @@ +import { Component } from "react"; +import type { ErrorInfo, ReactNode } from "react"; +import { captureException } from "@/lib/sentry"; + +interface ErrorBoundaryProps { + /** 通常時に描画する子ツリー / Children rendered while no error has been caught */ + children: ReactNode; + /** + * エラー時に描画するフォールバック。`reset` で内部状態をリセットする。 + * Optional render-prop for the fallback UI; `reset` clears the error so + * children can mount again. + */ + fallback?: (props: { error: Error; reset: () => void }) => ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * 子ツリーで発生した未捕捉例外をキャッチし Sentry に通知する境界。 + * フォールバック未指定時はミニマルなエラーメッセージを描画する。 + * + * Error boundary that catches unhandled exceptions from its subtree, forwards + * them to Sentry via `@/lib/sentry`, and renders a fallback UI in their place. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { + state: ErrorBoundaryState = { error: null }; + + /** React の標準 Error Boundary API。/ React's standard Error Boundary hook. */ + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + /** 例外を Sentry に転送する。/ Forward the caught exception to Sentry. */ + componentDidCatch(error: Error, info: ErrorInfo): void { + // Sentry が未初期化でも落ちないよう、helper 経由でガードする。 + // `componentStack` を `extra` に載せて、どのコンポーネントツリーで発生したかを + // Sentry イベントに残す。 + // + // Route through the helper so an uninitialized Sentry SDK still no-ops cleanly. + // Forward `componentStack` so the Sentry event records which subtree threw. + try { + captureException(error, { + extra: { componentStack: info.componentStack ?? undefined }, + }); + } catch { + // 失敗時もフォールバック描画は継続する。 + // Continue rendering the fallback even if reporting fails. + } + if (import.meta.env.DEV) { + console.error("ErrorBoundary caught:", error, info.componentStack); + } + } + + /** フォールバックから内部状態をクリアする。/ Clear the captured error so children can mount. */ + reset = (): void => { + this.setState({ error: null }); + }; + + /** フォールバック / 子ツリーを切り替えて描画する。/ Render either the fallback or the children. */ + render(): ReactNode { + const { error } = this.state; + if (error) { + if (this.props.fallback) { + return this.props.fallback({ error, reset: this.reset }); + } + return ( + <div role="alert" className="p-4 text-sm"> + <p className="font-semibold">Something went wrong.</p> + <p className="text-muted-foreground mt-1 text-xs">{error.message}</p> + </div> + ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/admin/src/i18n/index.ts b/admin/src/i18n/index.ts index 127bb8c1..821954b7 100644 --- a/admin/src/i18n/index.ts +++ b/admin/src/i18n/index.ts @@ -12,6 +12,7 @@ import jaAudit from "./locales/ja/audit.json"; import jaWikiHealth from "./locales/ja/wikiHealth.json"; import jaActivityLog from "./locales/ja/activityLog.json"; import jaAiModels from "./locales/ja/aiModels.json"; +import jaErrors from "./locales/ja/errors.json"; import enCommon from "./locales/en/common.json"; import enNav from "./locales/en/nav.json"; import enAuth from "./locales/en/auth.json"; @@ -20,6 +21,7 @@ import enAudit from "./locales/en/audit.json"; import enWikiHealth from "./locales/en/wikiHealth.json"; import enActivityLog from "./locales/en/activityLog.json"; import enAiModels from "./locales/en/aiModels.json"; +import enErrors from "./locales/en/errors.json"; const ja = { common: jaCommon, @@ -30,6 +32,7 @@ const ja = { wikiHealth: jaWikiHealth, activityLog: jaActivityLog, aiModels: jaAiModels, + errors: jaErrors, }; const en = { @@ -41,6 +44,7 @@ const en = { wikiHealth: enWikiHealth, activityLog: enActivityLog, aiModels: enAiModels, + errors: enErrors, }; /** diff --git a/admin/src/i18n/locales/en/errors.json b/admin/src/i18n/locales/en/errors.json new file mode 100644 index 00000000..8d0f8444 --- /dev/null +++ b/admin/src/i18n/locales/en/errors.json @@ -0,0 +1,47 @@ +{ + "title": "API Errors", + "empty": "No errors have been reported yet.", + "filters": { + "status": "Status", + "severity": "Severity" + }, + "columns": { + "status": "Status", + "severity": "Severity", + "title": "Title", + "route": "Route", + "occurrences": "Occurrences", + "lastSeen": "Last seen", + "actions": "Actions" + }, + "status": { + "open": "Open", + "investigating": "Investigating", + "resolved": "Resolved", + "ignored": "Ignored" + }, + "severity": { + "high": "High", + "medium": "Medium", + "low": "Low", + "unknown": "Unknown" + }, + "actions": { + "viewDetail": "View detail" + }, + "detail": { + "firstSeen": "First seen", + "lastSeen": "Last seen", + "severity": "Severity", + "statusCode": "HTTP status", + "occurrencesShort_one": "{{count}} occurrence", + "occurrencesShort_other": "{{count}} occurrences", + "aiSummary": "AI summary", + "aiRootCause": "AI root cause", + "aiSuggestedFix": "AI suggested fix", + "suspectedFiles": "Suspected files", + "githubIssue": "Linked GitHub issue: #{{number}}", + "statusUpdate": "Update status", + "save": "Save" + } +} diff --git a/admin/src/i18n/locales/en/nav.json b/admin/src/i18n/locales/en/nav.json index 2c6fdaef..3d34c54c 100644 --- a/admin/src/i18n/locales/en/nav.json +++ b/admin/src/i18n/locales/en/nav.json @@ -2,11 +2,14 @@ "adminPanelTitle": "Zedi Admin", "adminShortTitle": "Admin", "menu": "Menu", + "unreadBadgeAriaLabel_one": "{{count}} unresolved API error", + "unreadBadgeAriaLabel_other": "{{count}} unresolved API errors", "items": { "aiModels": "AI Models", "users": "Users", "auditLogs": "Audit Logs", "wikiHealth": "Wiki Health", - "activityLog": "Activity Log" + "activityLog": "Activity Log", + "errors": "Errors" } } diff --git a/admin/src/i18n/locales/ja/errors.json b/admin/src/i18n/locales/ja/errors.json new file mode 100644 index 00000000..05a73f91 --- /dev/null +++ b/admin/src/i18n/locales/ja/errors.json @@ -0,0 +1,47 @@ +{ + "title": "API エラー", + "empty": "対象のエラーはまだ報告されていません。", + "filters": { + "status": "ステータス", + "severity": "重大度" + }, + "columns": { + "status": "状態", + "severity": "重大度", + "title": "タイトル", + "route": "ルート", + "occurrences": "発生回数", + "lastSeen": "最終発生", + "actions": "操作" + }, + "status": { + "open": "未対応", + "investigating": "調査中", + "resolved": "解決済み", + "ignored": "無視" + }, + "severity": { + "high": "高", + "medium": "中", + "low": "低", + "unknown": "未判定" + }, + "actions": { + "viewDetail": "詳細を見る" + }, + "detail": { + "firstSeen": "初回発生", + "lastSeen": "最終発生", + "severity": "重大度", + "statusCode": "HTTP ステータス", + "occurrencesShort_one": "{{count}} 回発生", + "occurrencesShort_other": "{{count}} 回発生", + "aiSummary": "AI による要約", + "aiRootCause": "AI による原因仮説", + "aiSuggestedFix": "AI による修正方針", + "suspectedFiles": "関連が疑われるファイル", + "githubIssue": "関連 GitHub Issue: #{{number}}", + "statusUpdate": "ステータスを更新", + "save": "保存" + } +} diff --git a/admin/src/i18n/locales/ja/nav.json b/admin/src/i18n/locales/ja/nav.json index a4eb8248..7faa737d 100644 --- a/admin/src/i18n/locales/ja/nav.json +++ b/admin/src/i18n/locales/ja/nav.json @@ -2,11 +2,14 @@ "adminPanelTitle": "Zedi 管理画面", "adminShortTitle": "管理画面", "menu": "メニュー", + "unreadBadgeAriaLabel_one": "未対応の API エラー {{count}} 件", + "unreadBadgeAriaLabel_other": "未対応の API エラー {{count}} 件", "items": { "aiModels": "AI モデル", "users": "ユーザー管理", "auditLogs": "監査ログ", "wikiHealth": "Wiki Health", - "activityLog": "活動ログ" + "activityLog": "活動ログ", + "errors": "エラー" } } diff --git a/admin/src/lib/sentry.ts b/admin/src/lib/sentry.ts new file mode 100644 index 00000000..8f4b2c51 --- /dev/null +++ b/admin/src/lib/sentry.ts @@ -0,0 +1,63 @@ +/** + * 管理画面 (admin) 用の Sentry React SDK 初期化。 + * `VITE_ADMIN_SENTRY_DSN` が未設定なら no-op で動作する。 + * + * Sentry initialization for the admin SPA. No-ops when + * `VITE_ADMIN_SENTRY_DSN` is unset so local dev never reports. + * + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/804 + */ +import * as Sentry from "@sentry/react"; + +let initialized = false; + +/** + * Sentry SDK を初期化する。多重呼び出しは無視する。 + * Initializes the Sentry browser SDK. Subsequent calls are ignored. + * + * @returns 初期化を実行した場合は true、DSN 未設定や二回目以降は false + * / true when init ran, false when skipped + */ +export function initSentry(): boolean { + if (initialized) return false; + const dsn = import.meta.env.VITE_ADMIN_SENTRY_DSN?.trim(); + if (!dsn) return false; + + Sentry.init({ + dsn, + environment: import.meta.env.MODE, + // 管理画面でも PII の自動付与は禁止する(サーバ側と同じポリシー)。 + // Mirror the server-side policy: no automatic PII attachment. + sendDefaultPii: false, + tracesSampleRate: 0, + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + }); + + initialized = true; + return true; +} + +/** + * Sentry へ送る追加コンテキスト(`extra` のみ受け付ける軽量版)。 + * Lightweight subset of Sentry's `CaptureContext` exposed to callers — only + * `extra` is supported so the helper stays easy to mock in tests. + */ +export interface CaptureExtras { + extra?: Record<string, unknown>; +} + +/** + * 任意の例外を Sentry に送信するヘルパー。 + * Helper for forwarding caught exceptions to Sentry. + * + * @param error - 例外オブジェクト / Caught exception + * @param context - `{ extra: {...} }` 形式の追加コンテキスト(任意) + * / Optional `{ extra: {...} }` context attached to the event + */ +export function captureException(error: unknown, context?: CaptureExtras): void { + Sentry.captureException(error, context); +} + +export { Sentry }; diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 0315b359..76d18481 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -1,13 +1,21 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { initSentry } from "./lib/sentry"; import "./i18n"; import "./index.css"; +// Sentry は createRoot より前に初期化する(初回レンダリング時の例外も捕捉するため)。 +// Initialize Sentry before createRoot so first-render exceptions are reported. +initSentry(); + const rootEl = document.getElementById("root"); if (!rootEl) throw new Error("Root element #root not found"); createRoot(rootEl).render( <StrictMode> - <App /> + <ErrorBoundary> + <App /> + </ErrorBoundary> </StrictMode>, ); diff --git a/admin/src/pages/Layout.tsx b/admin/src/pages/Layout.tsx index 604c7525..a4bad156 100644 --- a/admin/src/pages/Layout.tsx +++ b/admin/src/pages/Layout.tsx @@ -1,7 +1,16 @@ import { Outlet, Link, useLocation } from "react-router-dom"; -import { Bot, Users, ScrollText, HeartPulse, Activity } from "lucide-react"; +import { + Bot, + Users, + ScrollText, + HeartPulse, + Activity, + AlertTriangle, + type LucideIcon, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { + Badge, SidebarProvider, Sidebar, SidebarContent, @@ -15,15 +24,73 @@ import { SidebarTrigger, SidebarHeader, } from "@zedi/ui"; +import { useApiErrorActiveCount } from "./errors/useApiErrorActiveCount"; + +interface NavItem { + to: string; + labelKey: string; + icon: LucideIcon; + /** バッジ表示用の件数を返す hook(任意) / Optional hook returning the badge count */ + useBadgeCount?: () => number; +} -const NAV_ITEMS = [ +const NAV_ITEMS: NavItem[] = [ { to: "/ai-models", labelKey: "nav.items.aiModels", icon: Bot }, { to: "/users", labelKey: "nav.items.users", icon: Users }, { to: "/audit-logs", labelKey: "nav.items.auditLogs", icon: ScrollText }, { to: "/wiki-health", labelKey: "nav.items.wikiHealth", icon: HeartPulse }, { to: "/activity-log", labelKey: "nav.items.activityLog", icon: Activity }, + { + to: "/errors", + labelKey: "nav.items.errors", + icon: AlertTriangle, + useBadgeCount: useApiErrorActiveCount, + }, ]; +interface NavLinkProps { + item: NavItem; + isActive: boolean; +} + +/** + * サイドバー 1 項目分のリンク。`useBadgeCount` 指定時はバッジを描画する。 + * Hook ルールを守るために `NavItem` を 1:1 に展開するコンポーネントとして切り出す。 + * + * Renders a single sidebar link, optionally with a numeric badge. Split out so + * the per-item Hook (`useBadgeCount`) is called from a stable component + * position rather than inside `NAV_ITEMS.map`. + */ +function NavLink({ item, isActive }: NavLinkProps) { + const { t } = useTranslation(); + const label = t(item.labelKey); + const Icon = item.icon; + // `useBadgeCount` は描画位置で固定されているため、Rules of Hooks に違反しない。 + // The hook is called from a fixed component position, so Rules of Hooks holds. + const badgeCount = item.useBadgeCount?.() ?? 0; + const showBadge = item.useBadgeCount != null && badgeCount > 0; + + return ( + <SidebarMenuItem> + <SidebarMenuButton asChild isActive={isActive} tooltip={label}> + <Link to={item.to}> + <Icon /> + <span>{label}</span> + {showBadge && ( + <Badge + variant="destructive" + className="ml-auto h-5 min-w-5 justify-center px-1 text-[10px] tabular-nums" + aria-label={t("nav.unreadBadgeAriaLabel", { count: badgeCount })} + > + {badgeCount > 99 ? "99+" : badgeCount} + </Badge> + )} + </Link> + </SidebarMenuButton> + </SidebarMenuItem> + ); +} + /** * 管理画面のレイアウト(サイドバー付き)。 * Admin layout with sidebar navigation. @@ -43,20 +110,11 @@ export default function Layout() { <SidebarGroupLabel>{t("nav.menu")}</SidebarGroupLabel> <SidebarGroupContent> <SidebarMenu> - {NAV_ITEMS.map(({ to, labelKey, icon: Icon }) => { - const label = t(labelKey); + {NAV_ITEMS.map((item) => { const isActive = - location.pathname === to || (to !== "/" && location.pathname.startsWith(to)); - return ( - <SidebarMenuItem key={to}> - <SidebarMenuButton asChild isActive={isActive} tooltip={label}> - <Link to={to}> - <Icon /> - <span>{label}</span> - </Link> - </SidebarMenuButton> - </SidebarMenuItem> - ); + location.pathname === item.to || + (item.to !== "/" && location.pathname.startsWith(item.to)); + return <NavLink key={item.to} item={item} isActive={isActive} />; })} </SidebarMenu> </SidebarGroupContent> diff --git a/admin/src/pages/errors/ErrorDetailDialog.test.tsx b/admin/src/pages/errors/ErrorDetailDialog.test.tsx new file mode 100644 index 00000000..ecb6ce18 --- /dev/null +++ b/admin/src/pages/errors/ErrorDetailDialog.test.tsx @@ -0,0 +1,152 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ErrorDetailDialog } from "./ErrorDetailDialog"; +import type { ApiErrorRow } from "@/api/admin"; + +vi.mock("@zedi/ui", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, + Button: ({ + children, + onClick, + disabled, + variant, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: string; + }) => ( + <button type="button" data-variant={variant} onClick={onClick} disabled={disabled}> + {children} + </button> + ), + Dialog: ({ + open, + onOpenChange, + children, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + }) => + open ? ( + <div data-testid="dialog-root" role="dialog" aria-modal="true"> + <button type="button" data-testid="outside-close" onClick={() => onOpenChange(false)}> + outside + </button> + {children} + </div> + ) : null, + DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => ( + <div className={className}>{children}</div> + ), + DialogHeader: ({ children }: { children: React.ReactNode }) => <header>{children}</header>, + DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>, + DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>, + DialogFooter: ({ children }: { children: React.ReactNode }) => <footer>{children}</footer>, + Select: ({ + value, + onValueChange, + disabled, + }: { + value: string; + onValueChange: (v: string) => void; + disabled?: boolean; + children?: React.ReactNode; + }) => ( + <select + data-testid="status-select" + id="errors-status-update" + value={value} + disabled={disabled} + onChange={(e) => onValueChange(e.target.value)} + > + <option value="open">open</option> + <option value="investigating">investigating</option> + <option value="resolved">resolved</option> + <option value="ignored">ignored</option> + </select> + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>, + SelectItem: ({ children }: { children: React.ReactNode }) => <>{children}</>, + SelectValue: () => null, +})); + +const baseRow: ApiErrorRow = { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-1", + fingerprint: null, + title: "TypeError", + route: "GET /api/x", + statusCode: 500, + occurrences: 1, + firstSeenAt: "2026-05-01T00:00:00Z", + lastSeenAt: "2026-05-04T00:00:00Z", + severity: "high", + status: "open", + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", +}; + +describe("ErrorDetailDialog", () => { + const onClose = vi.fn(); + const onUpdateStatus = vi.fn(); + + beforeEach(() => { + onClose.mockClear(); + onUpdateStatus.mockClear(); + }); + + /** + * キャンセルで閉じたあと同じ行を再度開いたとき、未保存のステータス選択が残らないこと。 + * Cancel clears pending status so reopening the same row does not show a stale draft. + */ + it("discards pending status when Cancel is clicked so reopen shows server status", async () => { + const row = { ...baseRow, status: "open" as const }; + const { rerender } = render( + <ErrorDetailDialog + row={row} + saving={false} + saveError={null} + onClose={onClose} + onUpdateStatus={onUpdateStatus} + />, + ); + + await userEvent.selectOptions(screen.getByTestId("status-select"), "resolved"); + expect(screen.getByTestId("status-select")).toHaveValue("resolved"); + + await userEvent.click(screen.getByRole("button", { name: "キャンセル" })); + expect(onClose).toHaveBeenCalledTimes(1); + + rerender( + <ErrorDetailDialog + row={null} + saving={false} + saveError={null} + onClose={onClose} + onUpdateStatus={onUpdateStatus} + />, + ); + + rerender( + <ErrorDetailDialog + row={row} + saving={false} + saveError={null} + onClose={onClose} + onUpdateStatus={onUpdateStatus} + />, + ); + + expect(screen.getByTestId("status-select")).toHaveValue("open"); + }); +}); diff --git a/admin/src/pages/errors/ErrorDetailDialog.tsx b/admin/src/pages/errors/ErrorDetailDialog.tsx new file mode 100644 index 00000000..0164158b --- /dev/null +++ b/admin/src/pages/errors/ErrorDetailDialog.tsx @@ -0,0 +1,212 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@zedi/ui"; +import type { ApiErrorRow, ApiErrorStatus } from "@/api/admin"; +import { API_ERROR_STATUS_VALUES } from "@/api/admin"; +import { formatDate } from "@/lib/dateUtils"; + +interface ErrorDetailDialogProps { + row: ApiErrorRow | null; + saving: boolean; + saveError: string | null; + onClose: () => void; + onUpdateStatus: (id: string, next: ApiErrorStatus) => Promise<void>; +} + +/** + * 単一の API エラー詳細ダイアログ。AI 解析結果(要約・推定原因・関連ファイル)を + * 表示しつつ、`PATCH /api/admin/errors/:id` でステータスを更新できる。 + * + * Detail dialog for a single API error. Shows AI analysis output (summary, + * suspected files, root cause) and lets the admin update the workflow status + * via `PATCH /api/admin/errors/:id`. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +export function ErrorDetailDialog({ + row, + saving, + saveError, + onClose, + onUpdateStatus, +}: ErrorDetailDialogProps) { + const { t } = useTranslation(); + // 「現在開いている行」をキーに「未保存の status 選択」を持つ。row.id をキーに + // 含めることで、別行に切り替わった瞬間に `pendingStatus` を破棄でき、 + // useEffect での setState(cascading render の原因)を不要にできる。 + // + // Track unsaved status by the currently-open row id; switching rows + // automatically discards the prior selection without an effect that would + // trigger a cascading render. + const [pendingFor, setPendingFor] = useState<{ id: string; status: ApiErrorStatus } | null>(null); + const pendingStatus = pendingFor && row && pendingFor.id === row.id ? pendingFor.status : null; + const setPendingStatus = (next: ApiErrorStatus) => { + if (!row) return; + setPendingFor({ id: row.id, status: next }); + }; + + /** オーバーレイ・ESC と同様に、キャンセルボタンでも未保存選択を破棄する。 */ + const handleClose = () => { + setPendingFor(null); + onClose(); + }; + + if (!row) return null; + + const effectiveStatus: ApiErrorStatus = pendingStatus ?? row.status; + const dirty = pendingStatus !== null && pendingStatus !== row.status; + + const handleSave = async () => { + if (!pendingStatus || pendingStatus === row.status) return; + await onUpdateStatus(row.id, pendingStatus); + }; + + return ( + <Dialog + open={row !== null} + onOpenChange={(open) => { + if (!open) { + handleClose(); + } + }} + > + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="break-all">{row.title}</DialogTitle> + <DialogDescription> + {row.route ? `${row.route} · ` : ""} + {/* `count` は i18next の plural ルール解決にそのまま使われるので number で渡す。 + Pass `count` as a number so i18next plural resolution works (`_one` / `_other`). */} + {t("errors.detail.occurrencesShort", { count: row.occurrences })} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 text-sm"> + <dl className="grid grid-cols-2 gap-3"> + <div> + <dt className="text-muted-foreground text-xs">{t("errors.detail.firstSeen")}</dt> + <dd>{formatDate(row.firstSeenAt)}</dd> + </div> + <div> + <dt className="text-muted-foreground text-xs">{t("errors.detail.lastSeen")}</dt> + <dd>{formatDate(row.lastSeenAt)}</dd> + </div> + <div> + <dt className="text-muted-foreground text-xs">{t("errors.detail.severity")}</dt> + <dd> + <Badge variant="outline">{t(`errors.severity.${row.severity}`)}</Badge> + </dd> + </div> + <div> + <dt className="text-muted-foreground text-xs">{t("errors.detail.statusCode")}</dt> + <dd>{row.statusCode ?? "—"}</dd> + </div> + </dl> + + {row.aiSummary && ( + <section> + <h3 className="text-xs font-semibold tracking-wide text-slate-400 uppercase"> + {t("errors.detail.aiSummary")} + </h3> + <p className="mt-1 whitespace-pre-wrap">{row.aiSummary}</p> + </section> + )} + + {row.aiRootCause && ( + <section> + <h3 className="text-xs font-semibold tracking-wide text-slate-400 uppercase"> + {t("errors.detail.aiRootCause")} + </h3> + <p className="mt-1 whitespace-pre-wrap">{row.aiRootCause}</p> + </section> + )} + + {row.aiSuggestedFix && ( + <section> + <h3 className="text-xs font-semibold tracking-wide text-slate-400 uppercase"> + {t("errors.detail.aiSuggestedFix")} + </h3> + <p className="mt-1 whitespace-pre-wrap">{row.aiSuggestedFix}</p> + </section> + )} + + {row.aiSuspectedFiles && row.aiSuspectedFiles.length > 0 && ( + <section> + <h3 className="text-xs font-semibold tracking-wide text-slate-400 uppercase"> + {t("errors.detail.suspectedFiles")} + </h3> + <ul className="text-muted-foreground mt-1 list-inside list-disc space-y-0.5 text-xs"> + {row.aiSuspectedFiles.map((file, idx) => ( + <li key={`${file.path}-${idx}`}> + <code>{file.path}</code> + {file.line != null ? `:${file.line}` : ""} + {file.reason ? ` — ${file.reason}` : ""} + </li> + ))} + </ul> + </section> + )} + + {row.githubIssueNumber != null && ( + <p className="text-muted-foreground text-xs"> + {t("errors.detail.githubIssue", { number: row.githubIssueNumber })} + </p> + )} + + <section> + <label + htmlFor="errors-status-update" + className="mb-1 block text-xs font-semibold tracking-wide text-slate-400 uppercase" + > + {t("errors.detail.statusUpdate")} + </label> + <Select + value={effectiveStatus} + onValueChange={(v) => setPendingStatus(v as ApiErrorStatus)} + disabled={saving} + > + <SelectTrigger id="errors-status-update" className="w-full sm:w-[220px]"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {API_ERROR_STATUS_VALUES.map((value) => ( + <SelectItem key={value} value={value}> + {t(`errors.status.${value}`)} + </SelectItem> + ))} + </SelectContent> + </Select> + {saveError && ( + <p role="alert" className="mt-2 text-xs text-red-300"> + {saveError} + </p> + )} + </section> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={handleClose} disabled={saving}> + {t("common.cancel")} + </Button> + <Button type="button" onClick={handleSave} disabled={!dirty || saving}> + {saving ? t("common.saving") : t("errors.detail.save")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/admin/src/pages/errors/ErrorsContent.test.tsx b/admin/src/pages/errors/ErrorsContent.test.tsx new file mode 100644 index 00000000..d057eca5 --- /dev/null +++ b/admin/src/pages/errors/ErrorsContent.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ErrorsContent } from "./ErrorsContent"; +import type { ApiErrorRow } from "@/api/admin"; + +vi.mock("@zedi/ui", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + <span data-testid="badge">{children}</span> + ), + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + <button type="button" onClick={onClick} disabled={disabled}> + {children} + </button> + ), + Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => ( + <option value={value}>{children}</option> + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => <span>{children}</span>, + SelectValue: () => null, + Table: ({ children }: { children: React.ReactNode }) => <table>{children}</table>, + TableBody: ({ children }: { children: React.ReactNode }) => <tbody>{children}</tbody>, + TableCell: ({ children }: { children: React.ReactNode }) => <td>{children}</td>, + TableHead: ({ children }: { children: React.ReactNode }) => <th>{children}</th>, + TableHeader: ({ children }: { children: React.ReactNode }) => <thead>{children}</thead>, + TableRow: ({ children }: { children: React.ReactNode }) => <tr>{children}</tr>, +})); + +vi.mock("@/lib/dateUtils", () => ({ + formatDate: (d: string) => d, + formatNumber: (n: number) => n.toLocaleString("ja-JP"), + getActiveLocale: () => "ja-JP" as const, +})); + +const baseRow: ApiErrorRow = { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-1", + fingerprint: null, + title: "TypeError: cannot read properties of null", + route: "GET /api/users/:id", + statusCode: 500, + occurrences: 42, + firstSeenAt: "2026-05-01T00:00:00Z", + lastSeenAt: "2026-05-04T00:00:00Z", + severity: "high", + status: "open", + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", +}; + +const defaultProps = { + rows: [baseRow], + total: 1, + loading: false, + error: null, + statusFilter: "all" as const, + severityFilter: "all" as const, + onStatusFilterChange: vi.fn(), + onSeverityFilterChange: vi.fn(), + onSelect: vi.fn(), +}; + +describe("ErrorsContent", () => { + it("renders the page title", () => { + render(<ErrorsContent {...defaultProps} />); + expect(screen.getByRole("heading", { name: "API エラー" })).toBeInTheDocument(); + }); + + it("shows the empty-state message when rows is empty and not loading", () => { + render(<ErrorsContent {...defaultProps} rows={[]} total={0} />); + expect(screen.getByText("対象のエラーはまだ報告されていません。")).toBeInTheDocument(); + }); + + it("renders one row with title, route, and occurrences", () => { + render(<ErrorsContent {...defaultProps} />); + expect(screen.getByText(baseRow.title)).toBeInTheDocument(); + if (baseRow.route) { + expect(screen.getByText(baseRow.route)).toBeInTheDocument(); + } + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("HTTP 500")).toBeInTheDocument(); + }); + + it("invokes onSelect with the row when 詳細を見る is clicked", async () => { + const onSelect = vi.fn(); + render(<ErrorsContent {...defaultProps} onSelect={onSelect} />); + + await userEvent.click(screen.getByRole("button", { name: "詳細を見る" })); + expect(onSelect).toHaveBeenCalledWith(baseRow); + }); + + it("renders the loading message when loading and no rows are present yet", () => { + render(<ErrorsContent {...defaultProps} rows={[]} total={0} loading={true} />); + expect(screen.getByText("読み込み中...")).toBeInTheDocument(); + }); + + it("renders the error message in an alert region", () => { + render(<ErrorsContent {...defaultProps} error="server is down" />); + expect(screen.getByRole("alert")).toHaveTextContent("server is down"); + }); +}); diff --git a/admin/src/pages/errors/ErrorsContent.tsx b/admin/src/pages/errors/ErrorsContent.tsx new file mode 100644 index 00000000..7ea9b0be --- /dev/null +++ b/admin/src/pages/errors/ErrorsContent.tsx @@ -0,0 +1,247 @@ +import { useTranslation } from "react-i18next"; +import { + Badge, + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@zedi/ui"; +import type { ApiErrorRow, ApiErrorSeverity, ApiErrorStatus } from "@/api/admin"; +import { API_ERROR_SEVERITY_VALUES, API_ERROR_STATUS_VALUES } from "@/api/admin"; +import { formatDate, formatNumber } from "@/lib/dateUtils"; + +const ANY = "__any__"; + +interface ErrorsContentProps { + rows: ApiErrorRow[]; + total: number; + loading: boolean; + error: string | null; + statusFilter: ApiErrorStatus | "all"; + severityFilter: ApiErrorSeverity | "all"; + onStatusFilterChange: (next: ApiErrorStatus | "all") => void; + onSeverityFilterChange: (next: ApiErrorSeverity | "all") => void; + onSelect: (row: ApiErrorRow) => void; +} + +/** + * `status` ごとのバッジ色(テーマトークンで揃える)。 + * Status badge variants matching the workflow semantics. + */ +function StatusBadge({ status }: { status: ApiErrorStatus }) { + const { t } = useTranslation(); + const label = t(`errors.status.${status}`); + switch (status) { + case "open": + return <Badge variant="destructive">{label}</Badge>; + case "investigating": + return ( + <Badge variant="outline" className="border-yellow-600 text-yellow-400"> + {label} + </Badge> + ); + case "resolved": + return ( + <Badge variant="outline" className="border-green-600 text-green-400"> + {label} + </Badge> + ); + case "ignored": + return <Badge variant="secondary">{label}</Badge>; + default: + return <Badge variant="outline">{status}</Badge>; + } +} + +/** + * `severity` ごとのバッジ色。`unknown` は AI 解析未完了の暫定値。 + * Severity badge variants; `unknown` is the pre-analysis default. + */ +function SeverityBadge({ severity }: { severity: ApiErrorSeverity }) { + const { t } = useTranslation(); + const label = t(`errors.severity.${severity}`); + switch (severity) { + case "high": + return <Badge variant="destructive">{label}</Badge>; + case "medium": + return ( + <Badge variant="outline" className="border-orange-600 text-orange-400"> + {label} + </Badge> + ); + case "low": + return ( + <Badge variant="outline" className="border-blue-600 text-blue-400"> + {label} + </Badge> + ); + default: + return <Badge variant="secondary">{label}</Badge>; + } +} + +/** + * 管理画面「エラー一覧」のプレゼンテーション層。データ取得・状態管理は + * コンテナ (index.tsx) に分離する。 + * + * Presentational layer for the admin errors list. Data fetching and state + * management live in the container (`index.tsx`). + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +export function ErrorsContent({ + rows, + total, + loading, + error, + statusFilter, + severityFilter, + onStatusFilterChange, + onSeverityFilterChange, + onSelect, +}: ErrorsContentProps) { + const { t } = useTranslation(); + + return ( + <div> + <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> + <h1 className="text-lg font-semibold">{t("errors.title")}</h1> + </div> + + <div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"> + <div> + <label + htmlFor="errors-filter-status" + className="mb-1 block text-xs text-slate-400" + id="errors-filter-status-label" + > + {t("errors.filters.status")} + </label> + <Select + value={statusFilter === "all" ? ANY : statusFilter} + onValueChange={(v) => onStatusFilterChange(v === ANY ? "all" : (v as ApiErrorStatus))} + > + <SelectTrigger id="errors-filter-status" aria-labelledby="errors-filter-status-label"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value={ANY}>{t("common.all")}</SelectItem> + {API_ERROR_STATUS_VALUES.map((value) => ( + <SelectItem key={value} value={value}> + {t(`errors.status.${value}`)} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div> + <label + htmlFor="errors-filter-severity" + className="mb-1 block text-xs text-slate-400" + id="errors-filter-severity-label" + > + {t("errors.filters.severity")} + </label> + <Select + value={severityFilter === "all" ? ANY : severityFilter} + onValueChange={(v) => + onSeverityFilterChange(v === ANY ? "all" : (v as ApiErrorSeverity)) + } + > + <SelectTrigger + id="errors-filter-severity" + aria-labelledby="errors-filter-severity-label" + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value={ANY}>{t("common.all")}</SelectItem> + {API_ERROR_SEVERITY_VALUES.map((value) => ( + <SelectItem key={value} value={value}> + {t(`errors.severity.${value}`)} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + {error && ( + <div role="alert" className="mt-2 rounded bg-red-900/30 px-3 py-2 text-sm text-red-200"> + {error} + </div> + )} + + {loading && rows.length === 0 ? ( + <p className="mt-4 text-slate-400">{t("common.loading")}</p> + ) : rows.length === 0 ? ( + <p className="mt-4 text-slate-400">{t("errors.empty")}</p> + ) : ( + <> + <div className="mt-4 overflow-x-auto"> + <Table className="border-border min-w-[720px] rounded border"> + <TableHeader> + <TableRow className="border-border bg-muted/50 hover:bg-transparent"> + <TableHead className="px-3 py-2">{t("errors.columns.status")}</TableHead> + <TableHead className="px-3 py-2">{t("errors.columns.severity")}</TableHead> + <TableHead className="px-3 py-2">{t("errors.columns.title")}</TableHead> + <TableHead className="px-3 py-2">{t("errors.columns.route")}</TableHead> + <TableHead className="px-3 py-2">{t("errors.columns.occurrences")}</TableHead> + <TableHead className="px-3 py-2">{t("errors.columns.lastSeen")}</TableHead> + <TableHead className="px-3 py-2">{t("errors.columns.actions")}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {rows.map((row) => ( + <TableRow key={row.id} className="border-border align-top"> + <TableCell className="px-3 py-2"> + <StatusBadge status={row.status} /> + </TableCell> + <TableCell className="px-3 py-2"> + <SeverityBadge severity={row.severity} /> + </TableCell> + <TableCell className="px-3 py-2"> + <div className="text-sm font-medium">{row.title}</div> + {row.statusCode != null && ( + <div className="text-muted-foreground text-xs">HTTP {row.statusCode}</div> + )} + </TableCell> + <TableCell className="text-muted-foreground px-3 py-2 text-xs"> + {row.route ?? "—"} + </TableCell> + <TableCell className="px-3 py-2 tabular-nums"> + {formatNumber(row.occurrences)} + </TableCell> + <TableCell className="text-muted-foreground px-3 py-2 whitespace-nowrap"> + {formatDate(row.lastSeenAt)} + </TableCell> + <TableCell className="px-3 py-2"> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => onSelect(row)} + > + {t("errors.actions.viewDetail")} + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + <p className="mt-2 text-xs text-slate-500">{t("common.totalCount", { count: total })}</p> + </> + )} + </div> + ); +} diff --git a/admin/src/pages/errors/index.tsx b/admin/src/pages/errors/index.tsx new file mode 100644 index 00000000..42ab48be --- /dev/null +++ b/admin/src/pages/errors/index.tsx @@ -0,0 +1,84 @@ +import { useCallback, useState } from "react"; +import type { ApiErrorRow, ApiErrorSeverity, ApiErrorStatus } from "@/api/admin"; +import { patchApiErrorStatus } from "@/api/admin"; +import { ErrorsContent } from "./ErrorsContent"; +import { ErrorDetailDialog } from "./ErrorDetailDialog"; +import { useApiErrors } from "./useApiErrors"; + +const PAGE_SIZE = 50; + +/** + * 管理画面「エラー一覧」のコンテナ。`useApiErrors` でポーリング取得しつつ、 + * 詳細ダイアログ・ステータス更新を組み合わせる。 + * + * Container for the admin errors page. Pulls list data with `useApiErrors` + * (Phase 1 polling) and orchestrates the detail dialog + status mutations. + * + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/804 + */ +export default function Errors() { + const [statusFilter, setStatusFilter] = useState<ApiErrorStatus | "all">("all"); + const [severityFilter, setSeverityFilter] = useState<ApiErrorSeverity | "all">("all"); + const [selected, setSelected] = useState<ApiErrorRow | null>(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState<string | null>(null); + + const { errors, total, loading, error, refetch } = useApiErrors({ + status: statusFilter === "all" ? undefined : statusFilter, + severity: severityFilter === "all" ? undefined : severityFilter, + limit: PAGE_SIZE, + }); + + const handleSelect = useCallback((row: ApiErrorRow) => { + setSelected(row); + setSaveError(null); + }, []); + + const handleClose = useCallback(() => { + setSelected(null); + setSaveError(null); + }, []); + + const handleUpdateStatus = useCallback( + async (id: string, next: ApiErrorStatus) => { + setSaving(true); + setSaveError(null); + try { + const updated = await patchApiErrorStatus(id, next); + // 更新後の最新値を即時反映するため、ダイアログ内の選択状態も書き換える。 + // Sync the dialog state with the server's authoritative row. + setSelected(updated); + await refetch(); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }, + [refetch], + ); + + return ( + <> + <ErrorsContent + rows={errors} + total={total} + loading={loading} + error={error} + statusFilter={statusFilter} + severityFilter={severityFilter} + onStatusFilterChange={setStatusFilter} + onSeverityFilterChange={setSeverityFilter} + onSelect={handleSelect} + /> + <ErrorDetailDialog + row={selected} + saving={saving} + saveError={saveError} + onClose={handleClose} + onUpdateStatus={handleUpdateStatus} + /> + </> + ); +} diff --git a/admin/src/pages/errors/useApiErrorActiveCount.ts b/admin/src/pages/errors/useApiErrorActiveCount.ts new file mode 100644 index 00000000..5ab9acac --- /dev/null +++ b/admin/src/pages/errors/useApiErrorActiveCount.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from "react"; +import { ACTIVE_API_ERROR_STATUSES, getApiErrors } from "@/api/admin"; +import { API_ERRORS_POLL_INTERVAL_MS } from "./useApiErrors"; + +/** + * サイドバーのバッジ用に「未対応 (`open` + `investigating`) 件数」だけを軽量に取得する。 + * + * `getApiErrors({ status, limit: 1 })` を `ACTIVE_API_ERROR_STATUSES` ごとに 1 回ずつ + * 叩き、レスポンスの `total` のみ合算する。行データを使わないので最小トラフィックで済む。 + * + * Lightweight count of "active" errors for the sidebar badge. Issues one + * `limit:1` call per active status and sums the `total` fields, so we only + * pay the cost of the COUNT query — not the row payload. + * + * @returns 初期値は 0。取得成功で値を更新し、失敗時は直前の取得値を維持する + * (誤った 0 表示でフラッシュしないため)。 + * / Starts at 0; updates on successful fetches and preserves the last + * successful value when a refresh fails (so the badge does not flash + * to 0 on transient errors). + */ +export function useApiErrorActiveCount(): number { + const [count, setCount] = useState(0); + const isMountedRef = useRef(true); + // ポーリングと visibilitychange の発火が重なると、遅い古いリクエストが + // 新しい結果を上書きする恐れがある。`useApiErrors` と同じパターンで + // 「最新リクエスト ID」だけを採用するようガードする。 + // + // Polling and visibilitychange can race; a slow earlier response would + // otherwise overwrite a fresher one. Mirror `useApiErrors`' pattern and + // keep only the latest request's result. + const latestRequestRef = useRef(0); + + useEffect(() => { + isMountedRef.current = true; + + const fetchCount = async () => { + const requestId = ++latestRequestRef.current; + try { + const results = await Promise.all( + ACTIVE_API_ERROR_STATUSES.map((status) => getApiErrors({ status, limit: 1 })), + ); + if (!isMountedRef.current || requestId !== latestRequestRef.current) return; + const total = results.reduce((sum, r) => sum + r.total, 0); + setCount(total); + } catch { + // バッジ取得失敗時は表示を更新しない(古い値を維持して誤った 0 表示を避ける)。 + // Swallow errors to keep stale-but-correct count rather than flashing 0. + } + }; + + // 初回ブートストラップもポーリングと同じ可視性ガードに従わせる。バックグラウンド + // タブで mount された際に fan-out リクエストを走らせない。 + // Apply the same visibility guard to the bootstrap fetch so a tab that + // mounts while hidden does not pay the full fan-out before any tick fires. + if (typeof document === "undefined" || !document.hidden) { + void fetchCount(); + } + const id = window.setInterval(() => { + if (typeof document !== "undefined" && document.hidden) return; + void fetchCount(); + }, API_ERRORS_POLL_INTERVAL_MS); + + const onVisible = () => { + if (typeof document !== "undefined" && !document.hidden) { + void fetchCount(); + } + }; + document.addEventListener("visibilitychange", onVisible); + + return () => { + isMountedRef.current = false; + // unmount 後の遅延応答も確実に破棄するため request id を進めておく。 + // Bump the request id on unmount so any in-flight response is discarded. + latestRequestRef.current += 1; + window.clearInterval(id); + document.removeEventListener("visibilitychange", onVisible); + }; + }, []); + + return count; +} diff --git a/admin/src/pages/errors/useApiErrors.test.ts b/admin/src/pages/errors/useApiErrors.test.ts new file mode 100644 index 00000000..0eafe740 --- /dev/null +++ b/admin/src/pages/errors/useApiErrors.test.ts @@ -0,0 +1,246 @@ +/** + * `useApiErrors` の単体テスト。 + * SSE で push された行を初期取得結果にマージできること、フィルタ非該当な行を + * 無視すること、アンマウント時に EventSource を close することを検証する。 + * + * Unit tests for `useApiErrors`. Verifies that pushed SSE rows merge into the + * REST-bootstrapped list, filter mismatches are ignored, and the EventSource + * is closed on unmount (no fd leaks). + * + * @see ./useApiErrors.ts + * @see https://github.com/otomatty/zedi/issues/807 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ApiErrorRow, GetApiErrorsResponse } from "@/api/admin"; + +// `getApiErrors` を mock してネットワーク呼び出しを避ける。 +// Mock the REST helper so the hook's bootstrap fetch returns a fixed payload. +vi.mock("@/api/admin", async (orig) => { + const actual = await orig<typeof import("@/api/admin")>(); + return { + ...actual, + getApiErrors: vi.fn(), + }; +}); + +const { getApiErrors } = await import("@/api/admin"); +const { useApiErrors } = await import("./useApiErrors"); + +type Listener = (event: MessageEvent<string>) => void; + +interface FakeEventSource { + url: string; + withCredentials: boolean; + closed: boolean; + close: () => void; + onerror: ((ev: Event) => void) | null; + addEventListener: (type: string, cb: Listener) => void; + dispatch: (type: string, payload: unknown) => void; +} + +let lastInstance: FakeEventSource | null = null; + +function createFakeEventSourceCtor(): typeof EventSource { + // テスト用の最小限の EventSource 互換 stub。`new` で生成された各インスタンスを + // `lastInstance` に保存し、テスト本文から `dispatch` で擬似イベントを送れるようにする。 + // Minimal EventSource stub. Each new instance is stashed in `lastInstance` so + // the test body can dispatch events imperatively without touching `this`. + function build(url: string, init?: { withCredentials?: boolean }): FakeEventSource { + const listeners = new Map<string, Set<Listener>>(); + const instance: FakeEventSource = { + url, + withCredentials: init?.withCredentials ?? false, + closed: false, + onerror: null, + close: () => { + instance.closed = true; + }, + addEventListener: (type, cb) => { + let set = listeners.get(type); + if (!set) { + set = new Set(); + listeners.set(type, set); + } + set.add(cb); + }, + dispatch: (type, payload) => { + const event = { + type, + data: typeof payload === "string" ? payload : JSON.stringify(payload), + } as MessageEvent<string>; + listeners.get(type)?.forEach((cb) => cb(event)); + }, + }; + lastInstance = instance; + return instance; + } + // EventSource は `new` で呼ばれるので、関数を `new` 互換に見せかけるラッパで返す。 + // EventSource is constructor-called; wrap `build` so `new EventSource(...)` + // forwards to it. + return function EventSourceCtor(url: string, init?: { withCredentials?: boolean }) { + return build(url, init); + } as unknown as typeof EventSource; +} + +function getInstance(): FakeEventSource { + if (!lastInstance) throw new Error("EventSource has not been instantiated"); + return lastInstance; +} + +const FIXED_RESPONSE: GetApiErrorsResponse = { + errors: [ + { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-1", + fingerprint: null, + title: "old error", + route: "GET /api/foo", + statusCode: 500, + occurrences: 1, + firstSeenAt: "2026-05-01T00:00:00Z", + lastSeenAt: "2026-05-04T00:00:00Z", + severity: "unknown", + status: "open", + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", + }, + ], + total: 1, + limit: 50, + offset: 0, +}; + +function getBaseRow(): ApiErrorRow { + const base = FIXED_RESPONSE.errors[0]; + if (!base) throw new Error("FIXED_RESPONSE must have at least one row"); + return base; +} + +function makePushed(overrides: Partial<ApiErrorRow> = {}): ApiErrorRow { + return { + ...getBaseRow(), + id: "00000000-0000-0000-0000-0000000000aa", + title: "new pushed error", + ...overrides, + }; +} + +beforeEach(() => { + vi.mocked(getApiErrors).mockResolvedValue(FIXED_RESPONSE); + lastInstance = null; + // jsdom には EventSource が無いので global に注入する。 + // jsdom doesn't ship EventSource; install our fake on globalThis. + (globalThis as unknown as { EventSource: typeof EventSource }).EventSource = + createFakeEventSourceCtor(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + // EventSource を消して次のテストの環境を汚染しないようにする。 + // Strip our fake so the next test boots from a clean slate. + delete (globalThis as { EventSource?: unknown }).EventSource; +}); + +describe("useApiErrors", () => { + it("loads via REST and exposes the rows", async () => { + const { result } = renderHook(() => useApiErrors({ intervalMs: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.errors).toHaveLength(1); + expect(result.current.total).toBe(1); + }); + + it("opens an EventSource on mount (URL routed through getApiUrl) and closes it on unmount", async () => { + const { unmount } = renderHook(() => useApiErrors({ intervalMs: 0 })); + await waitFor(() => expect(lastInstance).not.toBeNull()); + const es = getInstance(); + // `getApiUrl` 経由なので、`VITE_API_BASE_URL` が空文字でもフルパスは + // `/api/admin/errors/stream` を含む。 + // `getApiUrl` resolves the path; with an empty `VITE_API_BASE_URL` it + // stays same-origin, so the URL still ends with the SSE path. + expect(es.url).toContain("/api/admin/errors/stream"); + expect(es.withCredentials).toBe(true); + unmount(); + expect(es.closed).toBe(true); + }); + + it("merges an `update` SSE event by id and moves the row to the front", async () => { + // 初期一覧に 2 件目の行を返すように mockResolvedValue を上書きし、 + // 2 番目の行を更新したらリストの先頭へ移動することを確認する。 + // Bootstrap with two rows so we can verify the second row is *moved* to + // the front (matching the server's `last_seen_at DESC` ordering) rather + // than replaced in place. + const second: ApiErrorRow = { + ...getBaseRow(), + id: "00000000-0000-0000-0000-0000000000bb", + title: "second", + }; + vi.mocked(getApiErrors).mockResolvedValueOnce({ + errors: [getBaseRow(), second], + total: 2, + limit: 50, + offset: 0, + }); + + const { result } = renderHook(() => useApiErrors({ intervalMs: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + getInstance().dispatch("ready", ""); + }); + + const updatedSecond = { ...second, title: "second (updated)" }; + act(() => { + getInstance().dispatch("update", updatedSecond); + }); + + expect(result.current.errors).toHaveLength(2); + expect(result.current.errors[0]?.id).toBe(second.id); + expect(result.current.errors[0]?.title).toBe("second (updated)"); + expect(result.current.errors[1]?.id).toBe(getBaseRow().id); + expect(result.current.total).toBe(2); + expect(result.current.streamConnected).toBe(true); + }); + + it("prepends a brand-new id and bumps total", async () => { + const { result } = renderHook(() => useApiErrors({ intervalMs: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const fresh = makePushed(); + act(() => { + getInstance().dispatch("ready", ""); + getInstance().dispatch("update", fresh); + }); + + expect(result.current.errors[0]?.id).toBe(fresh.id); + expect(result.current.errors).toHaveLength(2); + expect(result.current.total).toBe(2); + }); + + it("ignores rows that don't match the active filter", async () => { + const { result } = renderHook(() => + useApiErrors({ status: "open", severity: "high", intervalMs: 0 }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const mismatch = makePushed({ status: "resolved", severity: "low" }); + act(() => { + getInstance().dispatch("ready", ""); + getInstance().dispatch("update", mismatch); + }); + + expect(result.current.errors).toHaveLength(1); + expect(result.current.total).toBe(1); + }); + + it("does not open EventSource when enableStream is false", async () => { + renderHook(() => useApiErrors({ intervalMs: 0, enableStream: false })); + await waitFor(() => expect(getApiErrors).toHaveBeenCalled()); + expect(lastInstance).toBeNull(); + }); +}); diff --git a/admin/src/pages/errors/useApiErrors.ts b/admin/src/pages/errors/useApiErrors.ts new file mode 100644 index 00000000..d552cdcb --- /dev/null +++ b/admin/src/pages/errors/useApiErrors.ts @@ -0,0 +1,345 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ApiErrorRow, + ApiErrorSeverity, + ApiErrorStatus, + GetApiErrorsResponse, +} from "@/api/admin"; +import { getApiErrors } from "@/api/admin"; +import { getApiUrl } from "@/api/client"; + +/** + * SSE が利用できない環境(古いブラウザ、ネットワーク制限)でのフォールバック + * ポーリング間隔 (ms)。EventSource が確立した後はサーバーから push されるため + * このタイマーは抑制される。 + * + * Fallback polling interval (ms) used when the SSE stream is unavailable + * (older browsers, network restrictions, server 503 from subscriber cap). Once + * a SSE connection is healthy this timer is suppressed because updates arrive + * via push. + */ +export const API_ERRORS_POLL_INTERVAL_MS = 30_000; + +/** + * SSE 切断時の再接続バックオフの初期値 (ms) と上限 (ms)。 + * 連続失敗で指数バックオフし、上限に到達したらそのまま継続。 + * + * Initial / max backoff (ms) used when the SSE connection drops. We grow the + * delay exponentially up to the cap so a flaky network or server hiccup does + * not cause a reconnect storm. + */ +const SSE_RECONNECT_INITIAL_MS = 1_000; +const SSE_RECONNECT_MAX_MS = 30_000; + +/** + * `useApiErrors` の入力。 + * Inputs accepted by the hook. + */ +export interface UseApiErrorsParams { + status?: ApiErrorStatus; + severity?: ApiErrorSeverity; + limit?: number; + offset?: number; + /** + * フォールバックポーリング間隔 (ms)。0 を渡すとポーリングを完全無効化する + * (テスト用途、もしくは SSE 専用にしたい場合)。 + * Fallback polling interval in ms; pass 0 to disable polling entirely + * (useful for tests or SSE-only environments). + */ + intervalMs?: number; + /** + * SSE エンドポイントを購読しない場合 false。テストや診断用途。 + * Disable the SSE subscription (default: true). Tests can opt out to keep + * EventSource off the wire. + */ + enableStream?: boolean; +} + +/** + * `useApiErrors` の戻り値。 + * Hook return shape. + */ +export interface UseApiErrorsResult { + errors: ApiErrorRow[]; + total: number; + loading: boolean; + error: string | null; + /** SSE が確立しているか(テスト・UI 表示用) / Whether the SSE link is up */ + streamConnected: boolean; + /** 即時再取得(リクエスト中のレースは内部で処理) / Force refresh (race-safe) */ + refetch: () => Promise<void>; +} + +/** + * SSE で受信した 1 行を既存の `errors` 配列にマージする。 + * + * - 既存の ID が見つかった場合は元の位置から取り除いて先頭へ移動する。 + * サーバー側のデフォルト順序が `last_seen_at DESC` なので、更新(再発生・ + * AI 解析完了・状態変更)があった行を先頭に置く方が REST 再取得時の順序 + * と整合する。 + * - 未知の ID なら先頭に追加し、`total` をインクリメントする。 + * - `offset > 0` のページではこの単純マージが完全には正しくないが、 + * 現状 UI は単一ページのみ表示するため許容(将来ページネーション導入時に + * 再考)。 + * + * Merge an SSE-pushed row into the list. Existing ids are *moved* to the + * front (matching the server's `last_seen_at DESC` ordering on a re-fetch), + * and brand-new ids are prepended with `total` bumping by one. Pagination + * (`offset > 0`) isn't fully consistent under this merge, but the current + * UI shows a single page so we accept the trade-off. + */ +function mergeRow(prev: GetApiErrorsResponse | null, row: ApiErrorRow): GetApiErrorsResponse { + if (!prev) { + return { errors: [row], total: 1, limit: 1, offset: 0 }; + } + const filtered = prev.errors.filter((r) => r.id !== row.id); + const isUpdate = filtered.length < prev.errors.length; + return { + ...prev, + errors: [row, ...filtered], + total: isUpdate ? prev.total : prev.total + 1, + }; +} + +/** + * SSE でフィルタに一致しない更新が来たとき、一覧から該当 ID を落とす。 + * + * Drop a row id from the cached list when an SSE update no longer matches the + * active filter (so filtered views do not keep stale rows after status churn). + */ +function dropRowById( + prev: GetApiErrorsResponse | null, + rowId: string, +): GetApiErrorsResponse | null { + if (!prev) return prev; + const idx = prev.errors.findIndex((r) => r.id === rowId); + if (idx === -1) return prev; + return { + ...prev, + errors: prev.errors.filter((r) => r.id !== rowId), + total: Math.max(0, prev.total - 1), + }; +} + +/** + * フィルタ条件と SSE で push された行が合致するかを判定する。 + * 合致しないものは UI に出さない(一覧の意味的な整合を保つ)。 + * + * Decide whether an SSE-pushed row matches the active filter. Mismatches are + * dropped client-side so a status/severity filter doesn't suddenly surface + * rows that wouldn't appear in a fresh REST query. + */ +function matchesFilter( + row: ApiErrorRow, + status: ApiErrorStatus | undefined, + severity: ApiErrorSeverity | undefined, +): boolean { + if (status && row.status !== status) return false; + if (severity && row.severity !== severity) return false; + return true; +} + +/** + * `getApiErrors` で初回・フォールバック取得しつつ、`/api/admin/errors/stream` + * を `EventSource` で購読してリアルタイム更新するフック (Epic #616 Phase 2 / + * issue #807)。 + * + * - 接続成功中は `intervalMs` のポーリングを抑制する。 + * - 切断時は exponential backoff で再接続する。可視タブのみ。 + * - アンマウント時は EventSource を必ず close する(fd リーク防止)。 + * + * Bootstrap the list via REST and subscribe to `/api/admin/errors/stream` for + * push updates (Epic #616 Phase 2 / issue #807). While the SSE link is up the + * fallback poller is suppressed; on disconnect we exponentially back off and + * reconnect (visible tabs only). The EventSource is always closed on unmount + * to prevent file-descriptor leaks. + */ +export function useApiErrors(params: UseApiErrorsParams = {}): UseApiErrorsResult { + const { + status, + severity, + limit, + offset, + intervalMs = API_ERRORS_POLL_INTERVAL_MS, + enableStream = true, + } = params; + + const [data, setData] = useState<GetApiErrorsResponse | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [streamConnected, setStreamConnected] = useState(false); + + const isMountedRef = useRef(true); + const latestRequestRef = useRef(0); + // 最新フィルタを ref に保持して EventSource の onmessage クロージャから参照する。 + // useEffect 依存に含めると接続を貼り直してしまうのを避ける。 + // Keep the latest filter in a ref so the EventSource handler can read the + // current value without forcing the SSE effect to tear down + reconnect on + // every filter change. + const filterRef = useRef({ status, severity }); + filterRef.current = { status, severity }; + + const load = useCallback( + async (showLoading: boolean) => { + const requestId = ++latestRequestRef.current; + if (showLoading && isMountedRef.current) setLoading(true); + try { + const result = await getApiErrors({ status, severity, limit, offset }); + if (!isMountedRef.current || requestId !== latestRequestRef.current) return; + setData(result); + setError(null); + } catch (e) { + if (!isMountedRef.current || requestId !== latestRequestRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (isMountedRef.current && requestId === latestRequestRef.current) { + setLoading(false); + } + } + }, + [status, severity, limit, offset], + ); + + useEffect(() => { + isMountedRef.current = true; + void load(true); + return () => { + isMountedRef.current = false; + }; + }, [load]); + + // SSE 購読。`enableStream` と `EventSource` 利用可否で短絡する。 + // SSE subscription. Short-circuits when EventSource is unavailable (jsdom, + // very old browsers) or when the caller opts out (`enableStream: false`). + useEffect(() => { + if (!enableStream) return; + if (typeof EventSource === "undefined") return; + + let es: EventSource | null = null; + let reconnectTimer: number | null = null; + let cancelled = false; + let backoff = SSE_RECONNECT_INITIAL_MS; + + const open = () => { + if (cancelled) return; + // 本番では admin と API のオリジンが分かれているため、相対パスではなく + // `VITE_API_BASE_URL` を解決した絶対 URL を使う必要がある (PR #816 review)。 + // Production splits the admin origin (Cloudflare Pages) from the API + // origin, so the EventSource URL must go through `getApiUrl` to hit + // the correct host instead of falling back to admin's origin. + es = new EventSource(getApiUrl("/api/admin/errors/stream"), { withCredentials: true }); + + es.addEventListener("ready", () => { + backoff = SSE_RECONNECT_INITIAL_MS; + if (isMountedRef.current) setStreamConnected(true); + }); + + es.addEventListener("update", (rawEvent) => { + const ev = rawEvent as MessageEvent<string>; + let row: ApiErrorRow; + try { + row = JSON.parse(ev.data) as ApiErrorRow; + } catch { + return; + } + const { status: curStatus, severity: curSeverity } = filterRef.current; + if (!matchesFilter(row, curStatus, curSeverity)) { + if (isMountedRef.current) { + setData((prev) => dropRowById(prev, row.id)); + } + return; + } + if (isMountedRef.current) { + setData((prev) => mergeRow(prev, row)); + } + }); + + // EventSource は接続が切れると自動で再接続する。意図しない無限ループを + // 避けるため、`onerror` で一度 close して我々の backoff スケジューラに + // 任せる。 + // EventSource auto-reconnects with a tiny delay, which fights with our + // backoff. Close the socket on error and reschedule ourselves so a + // failing endpoint doesn't hammer the server. + es.onerror = () => { + if (isMountedRef.current) setStreamConnected(false); + es?.close(); + es = null; + if (cancelled) return; + if (typeof document !== "undefined" && document.hidden) { + // 隠しタブでは再接続せず、可視化を待つ。 + // Hidden tabs: defer reconnect until the tab is visible again. + return; + } + reconnectTimer = window.setTimeout(() => { + backoff = Math.min(backoff * 2, SSE_RECONNECT_MAX_MS); + open(); + }, backoff); + }; + }; + + const onVisible = () => { + if (typeof document !== "undefined" && document.hidden) return; + // 可視化されたタイミングで未接続なら即時再接続を試みる。 + // 既存の `reconnectTimer` を必ずクリアしてから接続を張ることで、 + // タイマー fire と immediate-reopen が競合して EventSource が二重に + // 生成されるのを防ぐ。 + // When the tab becomes visible again, eagerly reconnect if we lost the + // stream while hidden. Cancel any pending backoff timer first so a + // racing `setTimeout` callback can't open a second EventSource on top + // of this one (which would leak the older connection). + if (!es) { + if (reconnectTimer != null) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + backoff = SSE_RECONNECT_INITIAL_MS; + open(); + } + }; + + open(); + document.addEventListener("visibilitychange", onVisible); + + return () => { + cancelled = true; + if (reconnectTimer != null) window.clearTimeout(reconnectTimer); + es?.close(); + es = null; + document.removeEventListener("visibilitychange", onVisible); + if (isMountedRef.current) setStreamConnected(false); + }; + }, [enableStream]); + + // フォールバックポーリング: SSE が確立していれば抑制する。 + // Fallback polling: suppressed while SSE is healthy so we don't double-load. + useEffect(() => { + if (intervalMs <= 0) return; + if (streamConnected) return; + const tick = () => { + if (typeof document !== "undefined" && document.hidden) return; + void load(false); + }; + const id = window.setInterval(tick, intervalMs); + const onVisible = () => { + if (typeof document !== "undefined" && !document.hidden) { + void load(false); + } + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [intervalMs, load, streamConnected]); + + const refetch = useCallback(() => load(false), [load]); + + return { + errors: data?.errors ?? [], + total: data?.total ?? 0, + loading, + error, + streamConnected, + refetch, + }; +} diff --git a/bun.lock b/bun.lock index d0aaf490..5ad7318c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,9 @@ "": { "name": "vite_react_shadcn_ts", "dependencies": { - "@anthropic-ai/sdk": "^0.91.1", + "@anthropic-ai/sdk": "^0.96.0", "@dagrejs/dagre": "^3.0.0", - "@google/genai": "^1.34.0", + "@google/genai": "^2.0.1", "@google/generative-ai": "^0.24.1", "@hocuspocus/provider": "^4.0.0", "@hookform/resolvers": "^5.2.2", @@ -40,7 +40,9 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-visually-hidden": "^1.2.3", + "@sentry/react": "^10.51.0", "@tanstack/react-query": "^5.83.0", + "@tanstack/react-virtual": "^3.13.24", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-fs": "^2", @@ -96,8 +98,9 @@ "mermaid": "^11.12.2", "next-themes": "^0.4.6", "openai": "^6.15.0", + "pdfjs-dist": "^5.7.284", "react": "^19.2.4", - "react-day-picker": "^9.14.0", + "react-day-picker": "^10.0.0", "react-dom": "^19.2.4", "react-hook-form": "^7.61.1", "react-i18next": "^17.0.1", @@ -124,8 +127,8 @@ "zustand": "^5.0.9", }, "devDependencies": { - "@commitlint/cli": "^20.4.2", - "@commitlint/config-conventional": "^20.4.2", + "@commitlint/cli": "^21.0.0", + "@commitlint/config-conventional": "^21.0.0", "@eslint/js": "^9.32.0", "@libsql/client": "^0.17.0", "@playwright/test": "^1.57.0", @@ -165,7 +168,7 @@ "pg": "^8.19.0", "postcss": "^8.5.6", "prettier": "^3.8.1", - "prettier-plugin-tailwindcss": "^0.7.2", + "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", "typescript-eslint": "^8.38.0", @@ -178,6 +181,7 @@ "name": "zedi-admin", "version": "0.1.0", "dependencies": { + "@sentry/react": "^10.51.0", "@zedi/ui": "workspace:*", "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", @@ -208,7 +212,7 @@ "name": "@zedi/claude-sidecar", "version": "0.1.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.90", + "@anthropic-ai/claude-agent-sdk": "^0.3.143", }, "devDependencies": { "typescript": "^6.0.2", @@ -261,7 +265,7 @@ "input-otp": "^1.4.2", "lucide-react": "^1.7.0", "next-themes": "^0.4.6", - "react-day-picker": "^9.14.0", + "react-day-picker": "^10.0.0", "react-hook-form": "^7.61.1", "react-resizable-panels": "^4.7.0", "recharts": "^3.8.1", @@ -287,9 +291,25 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.90", "", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-up5bK0pUbthKIZtNE18WDrIYi0KNpZUhdgjGbkfH/mFQJxI6W/uE3mTiLrCX3UF0SqNl0fMtojBTZPJr2b3O4g=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.143", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.143", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.143", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.143", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.143", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.143", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.143", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.143", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.143" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-JnxOTRpSBpFqKFMfV5cW4QjpeDOHUNr31rRislmOVWEPsRmCjsy9jYwKxDf7kxcwxyZ6h/YHz1ACvMaUWy6o6A=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.143", "", { "os": "darwin", "cpu": "arm64" }, "sha512-41WuTuP+bk4NxrjpG9IJGffsjh1ivyiiAmqgb5QoxPltDAA0p3gs+iZ3lTgDmY4Ga68wDoN05Lt18oCE+DQb7g=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.143", "", { "os": "darwin", "cpu": "x64" }, "sha512-3WrJ6MjjwQKvPnPbzUOm08qftlHYobkp/tmM1P/Vk/ldSjoPfFIgLFfzSzUmCKJiKEh2ZlHLJr8ORNrTxXhgQg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.143", "", { "os": "linux", "cpu": "arm64" }, "sha512-/9oP/FCewrPnwVN+QUS5rlO3kMa07w+hOrpWrz24aEpBYhcHzr0zoNMBriPDAkTr3ao/z1k40UZ2dHmgsSODzA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.143", "", { "os": "linux", "cpu": "arm64" }, "sha512-9UeV1W2vjOVwJSJrq9aw3UeMo82Ir59FfJ5mchh7OXZEaevkANvHYn25bTCnIpqfqOx7qFEosJW2ELIoV1nprg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.143", "", { "os": "linux", "cpu": "x64" }, "sha512-kwqnbHo4Zj6TzO1V/83uLhsTt0xBp/BN5V/aHIX+khM4UuNO6NOKNaZvr8Int3sF0ARF95Hjr4l/hMKxry6DhQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.143", "", { "os": "linux", "cpu": "x64" }, "sha512-rr4334GOLl9caYDeyWsbwMaVJCiNvKHE9nLdey8opIkq7/FHHu712U6tDk0tcoCdsGU/S3/BBaZParOgF+s5qw=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.143", "", { "os": "win32", "cpu": "arm64" }, "sha512-q5UaLZ9ABbqQN8UXpqHUqjW6akI1zMrV5Jvtq0yueKP4nIRbBBZBQ80M4bpdrc0+SiRmjVRV3p8lsCCAd8azgg=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.143", "", { "os": "win32", "cpu": "x64" }, "sha512-46L2mkskvIRfwzHP3p0uUE5u9Oc7Eb/DRVmXuGNk5Z8jiahlDSv0SHP1vnWSWw5nWIu4DWOKyXZelRmYc9LxCg=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.96.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-KlCsODtTyb17bLUVCSDC2HtSvAbJf60sEiPEax9dInF+aDF92vS4TZJ5XD7YCQXNb1/5icYaw8Y7wMjPlIV9Zg=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="], @@ -401,39 +421,41 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260301.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A=="], - "@commitlint/cli": ["@commitlint/cli@20.4.2", "", { "dependencies": { "@commitlint/format": "^20.4.0", "@commitlint/lint": "^20.4.2", "@commitlint/load": "^20.4.0", "@commitlint/read": "^20.4.0", "@commitlint/types": "^20.4.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-YjYSX2yj/WsVoxh9mNiymfFS2ADbg2EK4+1WAsMuckwKMCqJ5PDG0CJU/8GvmHWcv4VRB2V02KqSiecRksWqZQ=="], + "@commitlint/cli": ["@commitlint/cli@21.0.0", "", { "dependencies": { "@commitlint/format": "^21.0.0", "@commitlint/lint": "^21.0.0", "@commitlint/load": "^21.0.0", "@commitlint/read": "^21.0.0", "@commitlint/types": "^21.0.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-p3y2oC0G2R45zaadMwBxCiSesS8digi5RDplP3Zrfpzm7xIgrgAj0W4fGzONjpHyg8obDVJDU45g5txzeMcblg=="], - "@commitlint/config-conventional": ["@commitlint/config-conventional@20.4.2", "", { "dependencies": { "@commitlint/types": "^20.4.0", "conventional-changelog-conventionalcommits": "^9.1.0" } }, "sha512-rwkTF55q7Q+6dpSKUmJoScV0f3EpDlWKw2UPzklkLS4o5krMN1tPWAVOgHRtyUTMneIapLeQwaCjn44Td6OzBQ=="], + "@commitlint/config-conventional": ["@commitlint/config-conventional@21.0.0", "", { "dependencies": { "@commitlint/types": "^21.0.0", "conventional-changelog-conventionalcommits": "^9.2.0" } }, "sha512-QJX/rPK4Yu3f5J4OCIBy5aXq2e0EEdwSDFZ3NQvFAXTm3gs12ipyZ+yjhZxm3hHn6DB8wuv3zhFTL1I2tYzUBA=="], - "@commitlint/config-validator": ["@commitlint/config-validator@20.4.0", "", { "dependencies": { "@commitlint/types": "^20.4.0", "ajv": "^8.11.0" } }, "sha512-zShmKTF+sqyNOfAE0vKcqnpvVpG0YX8F9G/ZIQHI2CoKyK+PSdladXMSns400aZ5/QZs+0fN75B//3Q5CHw++w=="], + "@commitlint/config-validator": ["@commitlint/config-validator@21.0.0", "", { "dependencies": { "@commitlint/types": "^21.0.0", "ajv": "^8.11.0" } }, "sha512-v0UplTYryNUB463X5WrelzKq5/qyYm9/iUNk38S7ZLnd56Uuk2T9awhYKGlgD2/4L5YuN2gsKkyy4EHpRPPz2Q=="], - "@commitlint/ensure": ["@commitlint/ensure@20.4.1", "", { "dependencies": { "@commitlint/types": "^20.4.0", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", "lodash.startcase": "^4.4.0", "lodash.upperfirst": "^4.3.1" } }, "sha512-WLQqaFx1pBooiVvBrA1YfJNFqZF8wS/YGOtr5RzApDbV9tQ52qT5VkTsY65hFTnXhW8PcDfZLaknfJTmPejmlw=="], + "@commitlint/ensure": ["@commitlint/ensure@21.0.0", "", { "dependencies": { "@commitlint/types": "^21.0.0", "es-toolkit": "^1.46.0" } }, "sha512-n+OYs0Ws9GKC2WlmAeLNoPz9CUg6n/ZyYMkFF8rJ0aMn2kDTDTG0VqK/2Dco0EB4fhuF3JPIllJmU9/LKTl4aw=="], - "@commitlint/execute-rule": ["@commitlint/execute-rule@20.0.0", "", {}, "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw=="], + "@commitlint/execute-rule": ["@commitlint/execute-rule@21.0.0", "", {}, "sha512-3OhTq2gQX1tEheMsbDNqxfcNHsAM6g9cub9plf05I9jCxtbNfn8Y+mhClKyUwhX4dbtmC4OLZ9i+HNmoL1aksA=="], - "@commitlint/format": ["@commitlint/format@20.4.0", "", { "dependencies": { "@commitlint/types": "^20.4.0", "picocolors": "^1.1.1" } }, "sha512-i3ki3WR0rgolFVX6r64poBHXM1t8qlFel1G1eCBvVgntE3fCJitmzSvH5JD/KVJN/snz6TfaX2CLdON7+s4WVQ=="], + "@commitlint/format": ["@commitlint/format@21.0.0", "", { "dependencies": { "@commitlint/types": "^21.0.0", "picocolors": "^1.1.1" } }, "sha512-RTfGSrueEgofs1piqwi42U05d85wfxiMH2ncMCZnltx1XqPR3N2S48oACBtTy4xRAhWlf5XlHkK2RaDzEQu3dA=="], - "@commitlint/is-ignored": ["@commitlint/is-ignored@20.4.1", "", { "dependencies": { "@commitlint/types": "^20.4.0", "semver": "^7.6.0" } }, "sha512-In5EO4JR1lNsAv1oOBBO24V9ND1IqdAJDKZiEpdfjDl2HMasAcT7oA+5BKONv1pRoLG380DGPE2W2RIcUwdgLA=="], + "@commitlint/is-ignored": ["@commitlint/is-ignored@21.0.0", "", { "dependencies": { "@commitlint/types": "^21.0.0", "semver": "^7.6.0" } }, "sha512-K3SaaOTVY9VKhge7vl0R3ng7GENRzJQ9MPV43Tu53kAwEgSx/E0HF4US3AcVqdvlvsDUbF2yXvED95dhela83w=="], - "@commitlint/lint": ["@commitlint/lint@20.4.2", "", { "dependencies": { "@commitlint/is-ignored": "^20.4.1", "@commitlint/parse": "^20.4.1", "@commitlint/rules": "^20.4.2", "@commitlint/types": "^20.4.0" } }, "sha512-buquzNRtFng6xjXvBU1abY/WPEEjCgUipNQrNmIWe8QuJ6LWLtei/LDBAzEe5ASm45+Q9L2Xi3/GVvlj50GAug=="], + "@commitlint/lint": ["@commitlint/lint@21.0.0", "", { "dependencies": { "@commitlint/is-ignored": "^21.0.0", "@commitlint/parse": "^21.0.0", "@commitlint/rules": "^21.0.0", "@commitlint/types": "^21.0.0" } }, "sha512-dlUJA0Ka14R1YaR46JVRWE3m/8dOQAgE/D0heUfzYua5Jogtq/zzu2ITAIaB/u25DaKjtEO6kuvASzsFDyrPMw=="], - "@commitlint/load": ["@commitlint/load@20.4.0", "", { "dependencies": { "@commitlint/config-validator": "^20.4.0", "@commitlint/execute-rule": "^20.0.0", "@commitlint/resolve-extends": "^20.4.0", "@commitlint/types": "^20.4.0", "cosmiconfig": "^9.0.0", "cosmiconfig-typescript-loader": "^6.1.0", "is-plain-obj": "^4.1.0", "lodash.mergewith": "^4.6.2", "picocolors": "^1.1.1" } }, "sha512-Dauup/GfjwffBXRJUdlX/YRKfSVXsXZLnINXKz0VZkXdKDcaEILAi9oflHGbfydonJnJAbXEbF3nXPm9rm3G6A=="], + "@commitlint/load": ["@commitlint/load@21.0.0", "", { "dependencies": { "@commitlint/config-validator": "^21.0.0", "@commitlint/execute-rule": "^21.0.0", "@commitlint/resolve-extends": "^21.0.0", "@commitlint/types": "^21.0.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", "picocolors": "^1.1.1" } }, "sha512-l0nBfO/20PKcJXHZqDIgh7kw/TWVVwn8zZJOkVGBK/ig/h328jBu9jK7OiDl2oZr5mLphmKGjYDR2ffEyb2lIA=="], - "@commitlint/message": ["@commitlint/message@20.4.0", "", {}, "sha512-B5lGtvHgiLAIsK5nLINzVW0bN5hXv+EW35sKhYHE8F7V9Uz1fR4tx3wt7mobA5UNhZKUNgB/+ldVMQE6IHZRyA=="], + "@commitlint/message": ["@commitlint/message@21.0.0", "", {}, "sha512-+daU92JaOHhI2En9KcH+2mvZGJ6D4YSxb/32QDwqkOwSj1Vanjio8PbAqX7dneACdg6B7RgQ7i3mpyYZAws4nw=="], - "@commitlint/parse": ["@commitlint/parse@20.4.1", "", { "dependencies": { "@commitlint/types": "^20.4.0", "conventional-changelog-angular": "^8.1.0", "conventional-commits-parser": "^6.2.1" } }, "sha512-XNtZjeRcFuAfUnhYrCY02+mpxwY4OmnvD3ETbVPs25xJFFz1nRo/25nHj+5eM+zTeRFvWFwD4GXWU2JEtoK1/w=="], + "@commitlint/parse": ["@commitlint/parse@21.0.0", "", { "dependencies": { "@commitlint/types": "^21.0.0", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" } }, "sha512-1dbvFBcQK79aTbpc2QCrgEDc6/MMkQ0Mdz4gGmYkN4AHMnAK9HesSewTHqGTrW5mALrMlYSgcWyvKjloY2w19A=="], - "@commitlint/read": ["@commitlint/read@20.4.0", "", { "dependencies": { "@commitlint/top-level": "^20.4.0", "@commitlint/types": "^20.4.0", "git-raw-commits": "^4.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" } }, "sha512-QfpFn6/I240ySEGv7YWqho4vxqtPpx40FS7kZZDjUJ+eHxu3azfhy7fFb5XzfTqVNp1hNoI3tEmiEPbDB44+cg=="], + "@commitlint/read": ["@commitlint/read@21.0.0", "", { "dependencies": { "@commitlint/top-level": "^21.0.0", "@commitlint/types": "^21.0.0", "git-raw-commits": "^5.0.0", "tinyexec": "^1.0.0" } }, "sha512-8VKLKLl2vBSKoTMm1LwcySsyxrBeotnqcT5qJi9pPuPfqSapdAD870Ckgh79c41UFywL6kMqtiyY+kxtfcqZGg=="], - "@commitlint/resolve-extends": ["@commitlint/resolve-extends@20.4.0", "", { "dependencies": { "@commitlint/config-validator": "^20.4.0", "@commitlint/types": "^20.4.0", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", "resolve-from": "^5.0.0" } }, "sha512-ay1KM8q0t+/OnlpqXJ+7gEFQNlUtSU5Gxr8GEwnVf2TPN3+ywc5DzL3JCxmpucqxfHBTFwfRMXxPRRnR5Ki20g=="], + "@commitlint/resolve-extends": ["@commitlint/resolve-extends@21.0.0", "", { "dependencies": { "@commitlint/config-validator": "^21.0.0", "@commitlint/types": "^21.0.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" } }, "sha512-hrJYSZRpmecmSoxYrpuJ/1Q4J9JHt4AVVtr5/Ac6upLO/jJ1DnIm2AjD+38gru3KGOec4aHCVqETuWWLJhydWw=="], - "@commitlint/rules": ["@commitlint/rules@20.4.2", "", { "dependencies": { "@commitlint/ensure": "^20.4.1", "@commitlint/message": "^20.4.0", "@commitlint/to-lines": "^20.0.0", "@commitlint/types": "^20.4.0" } }, "sha512-oz83pnp5Yq6uwwTAabuVQPNlPfeD2Y5ZjMb7Wx8FSUlu4sLYJjbBWt8031Z0osCFPfHzAwSYrjnfDFKtuSMdKg=="], + "@commitlint/rules": ["@commitlint/rules@21.0.0", "", { "dependencies": { "@commitlint/ensure": "^21.0.0", "@commitlint/message": "^21.0.0", "@commitlint/to-lines": "^21.0.0", "@commitlint/types": "^21.0.0" } }, "sha512-NgQhX1qENA+rbrMw5KKyvVZpZG4D/0wgK8Z4INtcwKbfKtVDFMbn0oNc/Rs8wdyBPBj7ue8Lo/GllUL2Mqjwkg=="], - "@commitlint/to-lines": ["@commitlint/to-lines@20.0.0", "", {}, "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw=="], + "@commitlint/to-lines": ["@commitlint/to-lines@21.0.0", "", {}, "sha512-qMwvrJK/x3dPcXsIAtQAMKV5Q0wTioyqyHKR06vVN4wmBF4cCrrLq5x81FDeY3Ba+GWgDt0/P3Zw/IHGM8lwgg=="], - "@commitlint/top-level": ["@commitlint/top-level@20.4.0", "", { "dependencies": { "escalade": "^3.2.0" } }, "sha512-NDzq8Q6jmFaIIBC/GG6n1OQEaHdmaAAYdrZRlMgW6glYWGZ+IeuXmiymDvQNXPc82mVxq2KiE3RVpcs+1OeDeA=="], + "@commitlint/top-level": ["@commitlint/top-level@21.0.0", "", { "dependencies": { "escalade": "^3.2.0" } }, "sha512-8jPqyWZueuN4hU6/ArKVsZ6i8xWtjIrbzHEOaLaTGUfjhhbZNBfXef/DGjzxy55hAv3yFNxHLINfI1bCJ0/MzA=="], - "@commitlint/types": ["@commitlint/types@20.4.0", "", { "dependencies": { "conventional-commits-parser": "^6.2.1", "picocolors": "^1.1.1" } }, "sha512-aO5l99BQJ0X34ft8b0h7QFkQlqxC6e7ZPVmBKz13xM9O8obDaM1Cld4sQlJDXXU/VFuUzQ30mVtHjVz74TuStw=="], + "@commitlint/types": ["@commitlint/types@21.0.0", "", { "dependencies": { "conventional-commits-parser": "^6.3.0", "picocolors": "^1.1.1" } }, "sha512-6nEz+M7I90iix4sviA8NLwskOuyt0M98KUU2aYgiKbn46jMSxUm1l2ACtzRd9ec+y38aKyJhW4Fp6NW0z35kJQ=="], + + "@conventional-changelog/git-client": ["@conventional-changelog/git-client@2.7.0", "", { "dependencies": { "@simple-libs/child-process-utils": "^1.0.0", "@simple-libs/stream-utils": "^1.2.0", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.4.0" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -553,7 +575,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="], + "@google/genai": ["@google/genai@2.0.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-trxxbVePM9J8Cuni5x7+xvApoqb2y6Zk27/wugjT2cuwHOT78nFGdf/Ni29MkDxzWwrj90OQpno1Ana6dm3D2A=="], "@google/generative-ai": ["@google/generative-ai@0.24.1", "", {}, "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q=="], @@ -711,6 +733,30 @@ "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="], + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], @@ -819,6 +865,26 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -1013,6 +1079,24 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" } }, "sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA=="], + + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" } }, "sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA=="], + + "@sentry-internal/replay": ["@sentry-internal/replay@10.51.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw=="], + + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.51.0", "", { "dependencies": { "@sentry-internal/replay": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA=="], + + "@sentry/browser": ["@sentry/browser@10.51.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.51.0", "@sentry-internal/feedback": "10.51.0", "@sentry-internal/replay": "10.51.0", "@sentry-internal/replay-canvas": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA=="], + + "@sentry/core": ["@sentry/core@10.51.0", "", {}, "sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w=="], + + "@sentry/react": ["@sentry/react@10.51.0", "", { "dependencies": { "@sentry/browser": "10.51.0", "@sentry/core": "10.51.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-RRHHqjNvjji6ebIqdlAr453AkST8Vm4cxdu1vWm772IgbzTO7Jx46Cj6Bt2/GjMyH0YLE5euDaAOQhFMmpvAOw=="], + + "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="], + + "@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], + "@sindresorhus/base62": ["@sindresorhus/base62@1.0.0", "", {}, "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA=="], "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], @@ -1021,6 +1105,8 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -1063,8 +1149,6 @@ "@swc/wasm": ["@swc/wasm@1.15.8", "", {}, "sha512-RG2BxGbbsjtddFCo1ghKH6A/BMXbY1eMBfpysV0lJMCpI4DZOjW1BNBnxvBt7YsYmlJtmy5UXIg9/4ekBTFFaQ=="], - "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], @@ -1103,6 +1187,10 @@ "@tanstack/react-query": ["@tanstack/react-query@5.83.0", "", { "dependencies": { "@tanstack/query-core": "5.83.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], + "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], @@ -1343,6 +1431,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/sql.js": ["@types/sql.js@1.4.9", "", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -1543,7 +1633,7 @@ "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1569,11 +1659,11 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "conventional-changelog-angular": ["conventional-changelog-angular@8.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w=="], + "conventional-changelog-angular": ["conventional-changelog-angular@8.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg=="], - "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA=="], + "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw=="], - "conventional-commits-parser": ["conventional-commits-parser@6.2.1", "", { "dependencies": { "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA=="], + "conventional-commits-parser": ["conventional-commits-parser@6.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -1585,7 +1675,7 @@ "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], - "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], + "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], "cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.2.0", "", { "dependencies": { "jiti": "^2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ=="], @@ -1677,8 +1767,6 @@ "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], - "dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], @@ -1691,8 +1779,6 @@ "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], - "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], - "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], "debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -1863,6 +1949,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1923,6 +2011,8 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], @@ -1935,13 +2025,13 @@ "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], - "git-raw-commits": ["git-raw-commits@4.0.0", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="], + "git-raw-commits": ["git-raw-commits@5.0.1", "", { "dependencies": { "@conventional-changelog/git-client": "^2.6.0", "meow": "^13.0.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ=="], "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": "dist/esm/bin.mjs" }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], + "global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="], "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="], @@ -2025,15 +2115,13 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -2243,25 +2331,15 @@ "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], - "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], - "lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="], "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], - "lodash.kebabcase": ["lodash.kebabcase@4.1.1", "", {}, "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "lodash.mergewith": ["lodash.mergewith@4.6.2", "", {}, "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="], - - "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], - - "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], - - "lodash.upperfirst": ["lodash.upperfirst@4.3.1", "", {}, "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -2327,7 +2405,7 @@ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], + "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -2489,6 +2567,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -2523,6 +2603,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pdfjs-dist": ["pdfjs-dist@5.7.284", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.100" } }, "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw=="], + "pg": ["pg@8.19.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", "pg-protocol": "^1.12.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ=="], "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], @@ -2587,7 +2669,7 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], - "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="], + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.8.0", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -2637,6 +2719,8 @@ "prosemirror-view": ["prosemirror-view@1.41.4", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA=="], + "protobufjs": ["protobufjs@7.5.7", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -2653,7 +2737,7 @@ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], + "react-day-picker": ["react-day-picker@10.0.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -2707,8 +2791,6 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], @@ -2721,6 +2803,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="], "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], @@ -2817,13 +2901,15 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2839,7 +2925,7 @@ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3059,7 +3145,7 @@ "wrangler": ["wrangler@4.71.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.15.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260301.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260226.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-j6pSGAncOLNQDRzqtp0EqzYj52CldDP7uz/C9cxVrIgqa5p+cc0b4pIwnapZZAGv9E1Loa3tmPD0aXonH7KTkw=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -3085,9 +3171,9 @@ "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="], @@ -3113,8 +3199,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="], - "@asamuzakjp/css-color/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], "@babel/core/@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], @@ -3159,7 +3243,11 @@ "@bramus/specificity/css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], - "@commitlint/is-ignored/semver": ["semver@7.7.2", "", { "bin": "bin/semver.js" }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@commitlint/ensure/es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + + "@commitlint/load/es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + + "@commitlint/resolve-extends/es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], @@ -3187,8 +3275,6 @@ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "@libsql/hrana-client/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], @@ -3321,10 +3407,6 @@ "cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], - "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], - - "cosmiconfig/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], - "cosmiconfig-typescript-loader/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "cross-spawn/path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -3429,6 +3511,8 @@ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "protobufjs/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "react-i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "react-remove-scroll/tslib": ["tslib@2.8.0", "", {}, "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA=="], @@ -3453,10 +3537,12 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "sucrase/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.5", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -3483,10 +3569,14 @@ "whatwg-url/@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="], - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "zedi-admin/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -3533,8 +3623,6 @@ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "@microsoft/tsdoc-config/resolve/is-core-module": ["is-core-module@2.15.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ=="], @@ -3671,6 +3759,8 @@ "vite-plugin-top-level-await/@swc/core/@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], diff --git a/e2e/auth-mock.ts b/e2e/auth-mock.ts index e0040f59..3800bdeb 100644 --- a/e2e/auth-mock.ts +++ b/e2e/auth-mock.ts @@ -28,10 +28,16 @@ const helpers = { }, /** - * Create a new page and return its ID. - * Uses the home FAB (「新規作成」): `/pages/new` is no longer a creation entry (editor redirects to /home). + * Create a new page from the home FAB and return its note/page id pair. + * + * Issue #889 Phase 3 で `/pages/:id` ルートを撤去したため、作成後は必ず + * `/notes/:noteId/:pageId` に遷移する。テスト側もこの URL を待つように更新する。 + * + * Issue #889 Phase 3 retired `/pages/:id`, so pages always land on + * `/notes/:noteId/:pageId` after creation. This helper waits on that URL and + * returns both ids; callers that only care about the page id can destructure. */ - async createNewPage(page: Page): Promise<string> { + async createNewPage(page: Page): Promise<{ noteId: string; pageId: string }> { await page.goto("/home"); await page.waitForLoadState("networkidle"); @@ -40,21 +46,22 @@ const helpers = { await page.locator('[data-testid="home-fab"]').click(); await page.getByRole("button", { name: "新規作成" }).click(); - // `^/pages/<id>$` のみを許容し、`/notes/.../pages/<id>` のようなノート配下ルートに - // 誤遷移した場合はリグレッションとして検知できるように pathname 完全一致で判定する。 - // Match only the top-level `/pages/:id` route; a regression that accidentally - // creates a note-scoped page should fail this helper instead of silently passing. - await page.waitForURL((url) => /^\/pages\/(?!new$)[^/]+$/.test(url.pathname), { + // 作成後の遷移先は常に `/notes/:noteId/:pageId`(Issue #889 Phase 3)。 + // 旧 `/pages/:id` に着地した場合はリグレッションとして失敗させる。 + // After Issue #889 Phase 3 the post-create URL is always + // `/notes/:noteId/:pageId`. Reject the legacy `/pages/:id` shape so a + // regression surfaces immediately instead of silently passing. + await page.waitForURL((url) => /^\/notes\/[^/]+\/[^/]+$/.test(url.pathname), { timeout: 15000, }); const { pathname } = new URL(page.url()); - const match = pathname.match(/^\/pages\/([^/]+)$/); + const match = pathname.match(/^\/notes\/([^/]+)\/([^/]+)$/); if (!match) { - throw new Error(`Failed to extract page ID from URL: ${page.url()}`); + throw new Error(`Failed to extract note/page IDs from URL: ${page.url()}`); } - return match[1]; + return { noteId: match[1], pageId: match[2] }; }, /** diff --git a/e2e/fixtures/README.md b/e2e/fixtures/README.md new file mode 100644 index 00000000..1612cf27 --- /dev/null +++ b/e2e/fixtures/README.md @@ -0,0 +1,20 @@ +# E2E Fixtures + +E2E テストで使う固定リソース。Issue [#863](https://github.com/otomatty/zedi/issues/863) +の PDF 知識化フローの E2E 用フィクスチャを置く。 + +Static assets consumed by Playwright specs. Files here MUST stay small +(< 1 MB target per #863) so the repo doesn't bloat. + +## `sample.pdf` + +- **生成方法 / How it's built:** `bun run scripts/gen-pdf-fixture.ts` + (`scripts/gen-pdf-fixture.ts` がバイト単位で組み立てる) +- **特徴 / Characteristics:** + - 2 ページ、テキストのみ(画像なし)。Two text-only pages. + - 1 ページ目: `Hello Zedi E2E PDF` / `Page one body text` + - 2 ページ目: `Second page heading` / `Page two body text` + - サイズ: ~941 バイト +- **再生成 / Regenerate:** content を変えたいときはスクリプト側を編集して + `bun run scripts/gen-pdf-fixture.ts` で上書きする。手で PDF を編集しない + (xref のバイトオフセットがずれて pdf.js が読めなくなる)。 diff --git a/e2e/fixtures/sample.pdf b/e2e/fixtures/sample.pdf new file mode 100644 index 00000000..ce12a462 Binary files /dev/null and b/e2e/fixtures/sample.pdf differ diff --git a/e2e/page-editor.spec.ts b/e2e/page-editor.spec.ts deleted file mode 100644 index 9450b09f..00000000 --- a/e2e/page-editor.spec.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { test, expect } from "./auth-mock"; - -test.describe("Page Editor", () => { - test.setTimeout(60000); - - test.beforeEach(async ({ page, helpers }) => { - await helpers.goToHome(page); - }); - - test.describe("Page Creation", () => { - test("should create a new page and redirect to page ID", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Verify editor is visible - await expect(page.locator(".tiptap")).toBeVisible({ timeout: 10000 }); - await expect(page.getByPlaceholder("タイトル")).toBeVisible(); - }); - - test("redirects /pages/new to home (direct /pages/new is not a creation entry)", async ({ - page, - }) => { - await page.goto("/pages/new"); - await expect(page).toHaveURL(/\/home/, { timeout: 10000 }); - }); - }); - - test.describe("Title Editing", () => { - test("should save title changes with auto-save", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - const titleInput = page.getByPlaceholder("タイトル"); - await titleInput.fill("Test Title"); - - // Wait for debounced save (500ms + buffer) - await page.waitForTimeout(1500); - - // Verify title persists after reload - await page.reload(); - await page.waitForLoadState("networkidle"); - - await expect(titleInput).toHaveValue("Test Title"); - }); - - test("should show duplicate title warning", async ({ page, helpers }) => { - // Create first page with title - await helpers.createNewPage(page); - - await page.getByPlaceholder("タイトル").fill("Unique Title"); - await page.waitForTimeout(1500); - - // Create second page with same title - await helpers.createNewPage(page); - - await page.getByPlaceholder("タイトル").fill("Unique Title"); - // Debounced duplicate check (useTitleValidation) + save - await page.waitForTimeout(2500); - - // Duplicate message: toast + inline title (two role=alert with same copy) - await expect( - page - .getByRole("alert") - .filter({ hasText: /既に存在します/ }) - .first(), - ).toBeVisible({ timeout: 15000 }); - }); - }); - - test.describe("Content Editing", () => { - test("should type in editor and auto-generate title", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type("Auto generated title from first line"); - - // Wait for debounced save - await page.waitForTimeout(1500); - - // Title should be auto-generated from content - const titleInput = page.getByPlaceholder("タイトル"); - await expect(titleInput).toHaveValue("Auto generated title from first line"); - }); - - test("should persist content after reload", async ({ page, helpers }) => { - await helpers.createNewPage(page); - const currentUrl = page.url(); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Content Test"); - await page.waitForTimeout(500); - - // Enter content - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type("This content should persist"); - - // Wait for save - await page.waitForTimeout(2000); - - // Reload and verify - await page.goto(currentUrl); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(1000); - - await expect(editor).toContainText("This content should persist"); - }); - - test("should show content error warning for invalid content", async ({ page, helpers }) => { - // This test would require inserting invalid content directly into the database - // For now, we just verify the error UI exists in the DOM structure - await helpers.createNewPage(page); - - // The content error banner should not be visible for normal content - await expect(page.locator(".bg-amber-500\\/10")).not.toBeVisible(); - }); - - test("should apply bold via bubble menu when text is selected", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type("Bold test"); - await page.keyboard.press("Mod+a"); - - await expect(page.getByRole("button", { name: "太字" })).toBeVisible({ timeout: 3000 }); - await page.getByRole("button", { name: "太字" }).click(); - - await expect(editor.locator("strong")).toContainText("Bold test"); - }); - }); - - test.describe("Wiki Generator", () => { - test("should show Wiki生成 button when title exists and content is empty", async ({ - page, - helpers, - }) => { - await helpers.createNewPage(page); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Test Topic"); - await page.waitForTimeout(500); - - // Wiki生成 button should be visible - await expect(page.getByText("Wiki生成")).toBeVisible(); - }); - - test("should hide Wiki生成 button when content exists", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Test Topic"); - await page.waitForTimeout(500); - - // Enter content - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type("Some content here"); - await page.waitForTimeout(500); - - // Wiki生成 button should not be visible - await expect(page.getByText("Wiki生成")).not.toBeVisible(); - }); - }); - - test.describe("Navigation", () => { - test("should navigate back to home on back button click", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Enter title to avoid delete warning - await page.getByPlaceholder("タイトル").fill("Navigation Test"); - await page.waitForTimeout(1500); - - // Click back button - await page.locator('button:has(svg[class*="lucide-arrow-left"])').click(); - - // Should be on home page - await expect(page).toHaveURL("/home"); - }); - - test("should delete page on back if title is empty", async ({ page, helpers }) => { - await helpers.createNewPage(page); - const pageUrl = page.url(); - - // Don't enter title, just wait - await page.waitForTimeout(500); - - // Click back button - await page.locator('button:has(svg[class*="lucide-arrow-left"])').click(); - - // Should be on home page - await expect(page).toHaveURL("/home"); - - // Page should not exist anymore - await page.goto(pageUrl); - await page.waitForTimeout(1000); - - // Should redirect to home (page not found) - await expect(page).toHaveURL("/home"); - }); - }); - - test.describe("Page Actions Menu", () => { - test("should show dropdown menu with actions", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Actions Test"); - await page.waitForTimeout(500); - - // Click more options button - await page.locator('button:has(svg[class*="lucide-more-horizontal"])').click(); - - // Should see menu items - await expect(page.getByText("URLから取り込み")).toBeVisible(); - await expect(page.getByText("Markdownでエクスポート")).toBeVisible(); - await expect(page.getByText("Markdownをコピー")).toBeVisible(); - await expect(page.getByText("削除")).toBeVisible(); - }); - - test("should delete page via menu", async ({ page, helpers }) => { - await helpers.createNewPage(page); - const pageUrl = page.url(); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Delete Test"); - await page.waitForTimeout(1500); - - // Click more options and delete - await page.locator('button:has(svg[class*="lucide-more-horizontal"])').click(); - await page.getByText("削除").click(); - - // Should redirect to home - await expect(page).toHaveURL("/home"); - - // Page should not exist - await page.goto(pageUrl); - await page.waitForTimeout(1000); - await expect(page).toHaveURL("/home"); - }); - }); - - test.describe("Keyboard Shortcuts", () => { - test("should navigate home with Cmd+H", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Enter title to avoid delete warning - await page.getByPlaceholder("タイトル").fill("Shortcut Test"); - await page.waitForTimeout(1500); - - // Press Cmd+H (or Ctrl+H on Windows/Linux) - await page.keyboard.press("Meta+h"); - - // Should be on home page - await expect(page).toHaveURL("/home"); - }); - }); - - test.describe("Linked Pages Section", () => { - test("should show linked pages section when page has WikiLinks", async ({ page, helpers }) => { - // Create target page first - await helpers.createNewPage(page); - await page.getByPlaceholder("タイトル").fill("Target Page for Links"); - await page.locator(".tiptap").click(); - await page.keyboard.type("Content of target page"); - await page.waitForTimeout(2000); - - // Create source page with WikiLink - await helpers.createNewPage(page); - const sourceUrl = page.url(); - - await page.getByPlaceholder("タイトル").fill("Source Page with Links"); - await page.locator(".tiptap").click(); - - // Type WikiLink - await page.keyboard.type("[[Target Page for Links"); - await page.waitForTimeout(500); - await page.keyboard.press("Enter"); - await page.waitForTimeout(3000); - - // Reload source page - await page.goto(sourceUrl); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(2000); - - // Should see linked pages section - const linkSection = page.getByText("リンク"); - const isVisible = await linkSection.isVisible().catch(() => false); - - if (isVisible) { - await expect(linkSection).toBeVisible(); - } - }); - }); - - test.describe("Home page context menu delete", () => { - test("should delete page via context menu and keep UI interactive (fix #313)", async ({ - page, - helpers, - }) => { - await helpers.createNewPage(page); - const syncPromise = page.waitForResponse( - (res) => { - const req = res.request(); - return req.method() === "POST" && res.url().includes("/api/sync/pages") && res.ok(); - }, - { timeout: 10000 }, - ); - await page.getByPlaceholder("タイトル").fill("Context Menu Delete Test"); - await syncPromise; - - await page.goto("/home"); - await page.waitForLoadState("networkidle"); - - const card = page.locator(".page-card", { hasText: "Context Menu Delete Test" }); - await expect(card).toBeVisible({ timeout: 10000 }); - - await card.click({ button: "right" }); - await page.getByRole("menuitem", { name: "削除" }).click(); - - await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5000 }); - const deletePromise = page.waitForResponse( - (res) => { - const req = res.request(); - return req.method() === "DELETE" && res.url().includes("/api/pages") && res.ok(); - }, - { timeout: 10000 }, - ); - await page.getByRole("alertdialog").getByRole("button", { name: "削除" }).click(); - await deletePromise; - - await expect(page.getByRole("alertdialog")).not.toBeVisible({ timeout: 5000 }); - await expect(page).toHaveURL("/home"); - await expect(card).toHaveCount(0); - - const fab = page.locator('[data-testid="home-fab"]'); - await fab.click(); - await expect(page.getByRole("button", { name: /新規作成/ })).toBeVisible({ timeout: 3000 }); - }); - }); -}); diff --git a/e2e/pdf-knowledge.spec.ts b/e2e/pdf-knowledge.spec.ts new file mode 100644 index 00000000..6081de25 --- /dev/null +++ b/e2e/pdf-knowledge.spec.ts @@ -0,0 +1,229 @@ +/** + * E2E: PDF 知識化フロー (issue otomatty/zedi#863, follow-up to #389 / #858). + * + * このスペックは「PDF 本体のバイト列は絶対にサーバへ送られない」を前提とした + * Phase 1 のフローを守るためのテスト群を集約する。実際の登録 → ハイライト → + * 派生ページ → backlink → リロード後永続性、というフルフロー (1〜6) は Tauri + * デスクトップランタイム+tauri-driver を必要とするため、現状の Chromium 専用 + * Playwright 環境では `test.fixme()` で骨格だけ用意し、Tauri E2E 基盤の整備を + * 別 issue に切り出すまでは pending として残す。 + * + * Coverage map for #863 / Phase 1 acceptance criteria: + * ✅ Active here: + * - Web (non-Tauri) ターゲットで `/sources/:id/pdf` → `PdfReaderUnsupported` + * の文言が出る最小テスト。 + * - PDF バイナリがネットワーク送信されていないことを E2E 内で確認する + * sentinel テスト(ルート遷移時に PDF body を含む POST が無いことを assert)。 + * - フィクスチャ PDF (`e2e/fixtures/sample.pdf`) の存在チェック(後段の + * Tauri E2E が依存するため、欠落は早期に検知したい)。 + * ⏳ Pending (`test.fixme`, requires tauri-driver + native Tauri build): + * - シナリオ 1: ファイルダイアログから PDF を登録 → ビューアに遷移 + * - シナリオ 2: テキスト選択 → 「ハイライト保存」 → 一覧に出現 + * - シナリオ 3: 「保存して新規ページ」 → 派生ページ + 引用ブロック + 出典リンク + * - シナリオ 4: 出典リンク → `#page=N` ディープリンク + * - シナリオ 5: リロード(ウィンドウ再起動)後の永続性 + * - シナリオ 6: ファイル移動 → `MissingPdfBanner` → 「再アタッチ」 + * + * The pending block is intentionally `test.fixme`, not `test.skip`, so it shows + * up in CI reports as work-to-do rather than being silently hidden. + */ +import { existsSync, statSync } from "node:fs"; +import type { Request } from "@playwright/test"; +import { test, expect } from "./auth-mock"; + +/** + * Playwright は project root (cwd) から spec を起動するため、ここでは + * リポジトリルートからの相対パスで十分。`__dirname` は ESM/CJS の差で罠が + * 多いので避ける。 + * Playwright launches specs from the project root, so a cwd-relative path is + * the most portable way to refer to the fixture without fighting ESM/CJS. + */ +const SAMPLE_PDF_PATH = "e2e/fixtures/sample.pdf"; + +/** + * `/api/sources/pdf` 系のエンドポイント名。Phase 1 ではバイナリ本体を受け取らない + * 設計なので、ここに PDF バイト列を含む POST が飛んだら設計違反として fail する。 + * + * Endpoints that must never receive raw PDF bytes. Phase 1's contract is that + * the server only gets hashes + metadata + highlights — the actual bytes stay + * on the user's disk via the Tauri-side registry. + */ +const PDF_API_PATH_PATTERN = /\/api\/sources\/pdf(?:$|\/)/u; + +/** + * 「raw PDF」「base64 化された PDF」のどちらでも本文先頭に現れるマジック文字列。 + * 単なるサイズしきい値だと、本 PR の sample.pdf (~941 B) のような小さい + * フィクスチャが誤って POST されたとき false negative になる。代わりに + * PDF シグネチャ (`%PDF-`) と、その base64 表現 (`JVBERi0`) を直接探す。 + * + * Both raw and base64-encoded PDFs start with one of these markers. The Phase 1 + * invariant is "no PDF bytes on the wire" regardless of payload size, so a + * size-only threshold is too lax — a regression that uploads even the 941-byte + * `sample.pdf` fixture would slip past one. Magic-byte matching closes that + * gap (see review feedback on #871). + */ +const PDF_BODY_MARKERS = ["%PDF-", "JVBERi0"] as const; + +/** + * リクエスト body の中に PDF バイナリ (生 / base64) が混入していないか調べる。 + * Phase 1 contract: PDF bytes must never be uploaded to the server. Inspect + * the raw post body for the PDF magic bytes — both the literal `%PDF-` header + * and the `JVBERi0` prefix that a base64-encoded PDF starts with. + * + * @returns 検出時は判定根拠を含むオブジェクト、未検出時は `null`。 + */ +function detectPdfBytesInBody( + request: Request, +): { marker: string; bodySize: number; method: string } | null { + const buf = request.postDataBuffer(); + if (!buf) return null; + // latin1 でデコードすると 0x00-0xFF が 1:1 で文字に写るため、バイナリの中に + // 紛れた ASCII シグネチャを安全に探せる。utf-8 だと不正なシーケンスで早期に + // 切れる可能性があるため避ける。 + // latin1 maps every byte 1:1 to a code point, so ASCII signatures embedded + // in arbitrary binary survive the decode. utf-8 can silently truncate on + // invalid sequences, which would let a regression slip past. + const text = buf.toString("latin1"); + for (const marker of PDF_BODY_MARKERS) { + if (text.includes(marker)) { + return { marker, bodySize: buf.byteLength, method: request.method() }; + } + } + return null; +} + +test.describe("PDF knowledge ingestion — fixtures sanity", () => { + test.setTimeout(30_000); + + test("sample fixture PDF is present and below 1 MB", () => { + // `bun run scripts/gen-pdf-fixture.ts` を回し忘れた場合、後段の Tauri 用 + // テスト群がデバッグしづらい形で落ちる。早い段階で明確に失敗させる。 + // Fail loudly when the fixture is missing, so a developer who forgot to + // regenerate it doesn't waste time chasing opaque downstream errors. + expect(existsSync(SAMPLE_PDF_PATH)).toBe(true); + const { size } = statSync(SAMPLE_PDF_PATH); + expect(size).toBeGreaterThan(0); + expect(size).toBeLessThan(1024 * 1024); + }); +}); + +test.describe("PDF knowledge ingestion — web (non-Tauri) target", () => { + test.setTimeout(60_000); + + test("/sources/:id/pdf shows the desktop-only placeholder in a regular browser", async ({ + page, + }) => { + // モック認証下の通常 Chromium には `__TAURI_INTERNALS__` が無いので + // `isTauriDesktop()` が false → `PdfReaderUnsupported` が描画される想定。 + // The mock-auth Chromium target has no `__TAURI_INTERNALS__`, so + // `isTauriDesktop()` returns false and `PdfReaderUnsupported` should render. + await page.goto("/sources/nonexistent-source-id/pdf"); + await page.waitForLoadState("networkidle"); + + // 日本語・英語の両方を確認する(プロジェクト規約上どちらも併記される)。 + // Assert both Japanese and English copy — both are part of the contract. + await expect(page.getByText("PDF 知識化はデスクトップ版のみ対応")).toBeVisible(); + await expect(page.getByText(/Desktop-only/i)).toBeVisible(); + await expect( + page.getByRole("link", { name: /ノートに戻る|Back to your notes/i }), + ).toBeVisible(); + }); + + test("does not POST any PDF binary to /api/sources/pdf* on web", async ({ page }) => { + // ネットワークインターセプト sentinel。Phase 1 設計違反を early-detect する。 + // サイズ依存ではなく PDF マジックバイトで判定するので、本 PR の 941 バイト + // フィクスチャのような小サイズの regression も確実にフラグできる。 + // Network-level sentinel for the "no PDF bytes on the wire" Phase 1 + // contract. Uses magic-byte detection rather than a size threshold so a + // regression that uploads even the ~941-byte fixture is still caught. + const offendingRequests: { + url: string; + method: string; + marker: string; + bodySize: number; + }[] = []; + page.on("request", (request) => { + const url = request.url(); + if (!PDF_API_PATH_PATTERN.test(new URL(url).pathname)) return; + const hit = detectPdfBytesInBody(request); + if (hit) offendingRequests.push({ url, ...hit }); + }); + + // Web 側からは結局 `/sources/:id/pdf` がアンサポート画面になるが、それでも + // 念のためフルロードまで待ってから assertion する。 + // Even though the web path is unsupported, wait for full load before + // asserting so any rogue request has time to fire. + await page.goto("/sources/nonexistent-source-id/pdf"); + await page.waitForLoadState("networkidle"); + + expect( + offendingRequests, + `Phase 1 では PDF バイナリを /api/sources/pdf* に送ってはいけない。検知: ${JSON.stringify( + offendingRequests, + )}`, + ).toEqual([]); + }); +}); + +test.describe("PDF knowledge ingestion — Tauri desktop scenarios", () => { + // ───────────────────────────────────────────────────────────────────────── + // これらのシナリオは「実 Tauri デスクトップ + tauri-driver」を必要とする。 + // 現在の playwright.config.ts は Chromium のみ + Vite dev server で動作する + // ため、ここでフルフローを再現すると `__TAURI_INTERNALS__` 偽装の信頼性が + // 低く、テストが脆い偽陽性を量産する恐れがある。 + // + // 取るべきフォローアップ: + // 1. `tauri-driver` ベースの別 Playwright プロジェクト or 別 spec を追加 + // (e.g. `playwright.tauri.config.ts`). + // 2. CI でデスクトップ Linux runner を用意し、`bun run tauri build` した + // バイナリに対して webdriver セッションを張る。 + // 3. ファイルダイアログは `WebDriverIO` の Tauri プラグイン or 環境変数で + // 明示的にバイパスする(real OS dialog は CI で扱えない)。 + // + // それまではここの本文をフルで再構築せず、`test.fixme()` で「未完了として + // 可視化」する。`test.skip` だと PR レポートで沈黙するため避ける。 + // + // These scenarios need a real Tauri webview + tauri-driver. Until that + // infrastructure ships (tracked as a follow-up to #863), keep them + // `test.fixme` so they show up in CI reports as outstanding work instead of + // being silently skipped. + // ───────────────────────────────────────────────────────────────────────── + test.setTimeout(120_000); + + test.fixme("registers a PDF via the file dialog and lands on the viewer", async () => { + // TODO(#863 follow-up): drive `OpenPdfButton` through tauri-driver, mock + // the OS file dialog to return `e2e/fixtures/sample.pdf`, then assert that + // the URL settles on `/sources/:sourceId/pdf` and the canvas renders. + }); + + test.fixme("selects text and saves a highlight that appears in the sidebar", async () => { + // TODO(#863 follow-up): select a known string from page 1 of + // `e2e/fixtures/sample.pdf` (e.g. "Hello Zedi E2E PDF"), click + // `[data-testid="pdf-highlight-toolbar"] [data-action="save"]`, then assert + // the highlight row appears in `HighlightSidebar`. + }); + + test.fixme("'save and derive' creates a derived page with citation + source link", async () => { + // TODO(#863 follow-up): trigger the save-and-derive flow, follow the + // navigation, and assert that the rendered page contains both a citation + // block (excerpt) and a source link pointing back to the original PDF. + }); + + test.fixme("the source link deep-links back to the original PDF page (#page=N)", async () => { + // TODO(#863 follow-up): click the source link from the derived page and + // assert the resulting URL hash matches `#page=2` (or whichever page the + // highlight lives on), and that the viewer actually scrolls there. + }); + + test.fixme("highlights and derived pages survive a window reload", async () => { + // TODO(#863 follow-up): after the create-and-derive flow, reload the + // Tauri window (or close+reopen via tauri-driver) and re-assert that the + // highlight + derived page + citation are still visible. + }); + + test.fixme("missing file triggers MissingPdfBanner and re-attach restores the viewer", async () => { + // TODO(#863 follow-up): rename / move the fixture PDF on disk, restart the + // viewer, assert `MissingPdfBanner` is shown, then re-attach via the file + // dialog mock and assert the viewer recovers. + }); +}); diff --git a/e2e/web-clipper.spec.ts b/e2e/web-clipper.spec.ts index da422747..aa52c08d 100644 --- a/e2e/web-clipper.spec.ts +++ b/e2e/web-clipper.spec.ts @@ -1,51 +1,79 @@ /** * E2E: Web Clipper clipUrl flow (Chrome extension integration). - * clipUrl 付き起動 → WebClipperDialog 自動 open → URL プリフィル確認 + * `/notes/me?clipUrl=...` 経由(issue #826)、および旧 `/home?clipUrl=...` + * 経由(互換層)の双方で WebClipperDialog が自動 open し、URL がプリフィル + * されることを検証する。 + * + * E2E coverage for the Chrome-extension clip hand-off. After issue #826 the + * canonical entry point is `/notes/me?clipUrl=...`; the legacy + * `/home?clipUrl=...` URL must keep working as a query-preserving redirect + * until the extension itself is updated (issue #829). */ import { test, expect } from "./auth-mock"; test.describe("Web Clipper clipUrl flow", () => { test.setTimeout(60000); - test("should auto-open Web Clipper dialog with clipUrl prefilled", async ({ + test("auto-opens the dialog with clipUrl prefilled when hitting /notes/me directly", async ({ page, - helpers: _helpers, }) => { const clipUrl = "https://example.com/article"; await page.goto( - `/home?${new URLSearchParams({ clipUrl, from: "chrome-extension" }).toString()}`, + `/notes/me?${new URLSearchParams({ clipUrl, from: "chrome-extension" }).toString()}`, ); await page.waitForLoadState("networkidle"); - // With mock auth (VITE_E2E_TEST), user is signed in; dialog should auto-open + // With mock auth (VITE_E2E_TEST), the user is signed in; the dialog should + // auto-open after `/notes/me` resolves to `/notes/:noteId?clipUrl=...`. + // モック認証(VITE_E2E_TEST)ではサインイン済みなので、`/notes/me` が + // `/notes/:noteId?clipUrl=...` に解決された後にダイアログが自動で開く。 const dialog = page.getByRole("dialog").filter({ hasText: /URL.*取り込み|Import from URL/i }); await expect(dialog).toBeVisible({ timeout: 10000 }); - // URL input should be prefilled + // URL input should be prefilled. + // URL 入力欄には clipUrl がプリフィルされる。 const urlInput = page.getByPlaceholder(/URL.*入力|Enter URL/i); await expect(urlInput).toBeVisible(); await expect(urlInput).toHaveValue(clipUrl); }); - test("should not open dialog for invalid clipUrl", async ({ page }) => { + test("legacy /home?clipUrl=... still forwards to /notes/me and auto-opens the dialog", async ({ + page, + }) => { + const clipUrl = "https://example.com/legacy"; + await page.goto( + `/home?${new URLSearchParams({ clipUrl, from: "chrome-extension" }).toString()}`, + ); + await page.waitForLoadState("networkidle"); + + // /home preserves search params and redirects to /notes/me, which then + // resolves to /notes/:noteId?clipUrl=... — the dialog should still open. + // /home は search params を保ったまま /notes/me にリダイレクトし、その後 + // /notes/:noteId?clipUrl=... に解決されるため、ダイアログは開き続ける。 + const dialog = page.getByRole("dialog").filter({ hasText: /URL.*取り込み|Import from URL/i }); + await expect(dialog).toBeVisible({ timeout: 10000 }); + const urlInput = page.getByPlaceholder(/URL.*入力|Enter URL/i); + await expect(urlInput).toHaveValue(clipUrl); + }); + + test("does not open the dialog when clipUrl fails the URL policy", async ({ page }) => { const invalidUrl = "chrome://extensions"; - await page.goto(`/home?${new URLSearchParams({ clipUrl: invalidUrl }).toString()}`); + await page.goto(`/notes/me?${new URLSearchParams({ clipUrl: invalidUrl }).toString()}`); await page.waitForLoadState("networkidle"); - // Dialog should not auto-open for invalid URL + // Invalid URLs are stripped at /notes/me; the dialog must stay closed. + // 無効な URL は /notes/me で剥がされるため、ダイアログは閉じたままになる。 const dialog = page.getByRole("dialog").filter({ hasText: /URL.*取り込み|Import from URL/i }); await expect(dialog).not.toBeVisible(); }); - test("should open Web Clipper with clipUrl prefilled when from param is omitted", async ({ + test("opens the dialog with clipUrl prefilled even when from= param is omitted", async ({ page, - helpers: _helpers, }) => { const clipUrl = "https://example.com/page"; - await page.goto(`/home?${new URLSearchParams({ clipUrl }).toString()}`); + await page.goto(`/notes/me?${new URLSearchParams({ clipUrl }).toString()}`); await page.waitForLoadState("networkidle"); - // With mock auth, user is signed in; dialog should open even without from=chrome-extension const dialog = page.getByRole("dialog").filter({ hasText: /URL.*取り込み|Import from URL/i }); await expect(dialog).toBeVisible({ timeout: 10000 }); const urlInput = page.getByPlaceholder(/URL.*入力|Enter URL/i); diff --git a/e2e/wikilink-create-dialog.spec.ts b/e2e/wikilink-create-dialog.spec.ts deleted file mode 100644 index aa02e813..00000000 --- a/e2e/wikilink-create-dialog.spec.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { Page } from "@playwright/test"; -import { test, expect } from "./auth-mock"; -import { MOCK_USER_ID } from "../src/components/auth/MockAuthProvider"; - -const GHOST_TITLE = "生産手段"; - -async function seedBlankPage(page: Page, title: string) { - const pageId = crypto.randomUUID(); - const now = Date.now(); - - await page.evaluate( - async ({ userId, pageId, title, now }) => { - const request = indexedDB.open(`zedi-storage-${userId}`, 1); - - await new Promise<void>((resolve, reject) => { - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains("my_pages")) { - const pages = db.createObjectStore("my_pages", { keyPath: "id" }); - pages.createIndex("updated_at", "updatedAt", { unique: false }); - pages.createIndex("created_at", "createdAt", { unique: false }); - } - if (!db.objectStoreNames.contains("my_links")) { - const links = db.createObjectStore("my_links", { keyPath: ["sourceId", "targetId"] }); - links.createIndex("by_source", "sourceId", { unique: false }); - links.createIndex("by_target", "targetId", { unique: false }); - } - if (!db.objectStoreNames.contains("my_ghost_links")) { - const ghost = db.createObjectStore("my_ghost_links", { - keyPath: ["linkText", "sourcePageId"], - }); - ghost.createIndex("by_source", "sourcePageId", { unique: false }); - } - if (!db.objectStoreNames.contains("search_index")) { - db.createObjectStore("search_index", { keyPath: "pageId" }); - } - if (!db.objectStoreNames.contains("meta")) { - db.createObjectStore("meta", { keyPath: "key" }); - } - if (!db.objectStoreNames.contains("ydoc_versions")) { - db.createObjectStore("ydoc_versions", { keyPath: "pageId" }); - } - }; - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - - const db = request.result; - await new Promise<void>((resolve, reject) => { - const tx = db.transaction(["my_pages", "search_index", "ydoc_versions"], "readwrite"); - tx.objectStore("my_pages").put({ - id: pageId, - ownerId: userId, - sourcePageId: null, - title, - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - createdAt: now, - updatedAt: now, - isDeleted: false, - }); - tx.objectStore("search_index").put({ pageId, text: "" }); - tx.objectStore("ydoc_versions").put({ pageId, version: 1 }); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error); - }); - - db.close(); - }, - { userId: MOCK_USER_ID, pageId, title, now }, - ); - - return pageId; -} - -async function createPageWithGhostWikiLink(page: Page, sourceTitle: string) { - const pageId = await seedBlankPage(page, sourceTitle); - await page.goto(`/pages/${pageId}`); - await page.waitForLoadState("networkidle"); - await expect(page.getByRole("textbox", { name: "タイトル" })).toHaveValue(sourceTitle); - - const sourceUrl = page.url(); - - await page.getByRole("textbox", { name: "タイトル" }).fill(sourceTitle); - await page.waitForTimeout(500); - - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type(`[[${GHOST_TITLE}`); - await page.waitForTimeout(300); - await page.keyboard.press("Enter"); - - await expect(editor.locator(`[data-wiki-link][data-title="${GHOST_TITLE}"]`)).toBeVisible({ - timeout: 5000, - }); - - // Wait for autosave (PUT /api/pages/:id/content), then reload to test against persisted content. - await page.waitForResponse( - (res) => - res.url().includes("/api/pages/") && - res.url().includes("/content") && - res.request().method() === "PUT", - { timeout: 10000 }, - ); - await page.goto(sourceUrl); - await page.waitForLoadState("networkidle"); - - return { sourceUrl }; -} - -test.describe("WikiLink create-page dialog", () => { - test.setTimeout(60000); - - test.beforeEach(async ({ page, helpers }) => { - // auth-mock の page fixture が既に同じ per-user onboarding cache を seed - // しているため、ここでは goToHome のみ。重複 seed があるとキー変更時に - // ずれる可能性があるので単一責務にまとめる。 - // The auth-mock page fixture already seeds the same per-user onboarding - // cache; keep seeding centralized there to avoid drift when the key - // changes. - await helpers.goToHome(page); - }); - - test("shows create-page dialog and cancels without crashing", async ({ page }) => { - const pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); - - const { sourceUrl } = await createPageWithGhostWikiLink(page, "Ghost Link Cancel Test"); - - const ghostLink = page.locator(`.tiptap [data-wiki-link][data-title="${GHOST_TITLE}"]`); - await ghostLink.click(); - - await expect(page.getByRole("alertdialog")).toBeVisible(); - await expect(page.getByRole("heading", { name: "ページを作成しますか?" })).toBeVisible(); - - await page.getByRole("button", { name: "キャンセル" }).click(); - - await expect(page.getByRole("alertdialog")).not.toBeVisible(); - await expect(page).toHaveURL(sourceUrl); - await expect(page.locator(".tiptap")).toBeVisible(); - await expect(ghostLink).toBeVisible(); - - expect( - pageErrors.some((error) => error.message.includes("Maximum update depth exceeded")), - ).toBeFalsy(); - }); - - test("creates a page from an unconfigured wiki link", async ({ page }) => { - const pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); - - const { sourceUrl } = await createPageWithGhostWikiLink(page, "Ghost Link Create Test"); - - await page.route("**/api/pages", async (route) => { - if (route.request().method() !== "POST") { - await route.fallback(); - return; - } - let requestBody: { - title?: string; - content_preview?: string | null; - source_page_id?: string | null; - thumbnail_url?: string | null; - source_url?: string | null; - }; - try { - requestBody = route.request().postDataJSON() ?? {}; - } catch { - await route.fallback(); - return; - } - const now = new Date().toISOString(); - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - id: crypto.randomUUID(), - owner_id: MOCK_USER_ID, - source_page_id: requestBody.source_page_id ?? null, - title: requestBody.title ?? null, - content_preview: requestBody.content_preview ?? null, - thumbnail_url: requestBody.thumbnail_url ?? null, - source_url: requestBody.source_url ?? null, - created_at: now, - updated_at: now, - is_deleted: false, - }), - }); - }); - - const ghostLink = page.locator(`.tiptap [data-wiki-link][data-title="${GHOST_TITLE}"]`); - await ghostLink.click(); - - await expect(page.getByRole("alertdialog")).toBeVisible(); - await page.getByRole("button", { name: "作成する" }).click(); - - await expect(page).not.toHaveURL(sourceUrl, { timeout: 15000 }); - await expect(page.getByRole("textbox", { name: "タイトル" })).toHaveValue(GHOST_TITLE); - await expect(page.locator(".tiptap")).toBeVisible(); - - expect( - pageErrors.some((error) => error.message.includes("Maximum update depth exceeded")), - ).toBeFalsy(); - }); -}); diff --git a/extension/README.md b/extension/README.md index d07a2a95..e5324018 100644 --- a/extension/README.md +++ b/extension/README.md @@ -57,6 +57,7 @@ The extension accesses the auth page (`/auth/extension`) and API (`/api/ext/*`) extension/ config.js # popup 用(prepare-extension.js が生成。手動編集しない) / For popup (generated by prepare-extension.js; do not edit manually) config.worker.js # background 用(同上) / For background (same as above) + constants.js # 共有定数(popup と background から読み込み) / Shared constants (loaded from popup and background) manifest.json # 拡張マニフェスト / Extension manifest popup.html # ポップアップ UI / Popup UI popup.js # ポップアップロジック / Popup logic diff --git a/extension/background.js b/extension/background.js index a2a68574..0bf0ffba 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2,7 +2,7 @@ * Zedi Web Clipper - Background service worker * Phase 2: When token exists, call clip-and-create. Otherwise open Zedi with clipUrl. */ -importScripts("config.worker.js"); +importScripts("config.worker.js", "constants.js"); const STORAGE_KEY = "zedi_ext_token"; @@ -75,7 +75,8 @@ async function clipAndCreate(url) { function openZediWithClipUrl(url) { const params = new URLSearchParams({ clipUrl: url, from: "chrome-extension" }); - chrome.tabs.create({ url: `${getApiBase()}/home?${params.toString()}` }); + const path = self.ZEDI_EXT_CONSTANTS.CLIP_PATH; + chrome.tabs.create({ url: `${getApiBase()}${path}?${params.toString()}` }); } async function savePage(url) { diff --git a/extension/constants.js b/extension/constants.js new file mode 100644 index 00000000..a27556a4 --- /dev/null +++ b/extension/constants.js @@ -0,0 +1,16 @@ +/** + * Zedi Extension - shared constants / Zedi 拡張 - 共有定数 + * + * `self` is defined in both window (popup) and service worker contexts, so + * this single file can be loaded via `<script>` from `popup.html` and via + * `importScripts()` from `background.js`. + * + * `self` は popup ウィンドウとサービスワーカーの両方で参照できるため、本 + * ファイルは `popup.html` の `<script>` 読み込みと、`background.js` の + * `importScripts()` の両方で利用できる。 + */ +self.ZEDI_EXT_CONSTANTS = Object.freeze({ + // クリップフローのフォールバック先パス(ログイン状態でも未認証でも辿り着く)。 + // Path used as the clip-flow fallback (reachable signed-in or not). + CLIP_PATH: "/notes/me", +}); diff --git a/extension/manifest.base.json b/extension/manifest.base.json index 5dbceec5..a2a1158c 100644 --- a/extension/manifest.base.json +++ b/extension/manifest.base.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Zedi Web Clipper", - "version": "1.0.0", + "version": "1.1.0", "description": "Save web pages to Zedi with one click", "permissions": ["activeTab", "contextMenus", "scripting", "storage", "identity"], "host_permissions": [ diff --git a/extension/manifest.json b/extension/manifest.json index 5dbceec5..a2a1158c 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Zedi Web Clipper", - "version": "1.0.0", + "version": "1.1.0", "description": "Save web pages to Zedi with one click", "permissions": ["activeTab", "contextMenus", "scripting", "storage", "identity"], "host_permissions": [ diff --git a/extension/popup.html b/extension/popup.html index 910b8c6e..1d358343 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -5,6 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Zedi Web Clipper +