diff --git a/.github/actions/claude-analyze/__tests__/schema.test.mjs b/.github/actions/claude-analyze/__tests__/schema.test.mjs index 3df6cc99..d533814d 100644 --- a/.github/actions/claude-analyze/__tests__/schema.test.mjs +++ b/.github/actions/claude-analyze/__tests__/schema.test.mjs @@ -95,8 +95,61 @@ test("parseAndValidate strips Claude's ```json``` fence and prose preamble", () 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"), /JSON object/); + 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", () => { diff --git a/.github/actions/claude-analyze/action.yml b/.github/actions/claude-analyze/action.yml index ae46f8e7..e5c013ef 100644 --- a/.github/actions/claude-analyze/action.yml +++ b/.github/actions/claude-analyze/action.yml @@ -156,11 +156,13 @@ runs: break fi if [ "${http_status}" -ge 400 ] && [ "${http_status}" -lt 500 ]; then - echo "::error title=Callback rejected (${http_status})::$(cat /tmp/callback-response.txt)" + 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 - echo "::error title=Callback failed after ${max_attempts} attempts (${http_status})::$(cat /tmp/callback-response.txt)" + 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)) diff --git a/.github/actions/claude-analyze/autoIssueRunner.mjs b/.github/actions/claude-analyze/autoIssueRunner.mjs index 6db207e5..6afea7b3 100644 --- a/.github/actions/claude-analyze/autoIssueRunner.mjs +++ b/.github/actions/claude-analyze/autoIssueRunner.mjs @@ -32,8 +32,9 @@ import { runAutoIssue } from "./autoIssue.mjs"; * @returns {string} */ function requireEnv(name) { - const value = process.env[name]; - if (typeof value !== "string" || value.length === 0) { + 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; 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 "); + 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/schema.mjs b/.github/actions/claude-analyze/schema.mjs index 014319e6..17ad8c89 100644 --- a/.github/actions/claude-analyze/schema.mjs +++ b/.github/actions/claude-analyze/schema.mjs @@ -74,16 +74,87 @@ export const analysisOutputSchema = z * @typedef {z.infer} 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} */ (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` で - * 検証する。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. + * 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} @@ -92,23 +163,28 @@ 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}`); + 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}`); + } } - return result.data; + 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 index f23b2612..806dc7da 100644 --- a/.github/workflows/analyze-error.yml +++ b/.github/workflows/analyze-error.yml @@ -69,16 +69,18 @@ permissions: contents: read issues: write -# 同一 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. +# 同一 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: false + cancel-in-progress: true jobs: analyze: diff --git a/admin/src/api/admin.test.ts b/admin/src/api/admin.test.ts index e7f7c61e..55d01f5b 100644 --- a/admin/src/api/admin.test.ts +++ b/admin/src/api/admin.test.ts @@ -286,12 +286,18 @@ describe("getApiErrorById", () => { }); 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({ error: sampleErrorRow }), { status: 200 }), + new Response(JSON.stringify({ error: rowNeedsEncoding }), { status: 200 }), + ); + const out = await getApiErrorById(rowNeedsEncoding.id); + expect(out).toEqual(rowNeedsEncoding); + expect(adminFetch).toHaveBeenCalledWith( + `/api/admin/errors/${encodeURIComponent(rowNeedsEncoding.id)}`, ); - const out = await getApiErrorById(sampleErrorRow.id); - expect(out).toEqual(sampleErrorRow); - expect(adminFetch).toHaveBeenCalledWith(`/api/admin/errors/${sampleErrorRow.id}`); }); }); diff --git a/admin/src/i18n/locales/en/nav.json b/admin/src/i18n/locales/en/nav.json index 2fe330a9..3d34c54c 100644 --- a/admin/src/i18n/locales/en/nav.json +++ b/admin/src/i18n/locales/en/nav.json @@ -2,7 +2,8 @@ "adminPanelTitle": "Zedi Admin", "adminShortTitle": "Admin", "menu": "Menu", - "unreadBadgeAriaLabel": "{{count}} unresolved API errors", + "unreadBadgeAriaLabel_one": "{{count}} unresolved API error", + "unreadBadgeAriaLabel_other": "{{count}} unresolved API errors", "items": { "aiModels": "AI Models", "users": "Users", diff --git a/admin/src/i18n/locales/ja/nav.json b/admin/src/i18n/locales/ja/nav.json index 03bc0290..7faa737d 100644 --- a/admin/src/i18n/locales/ja/nav.json +++ b/admin/src/i18n/locales/ja/nav.json @@ -2,7 +2,8 @@ "adminPanelTitle": "Zedi 管理画面", "adminShortTitle": "管理画面", "menu": "メニュー", - "unreadBadgeAriaLabel": "未対応の API エラー {{count}} 件", + "unreadBadgeAriaLabel_one": "未対応の API エラー {{count}} 件", + "unreadBadgeAriaLabel_other": "未対応の API エラー {{count}} 件", "items": { "aiModels": "AI モデル", "users": "ユーザー管理", 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 }) => {children}, + Button: ({ + children, + onClick, + disabled, + variant, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: string; + }) => ( + + ), + Dialog: ({ + open, + onOpenChange, + children, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + }) => + open ? ( +
+ + {children} +
+ ) : null, + DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + Select: ({ + value, + onValueChange, + disabled, + }: { + value: string; + onValueChange: (v: string) => void; + disabled?: boolean; + children?: React.ReactNode; + }) => ( + + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => {children}, + 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( + , + ); + + 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( + , + ); + + rerender( + , + ); + + expect(screen.getByTestId("status-select")).toHaveValue("open"); + }); +}); diff --git a/admin/src/pages/errors/ErrorDetailDialog.tsx b/admin/src/pages/errors/ErrorDetailDialog.tsx index d2660091..0164158b 100644 --- a/admin/src/pages/errors/ErrorDetailDialog.tsx +++ b/admin/src/pages/errors/ErrorDetailDialog.tsx @@ -59,6 +59,12 @@ export function ErrorDetailDialog({ setPendingFor({ id: row.id, status: next }); }; + /** オーバーレイ・ESC と同様に、キャンセルボタンでも未保存選択を破棄する。 */ + const handleClose = () => { + setPendingFor(null); + onClose(); + }; + if (!row) return null; const effectiveStatus: ApiErrorStatus = pendingStatus ?? row.status; @@ -70,7 +76,14 @@ export function ErrorDetailDialog({ }; return ( - !open && onClose()}> + { + if (!open) { + handleClose(); + } + }} + > {row.title} @@ -186,7 +199,7 @@ export function ErrorDetailDialog({ -