Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions .github/actions/claude-analyze/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"severity": "critical",
"ai_summary": "Severity is not one of the allowed enum values."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"severity": "medium",
"ai_root_cause": "ai_summary is missing"
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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" }
]
}
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
Original file line number Diff line number Diff line change
@@ -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
}
123 changes: 123 additions & 0 deletions .github/actions/claude-analyze/__tests__/schema.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* 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<string>}
*/
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 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);
});
Loading
Loading