Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
36 changes: 35 additions & 1 deletion .github/actions/claude-analyze/__tests__/schema.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,42 @@ 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 throws on empty input", () => {
Expand Down
6 changes: 4 additions & 2 deletions .github/actions/claude-analyze/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
5 changes: 3 additions & 2 deletions .github/actions/claude-analyze/autoIssueRunner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions .github/actions/claude-analyze/escapeGithubAnnotation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* GitHub Actions のワークフローコマンド(例: ::error)へ埋め込む本文をエスケープする。
* `%` / CR / LF / `:` がそのままだと注釈が壊れたり追加コマンドとして解釚される。
*
* Escape text embedded in GitHub Actions workflow commands (e.g. ::error).
* Raw `%`, CR/LF, and `:` can corrupt annotations or inject extra commands.
*
* @see https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
*/
import fs from "node:fs";

const path = process.argv[2];
if (!path) {
console.error("usage: escapeGithubAnnotation.mjs <file>");
process.exit(1);
}
const s = fs.readFileSync(path, "utf8");
process.stdout.write(
s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A"),
);
123 changes: 98 additions & 25 deletions .github/actions/claude-analyze/schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,84 @@ export const analysisOutputSchema = z
* @typedef {z.infer<typeof analysisOutputSchema>} AnalysisOutput
*/

/**
* @param {string} str
* @param {number} startIdx - index of `{`
* @returns {string | null} balanced JSON object substring or null
*/
function extractBalancedJsonObject(str, startIdx) {
if (str[startIdx] !== "{") return null;
let depth = 0;
let inString = false;
let stringEscape = false;
for (let i = startIdx; i < str.length; i++) {
const c = str[i];
if (stringEscape) {
stringEscape = false;
continue;
}
if (inString) {
if (c === "\\") {
stringEscape = true;
continue;
}
if (c === '"') {
inString = false;
}
continue;
}
if (c === '"') {
inString = true;
continue;
}
if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) return str.slice(startIdx, i + 1);
}
}
return null;
}

/**
* severity + ai_summary を持つオブジェクトか(ネストした suspected_files エントリと区別)。
*
* Whether `value` looks like the top-level analysis payload rather than a nested
* suspected-file entry (which lacks `ai_summary`).
*
* @param {unknown} value
* @returns {boolean}
*/
function looksLikeAnalysisPayload(value) {
if (!value || typeof value !== "object") return false;
const o = /** @type {Record<string, unknown>} */ (value);
return typeof o.severity === "string" && typeof o.ai_summary === "string";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* ```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}
Expand All @@ -92,23 +160,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}`);
}
10 changes: 6 additions & 4 deletions .github/workflows/analyze-error.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 10 additions & 4 deletions admin/src/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
});

Expand Down
3 changes: 2 additions & 1 deletion admin/src/i18n/locales/en/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion admin/src/i18n/locales/ja/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"adminPanelTitle": "Zedi 管理画面",
"adminShortTitle": "管理画面",
"menu": "メニュー",
"unreadBadgeAriaLabel": "未対応の API エラー {{count}} 件",
"unreadBadgeAriaLabel_one": "未対応の API エラー {{count}} 件",
"unreadBadgeAriaLabel_other": "未対応の API エラー {{count}} 件",
"items": {
"aiModels": "AI モデル",
"users": "ユーザー管理",
Expand Down
10 changes: 9 additions & 1 deletion admin/src/pages/errors/ErrorDetailDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@ export function ErrorDetailDialog({
};

return (
<Dialog open={row !== null} onOpenChange={(open) => !open && onClose()}>
<Dialog
open={row !== null}
onOpenChange={(open) => {
if (!open) {
setPendingFor(null);
onClose();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="break-all">{row.title}</DialogTitle>
Expand Down
4 changes: 2 additions & 2 deletions admin/src/pages/errors/ErrorsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export function ErrorsContent({
{t("errors.filters.status")}
</label>
<Select
value={statusFilter}
value={statusFilter === "all" ? ANY : statusFilter}
onValueChange={(v) => onStatusFilterChange(v === ANY ? "all" : (v as ApiErrorStatus))}
>
<SelectTrigger id="errors-filter-status" aria-labelledby="errors-filter-status-label">
Expand All @@ -151,7 +151,7 @@ export function ErrorsContent({
{t("errors.filters.severity")}
</label>
<Select
value={severityFilter}
value={severityFilter === "all" ? ANY : severityFilter}
onValueChange={(v) =>
onSeverityFilterChange(v === ANY ? "all" : (v as ApiErrorSeverity))
}
Expand Down
27 changes: 26 additions & 1 deletion admin/src/pages/errors/useApiErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ function mergeRow(prev: GetApiErrorsResponse | null, row: ApiErrorRow): GetApiEr
};
}

/**
* SSE でフィルタに一致しない更新が来たとき、一覧から該当 ID を落とす。
*
* Drop a row id from the cached list when an SSE update no longer matches the
* active filter (so filtered views do not keep stale rows after status churn).
*/
function dropRowById(
prev: GetApiErrorsResponse | null,
rowId: string,
): GetApiErrorsResponse | null {
if (!prev) return prev;
const idx = prev.errors.findIndex((r) => r.id === rowId);
if (idx === -1) return prev;
return {
...prev,
errors: prev.errors.filter((r) => r.id !== rowId),
total: Math.max(0, prev.total - 1),
};
}

/**
* フィルタ条件と SSE で push された行が合致するかを判定する。
* 合致しないものは UI に出さない(一覧の意味的な整合を保つ)。
Expand Down Expand Up @@ -223,7 +243,12 @@ export function useApiErrors(params: UseApiErrorsParams = {}): UseApiErrorsResul
return;
}
const { status: curStatus, severity: curSeverity } = filterRef.current;
if (!matchesFilter(row, curStatus, curSeverity)) return;
if (!matchesFilter(row, curStatus, curSeverity)) {
if (isMountedRef.current) {
setData((prev) => dropRowById(prev, row.id));
}
return;
}
if (isMountedRef.current) {
setData((prev) => mergeRow(prev, row));
}
Expand Down
Loading
Loading