From 9a07b0f848654689f2fa1c8052ba3538eb5fab07 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 00:53:56 +0000 Subject: [PATCH 1/4] feat(ci): analyze-error.yml + Claude AI analysis composite action (#806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Epic #616 Phase 2 / sub-issue #806: a `repository_dispatch` (`event_type: analyze-error`) workflow that asks Claude (Anthropic) for a structured analysis of a Sentry-detected API error and PUTs the result back to the API callback (`PUT /api/webhooks/github/ai-result/:id`) landed in #805. - `.github/workflows/analyze-error.yml` — repository_dispatch + workflow_dispatch (with `dry_run` / `skip_callback` defaults of true so it stays green pre-secrets-rollout). Mints a short-lived installation token via `actions/create-github-app-token@v2`. Concurrency-grouped by `sentry_issue_id` so duplicate webhooks do not double-bill Anthropic. - `.github/actions/claude-analyze/action.yml` — composite action orchestrating bun install → analyze script → curl PUT (2 retries on 5xx, immediate fail on 4xx). - `.github/actions/claude-analyze/analyze.mjs` — derives keywords from title/route, runs `git grep -l` to gather up to 6 candidate files (head 80 lines each), renders `prompt.md`, calls Anthropic SDK with retry, validates output via Zod, writes JSON to `$GITHUB_OUTPUT` path. - `.github/actions/claude-analyze/schema.mjs` — Zod schema mirroring the server's `updateAiAnalysis` boundary (severity enum + suspected-file shape) so a malformed Claude response fails CI rather than writing garbage back. - `.github/actions/claude-analyze/prompt.md` — bilingual prompt template with the Epic #616 severity rubric (high/medium/low/unknown) and a fixed JSON output contract. - `__tests__/schema.test.mjs` + `fixtures/*.json` — 12 fixture cases using Node 24's built-in test runner (no new vitest workspace). - README.md documents the `client_payload` contract, required secrets, local + workflow_dispatch dry-run recipes, and retry semantics. Closes #806 --- .github/actions/claude-analyze/README.md | 198 +++++++++ .../fixtures/invalid-bad-severity.json | 4 + .../fixtures/invalid-missing-summary.json | 4 + .../fixtures/invalid-suspected-file.json | 9 + .../__tests__/fixtures/valid-high.json | 17 + .../__tests__/fixtures/valid-low-nulls.json | 7 + .../claude-analyze/__tests__/schema.test.mjs | 118 +++++ .github/actions/claude-analyze/action.yml | 142 ++++++ .github/actions/claude-analyze/analyze.mjs | 412 ++++++++++++++++++ .github/actions/claude-analyze/prompt.md | 92 ++++ .github/actions/claude-analyze/schema.mjs | 104 +++++ .github/workflows/analyze-error.yml | 113 +++++ 12 files changed, 1220 insertions(+) create mode 100644 .github/actions/claude-analyze/README.md create mode 100644 .github/actions/claude-analyze/__tests__/fixtures/invalid-bad-severity.json create mode 100644 .github/actions/claude-analyze/__tests__/fixtures/invalid-missing-summary.json create mode 100644 .github/actions/claude-analyze/__tests__/fixtures/invalid-suspected-file.json create mode 100644 .github/actions/claude-analyze/__tests__/fixtures/valid-high.json create mode 100644 .github/actions/claude-analyze/__tests__/fixtures/valid-low-nulls.json create mode 100644 .github/actions/claude-analyze/__tests__/schema.test.mjs create mode 100644 .github/actions/claude-analyze/action.yml create mode 100644 .github/actions/claude-analyze/analyze.mjs create mode 100644 .github/actions/claude-analyze/prompt.md create mode 100644 .github/actions/claude-analyze/schema.mjs create mode 100644 .github/workflows/analyze-error.yml diff --git a/.github/actions/claude-analyze/README.md b/.github/actions/claude-analyze/README.md new file mode 100644 index 00000000..3b2249af --- /dev/null +++ b/.github/actions/claude-analyze/README.md @@ -0,0 +1,198 @@ +# `claude-analyze` action + +Sentry が検知した API エラーを Claude (Anthropic) で解析し、構造化 JSON を Zedi +API のコールバックに `PUT` する composite action。Epic [#616](https://github.com/otomatty/zedi/issues/616) +Phase 2 / Issue [#806](https://github.com/otomatty/zedi/issues/806) の実装。 + +Composite action that asks Claude to analyze a Sentry-reported API error and +PUTs the validated structured result back to the Zedi API callback. Implements +Epic [#616](https://github.com/otomatty/zedi/issues/616) Phase 2 / issue +[#806](https://github.com/otomatty/zedi/issues/806). + +--- + +## ファイル構成 / 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 | +| `__tests__/schema.test.mjs` | 出力スキーマ fixture テスト / fixture tests for the schema | +| `__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 +``` + +新しいシナリオを追加するときは `__tests__/fixtures/` に JSON を置いて、`schema.test.mjs` +にケースを 1 つ足す。 + +To add a new scenario, drop a fixture JSON in `__tests__/fixtures/` and add one +test case in `schema.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. + +--- + +## リトライ・失敗時の挙動 / 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) | + +いずれの失敗もユーザーリクエストには影響しない(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__/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/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..fafa0c44 --- /dev/null +++ b/.github/actions/claude-analyze/__tests__/schema.test.mjs @@ -0,0 +1,118 @@ +/** + * 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("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 throws when no JSON object is present", () => { + assert.throws(() => parseAndValidate("nope, no braces here"), /JSON object/); +}); + +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..97e19313 --- /dev/null +++ b/.github/actions/claude-analyze/action.yml @@ -0,0 +1,142 @@ +# `.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" + +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 }} + +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})" + 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" \ + --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 + echo "::error title=Callback rejected (${http_status})::$(cat /tmp/callback-response.txt)" + exit 1 + fi + if [ "${attempt}" -ge "${max_attempts}" ]; then + echo "::error title=Callback failed after ${max_attempts} attempts (${http_status})::$(cat /tmp/callback-response.txt)" + exit 1 + fi + attempt=$((attempt + 1)) + sleep 5 + done diff --git a/.github/actions/claude-analyze/analyze.mjs b/.github/actions/claude-analyze/analyze.mjs new file mode 100644 index 00000000..11a57036 --- /dev/null +++ b/.github/actions/claude-analyze/analyze.mjs @@ -0,0 +1,412 @@ +#!/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 process from "node:process"; +import Anthropic from "@anthropic-ai/sdk"; +import { parseAndValidate } from "./schema.mjs"; + +const HERE = path.dirname(new URL(import.meta.url).pathname); + +/** + * 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; + }; + 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: 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: /^(1|true|yes)$/i.test(process.env.CLAUDE_ANALYZE_DRY_RUN?.trim() ?? ""), + }; +} + +/** + * `title` / `route` から英数字の検索キーワードを抽出する。短すぎる語 (< 4 文字)、 + * よくある語 (`error`, `failed` など)、HTTP メソッドは除外。重複も除く。 + * + * 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". De-duplicates results. + * + * @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(/[^A-Za-z0-9_/.\-]+/) + .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 []; + /** @type {Set} */ + const hits = new Set(); + for (const kw of keywords) { + const res = spawnSync( + "git", + [ + "grep", + "-l", + "--", + kw, + // 巨大な 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 }, + ); + 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); + } + } + if (hits.size >= MAX_EXCERPT_FILES * 4) break; + } + // 候補が多すぎるとプロンプトが肥大化するので、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(); + console.log( + `[claude-analyze] api_error_id=${env.apiErrorId} sentry_issue_id=${env.sentryIssueId} title=${JSON.stringify(env.title)} route=${JSON.stringify(env.route)}`, + ); + + const keywords = deriveKeywords(env.title, env.route); + console.log(`[claude-analyze] keywords=${JSON.stringify(keywords)}`); + 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, + ai_suspected_files: candidateFiles.map((p) => ({ path: p, reason: "grep candidate" })), + }); + } else { + const client = new Anthropic({ apiKey: env.anthropicApiKey }); + 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). +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname; +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/prompt.md b/.github/actions/claude-analyze/prompt.md new file mode 100644 index 00000000..5f68c104 --- /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"`). + +``` +{{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..259f58ee --- /dev/null +++ b/.github/actions/claude-analyze/schema.mjs @@ -0,0 +1,104 @@ +/** + * 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(), + ai_suspected_files: z.array(suspectedFileSchema).nullable().optional(), + }) + .strict(); + +/** + * @typedef {z.infer} AnalysisOutput + */ + +/** + * Claude の生応答 (テキスト) から JSON を抽出して `analysisOutputSchema` で + * 検証する。Claude は時々 ```json ... ``` のコードフェンスで包んだり前置きを + * 付けたりするので、最初の `{` から最後の `}` までを切り出してパースする。 + * + * Extract a JSON object from Claude's raw text response and validate it + * against `analysisOutputSchema`. Claude occasionally wraps JSON in + * ```json ... ``` fences or adds prose preambles, so we slice from the first + * `{` to the last `}` rather than relying on `JSON.parse(raw)`. Throws + * `Error` with a descriptive message on malformed JSON or schema violation. + * + * @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 start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Could not locate a JSON object in Claude response"); + } + const slice = raw.slice(start, end + 1); + /** @type {unknown} */ + let parsed; + try { + parsed = JSON.parse(slice); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Claude response was not valid JSON: ${msg}`); + } + const result = analysisOutputSchema.safeParse(parsed); + if (!result.success) { + throw new Error(`Claude response failed schema validation: ${result.error.message}`); + } + return result.data; +} diff --git a/.github/workflows/analyze-error.yml b/.github/workflows/analyze-error.yml new file mode 100644 index 00000000..4ec5713d --- /dev/null +++ b/.github/workflows/analyze-error.yml @@ -0,0 +1,113 @@ +# `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 + +# 最小権限。callback は GitHub App の installation token 経由で行うため、 +# `GITHUB_TOKEN` には何も書かせない。 +# Minimum permissions. The callback uses a GitHub App installation token, so +# the default `GITHUB_TOKEN` needs nothing beyond `contents: read`. +permissions: + contents: read + +# 同一 sentry_issue_id への並行起動を防ぐ。再来時にも余計に Claude を呼ばないため。 +# Prevent concurrent runs for the same Sentry issue. Avoids spending Anthropic +# credits twice when the webhook briefly double-fires on the same issue id. +concurrency: + group: >- + analyze-error-${{ + github.event.client_payload.sentry_issue_id || + github.event.inputs.sentry_issue_id + }} + cancel-in-progress: false + +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 に使う。`actions/create-github-app-token` は App ID + + # Private Key から短命 installation token を発行し、自動で revoke する。 + # ワークフロー外には漏れない。`skip_callback=true` の場合はこのステップを + # スキップして、secrets 未設定環境(fork PR など)でもドライランが回るようにする。 + # + # Mint the bearer used for the API callback. `actions/create-github-app-token` + # exchanges App ID + Private Key for a short-lived installation token and + # auto-revokes it at job end. Skipped when `skip_callback=true` so the + # dry-run path works in environments without the App secrets configured. + if: github.event_name == 'repository_dispatch' || github.event.inputs.skip_callback != 'true' + id: app-token + uses: actions/create-github-app-token@v2 + 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' }} From 98f79bcce1e8fac67cc1018930c1662f7c360edf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 01:08:32 +0000 Subject: [PATCH 2/4] fix(ci): address PR #815 review feedback (#806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit / Codex / Gemini review fixes — all minimal, no architectural changes: - analyze.mjs: replace `path.dirname(new URL(...).pathname)` with `import.meta.dirname` (Node 20.11+, Windows-safe). - analyze.mjs: switch keyword extraction regex to Unicode property classes (`\p{L}\p{N}`, `u` flag) so non-ASCII Sentry titles (Japanese error messages, …) keep their tokens instead of being stripped to empty. - analyze.mjs: collapse the per-keyword `git grep` loop into a single `git grep -e KW1 -e KW2 ...` call. OR semantics are preserved (set union); avoids per-keyword process startup overhead. - analyze.mjs: only require `ANTHROPIC_API_KEY` when `dryRun` is false, so the default `workflow_dispatch` (`dry_run=true` + `skip_callback=true`) works in fork PRs and pre-secrets environments. - analyze.mjs: redact `title` / `route` in the startup log; emit `title_len` / `route_present` and a `keywords_count` only. Sentry's data scrubbing remains the primary defense; CI logs are a separate retention plane (defense-in-depth per Epic #616). - analyze.mjs: cap the dry-run stub's `ai_suspected_files` at 5 entries to keep it consistent with the new schema cap (below). - action.yml: add `--connect-timeout 10` and `--max-time 30` to the callback `curl` so a hung TCP session can't burn the 10-minute job timeout on a single PUT before the retry loop progresses. - prompt.md: add `text` language hint to the `{{repo_excerpts}}` fence (markdownlint MD040). - schema.mjs: add `.max(5)` to `ai_suspected_files`. The 5-entry cap was already documented in `prompt.md` and the README; enforce it at the schema layer so a Claude response that ignores the instruction fails CI rather than being PUT to the API. - __tests__: add `invalid-too-many-files.json` fixture + a 13th test case asserting `parseAndValidate` rejects 6+ entries. Skipped: `actions/setup-node@v6` Gemini suggestion — `v6` is the established repo convention used 16+ times in existing workflows (ci.yml, deploy-dev.yml, deploy-prod.yml, …) and the PR's Lint / Build jobs already pass on the same major. --- .../fixtures/invalid-too-many-files.json | 12 ++ .../claude-analyze/__tests__/schema.test.mjs | 5 + .github/actions/claude-analyze/action.yml | 9 ++ .github/actions/claude-analyze/analyze.mjs | 120 ++++++++++++------ .github/actions/claude-analyze/prompt.md | 2 +- .github/actions/claude-analyze/schema.mjs | 12 +- 6 files changed, 120 insertions(+), 40 deletions(-) create mode 100644 .github/actions/claude-analyze/__tests__/fixtures/invalid-too-many-files.json 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__/schema.test.mjs b/.github/actions/claude-analyze/__tests__/schema.test.mjs index fafa0c44..3df6cc99 100644 --- a/.github/actions/claude-analyze/__tests__/schema.test.mjs +++ b/.github/actions/claude-analyze/__tests__/schema.test.mjs @@ -72,6 +72,11 @@ test("invalid-suspected-file.json is rejected when an entry has no path", async 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:", diff --git a/.github/actions/claude-analyze/action.yml b/.github/actions/claude-analyze/action.yml index 97e19313..8a883d62 100644 --- a/.github/actions/claude-analyze/action.yml +++ b/.github/actions/claude-analyze/action.yml @@ -117,10 +117,19 @@ runs: 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 diff --git a/.github/actions/claude-analyze/analyze.mjs b/.github/actions/claude-analyze/analyze.mjs index 11a57036..73179501 100644 --- a/.github/actions/claude-analyze/analyze.mjs +++ b/.github/actions/claude-analyze/analyze.mjs @@ -37,7 +37,12 @@ import process from "node:process"; import Anthropic from "@anthropic-ai/sdk"; import { parseAndValidate } from "./schema.mjs"; -const HERE = path.dirname(new URL(import.meta.url).pathname); +// `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` 環境変数で @@ -116,6 +121,14 @@ function readEnv() { 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"), @@ -124,22 +137,28 @@ function readEnv() { // 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: must("ANTHROPIC_API_KEY"), + 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: /^(1|true|yes)$/i.test(process.env.CLAUDE_ANALYZE_DRY_RUN?.trim() ?? ""), + dryRun, }; } /** - * `title` / `route` から英数字の検索キーワードを抽出する。短すぎる語 (< 4 文字)、 + * `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". De-duplicates results. + * 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 @@ -169,7 +188,7 @@ export function deriveKeywords(title, route) { "into", ]); const tokens = `${title} ${route}` - .split(/[^A-Za-z0-9_/.\-]+/) + .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); @@ -191,38 +210,44 @@ export function deriveKeywords(title, route) { */ 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", + "-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(); - for (const kw of keywords) { - const res = spawnSync( - "git", - [ - "grep", - "-l", - "--", - kw, - // 巨大な 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 }, - ); - 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); - } + 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); } - if (hits.size >= MAX_EXCERPT_FILES * 4) break; } // 候補が多すぎるとプロンプトが肥大化するので、source パスらしいものを優先する。 // Rank: prefer real source files; deprioritize tests, docs, snapshots. @@ -352,12 +377,25 @@ async function callClaudeWithRetry(client, model, prompt) { */ 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=${JSON.stringify(env.title)} route=${JSON.stringify(env.route)}`, + `[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); - console.log(`[claude-analyze] keywords=${JSON.stringify(keywords)}`); + // 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); @@ -384,7 +422,13 @@ export async function main() { ai_summary: `(dry-run) would analyze ${env.title}`, ai_root_cause: null, ai_suggested_fix: null, - ai_suspected_files: candidateFiles.map((p) => ({ path: p, reason: "grep candidate" })), + // 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 { const client = new Anthropic({ apiKey: env.anthropicApiKey }); diff --git a/.github/actions/claude-analyze/prompt.md b/.github/actions/claude-analyze/prompt.md index 5f68c104..2e765ae9 100644 --- a/.github/actions/claude-analyze/prompt.md +++ b/.github/actions/claude-analyze/prompt.md @@ -33,7 +33,7 @@ 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}} ``` diff --git a/.github/actions/claude-analyze/schema.mjs b/.github/actions/claude-analyze/schema.mjs index 259f58ee..014319e6 100644 --- a/.github/actions/claude-analyze/schema.mjs +++ b/.github/actions/claude-analyze/schema.mjs @@ -56,7 +56,17 @@ export const analysisOutputSchema = z 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(), - ai_suspected_files: z.array(suspectedFileSchema).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(); From 6f3f7e14059b236964ab88ea330423552d0c5974 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 01:14:07 +0000 Subject: [PATCH 3/4] fix(ci): use fileURLToPath for entry-point check on Windows (#806) CodeRabbit review nitpick on PR #815: the `invokedDirectly` check still used `new URL(import.meta.url).pathname`, which yields a POSIX-style `/C:/path/...` on Windows that never matches `path.resolve(process.argv[1])` (`C:\path\...`). CI runs on Linux so this didn't affect the workflow, but the local-dev path on Windows would silently skip `main()` and exit 0 with no output. `fileURLToPath` normalizes correctly across platforms and is consistent with the earlier `import.meta.dirname` migration for `HERE`. --- .github/actions/claude-analyze/analyze.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/actions/claude-analyze/analyze.mjs b/.github/actions/claude-analyze/analyze.mjs index 73179501..e3bcdaed 100644 --- a/.github/actions/claude-analyze/analyze.mjs +++ b/.github/actions/claude-analyze/analyze.mjs @@ -33,6 +33,7 @@ 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"; @@ -445,8 +446,14 @@ export async function main() { // `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]) === new URL(import.meta.url).pathname; + 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); From fa3ad2e08585c9da697fb03683a154b026e5cf52 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 01:19:39 +0000 Subject: [PATCH 4/4] fix(ci): git grep -F + disable SDK retries (#806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more CodeRabbit nitpicks on PR #815: - analyze.mjs (grepCandidateFiles): add `git grep -F` so keywords match literally. Sentry titles often contain regex metacharacters (`.`, `(`, …) and the previous regex mode was broadening matches and diluting the candidate-file ranking with unrelated hits. - analyze.mjs (Claude client): pass `maxRetries: 0` when constructing the Anthropic client. The SDK defaults to `maxRetries: 2`, which combined with the outer `callClaudeWithRetry` loop (`MAX_ATTEMPTS=2`) produced a worst-case 2×2=4 upstream calls — outside issue #806's "1〜2 回まで" budget. The outer loop now owns retries exclusively. --- .github/actions/claude-analyze/analyze.mjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/actions/claude-analyze/analyze.mjs b/.github/actions/claude-analyze/analyze.mjs index e3bcdaed..94e3aeb2 100644 --- a/.github/actions/claude-analyze/analyze.mjs +++ b/.github/actions/claude-analyze/analyze.mjs @@ -224,6 +224,13 @@ export function grepCandidateFiles(keywords, workspace) { "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, "--", @@ -432,7 +439,15 @@ export async function main() { .map((p) => ({ path: p, reason: "grep candidate" })), }); } else { - const client = new Anthropic({ apiKey: env.anthropicApiKey }); + // 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); }