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
19 changes: 19 additions & 0 deletions server/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,22 @@ GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
GITHUB_APP_INSTALLATION_ID=
GITHUB_DISPATCH_REPOSITORY=

# Monitoring alert email + admin URL for Epic #616 Phase 3 (issue #809).
# - MONITORING_NOTIFY_EMAIL: recipient address for `notifyApiErrorAlert`. When
# unset the notifier is a no-op so the AI callback path works in unconfigured
# staging environments.
# - ADMIN_BASE_URL: public origin of the admin panel (e.g. `https://admin.zedi-note.app`).
# Used to build a `<base>/errors/<api_error_id>` link inside alert emails.
# Internal/private URLs must NOT be set here. When unset the alert email
# omits the link line and falls back to sentry_issue_id only.
#
# Epic #616 Phase 3(issue #809)用の通知設定。
# - MONITORING_NOTIFY_EMAIL: severity が `high` / `medium` の API エラー通知の
# 宛先メール。未設定時は notifier が no-op となり、AI コールバックは正常に
# 動作する(ステージング段階で通知のみ無効化できる)。
# - ADMIN_BASE_URL: 管理画面の公開オリジン(例: `https://admin.zedi-note.app`)。
# 通知メール本文に `<base>/errors/<api_error_id>` のリンクを差し込む際に使う。
# 内部 URL を設定してはいけない。未設定時はリンク行を省略する。
ADMIN_BASE_URL=
MONITORING_NOTIFY_EMAIL=
190 changes: 185 additions & 5 deletions server/api/src/__tests__/routes/webhooks/githubAiCallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe("PUT /api/webhooks/github/ai-result/:id", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
process.env.GITHUB_APP_ID = "123";
process.env.GITHUB_APP_INSTALLATION_ID = "456";
process.env.GITHUB_APP_PRIVATE_KEY = "stub";
Expand Down Expand Up @@ -216,17 +217,26 @@ describe("PUT /api/webhooks/github/ai-result/:id", () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const pre = {
id: VALID_UUID,
sentryIssueId: "abc",
severity: "unknown",
title: "TypeError",
};
const updated = {
id: VALID_UUID,
sentryIssueId: "abc",
severity: "high",
title: "TypeError",
aiSummary: "ヌルポインタ参照",
aiSuspectedFiles: [{ path: "src/a.ts", line: 12 }],
aiRootCause: "X が undefined",
aiSuggestedFix: "guard 追加",
};
// updateAiAnalysis issues a single update chain that resolves to [row].
const { db, chains } = createMockDb([[updated]]);
// The route now does a pre-read (`getApiErrorById`) before the UPDATE so
// it can compare pre/post severity for the Phase 3 notifier. Mock both
// chains: select then update.
const { db, chains } = createMockDb([[pre], [updated]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
Expand Down Expand Up @@ -258,7 +268,10 @@ describe("PUT /api/webhooks/github/ai-result/:id", () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const { db } = createMockDb([]);
// Pre-read returns the existing row; updateAiAnalysis then throws
// ApiErrorAiAnalysisValidationError on the bad severity → 400.
const pre = { id: VALID_UUID, sentryIssueId: "abc", severity: "unknown", title: "x" };
const { db } = createMockDb([[pre]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
Expand All @@ -279,7 +292,8 @@ describe("PUT /api/webhooks/github/ai-result/:id", () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const { db } = createMockDb([]);
const pre = { id: VALID_UUID, sentryIssueId: "abc", severity: "unknown", title: "x" };
const { db } = createMockDb([[pre]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
Expand All @@ -300,7 +314,8 @@ describe("PUT /api/webhooks/github/ai-result/:id", () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
// updateAiAnalysis returns null when the UPDATE returns no rows.
// The pre-read short-circuits to 404 when the row is missing, so the
// route never issues the UPDATE.
const { db } = createMockDb([[]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
Expand All @@ -317,4 +332,169 @@ describe("PUT /api/webhooks/github/ai-result/:id", () => {
});
expect(res.status).toBe(404);
});

it("notifies once when severity transitions from unknown into high (Phase 3 / #809)", async () => {
// severity が `unknown` から `high` へ初めて昇格したケース。1 回だけ
// notifier に渡すことを担保する。
//
// First-sight escalation from `unknown` → `high` must invoke the
// notifier exactly once with the post-update row's fields.
vi.resetModules();
const notifySpy = vi.fn().mockResolvedValue({ email: { sent: true, id: "e1" } });
await vi.doMock("../../../services/notifier.js", () => ({
notifyApiErrorAlert: notifySpy,
}));
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const pre = {
id: VALID_UUID,
sentryIssueId: "sentry-xyz",
severity: "unknown",
title: "TypeError",
};
const updated = { ...pre, severity: "high" };
const { db } = createMockDb([[pre], [updated]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: JSON.stringify({ severity: "high" }),
});
expect(res.status).toBe(200);
// fire-and-forget の microtask が消化されるのを待つ。
// Drain any pending microtasks queued by the fire-and-forget call.
await new Promise((resolve) => setImmediate(resolve));
expect(notifySpy).toHaveBeenCalledTimes(1);
expect(notifySpy).toHaveBeenCalledWith({
apiErrorId: VALID_UUID,
sentryIssueId: "sentry-xyz",
severity: "high",
title: "TypeError",
});
});

it("does NOT notify on idempotent retry (high → high)", async () => {
// GitHub Actions が同じ severity でリトライした場合、行はすでに
// 通知済みなので再送しない。
//
// GitHub Actions retries the AI workflow occasionally. If the row was
// already escalated and the callback re-asserts the same severity, we
// must NOT resend the alert.
vi.resetModules();
const notifySpy = vi.fn().mockResolvedValue({ email: { sent: true } });
await vi.doMock("../../../services/notifier.js", () => ({
notifyApiErrorAlert: notifySpy,
}));
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const pre = {
id: VALID_UUID,
sentryIssueId: "sentry-xyz",
severity: "high",
title: "TypeError",
};
const updated = { ...pre };
const { db } = createMockDb([[pre], [updated]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: JSON.stringify({ severity: "high" }),
});
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
expect(notifySpy).not.toHaveBeenCalled();
});

it("does NOT notify on partial callback that omits severity (already-escalated row)", async () => {
// severity を含まない部分更新(例: ai_summary だけ refresh)。pre が
// すでに `high` の行に対して再送が発生してはいけない。
//
// Partial callback that only refreshes a non-severity AI field on a row
// already at `high`. Pre and post severity match, so the notifier must
// not fire — even though the post-update severity is notifiable.
vi.resetModules();
const notifySpy = vi.fn().mockResolvedValue({ email: { sent: true } });
await vi.doMock("../../../services/notifier.js", () => ({
notifyApiErrorAlert: notifySpy,
}));
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const pre = {
id: VALID_UUID,
sentryIssueId: "sentry-xyz",
severity: "high",
title: "TypeError",
};
const updated = { ...pre, aiSummary: "更新後の要約" };
const { db } = createMockDb([[pre], [updated]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: JSON.stringify({ ai_summary: "更新後の要約" }),
});
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
expect(notifySpy).not.toHaveBeenCalled();
});

it("does NOT notify when severity downgrades into low/unknown", async () => {
// 通知済み行 (`high`) を `low` に下げるケースは通知しない。
//
// De-escalations from a notifiable severity back to `low`/`unknown`
// must not produce a fresh alert.
vi.resetModules();
const notifySpy = vi.fn().mockResolvedValue({ email: { sent: true } });
await vi.doMock("../../../services/notifier.js", () => ({
notifyApiErrorAlert: notifySpy,
}));
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const pre = {
id: VALID_UUID,
sentryIssueId: "sentry-xyz",
severity: "high",
title: "TypeError",
};
const updated = { ...pre, severity: "low" };
const { db } = createMockDb([[pre], [updated]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: JSON.stringify({ severity: "low" }),
});
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
expect(notifySpy).not.toHaveBeenCalled();
});
});
64 changes: 64 additions & 0 deletions server/api/src/routes/webhooks/githubAiCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
import { Hono } from "hono";
import {
ApiErrorAiAnalysisValidationError,
getApiErrorById,
updateAiAnalysis,
type UpdateAiAnalysisInput,
} from "../../services/apiErrorService.js";
import type { ApiErrorSeverity } from "../../schema/apiErrors.js";
import { publishApiErrorUpdate } from "../../services/apiErrorBroadcaster.js";
import { notifyApiErrorAlert } from "../../services/notifier.js";
import { verifyInstallationToken } from "../../lib/githubAppAuth.js";
import type { AppEnv } from "../../types/index.js";

Expand All @@ -39,6 +42,29 @@ const app = new Hono<AppEnv>();
*/
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
* Phase 3 / #809 で通知対象となる severity の集合。
* Severity values that warrant an email alert (Phase 3 / #809).
*/
const NOTIFIABLE_SEVERITIES: ReadonlySet<ApiErrorSeverity> = new Set(["high", "medium"]);

/**
* 1 行に対する通知を「冪等な再送 / 部分更新では再発火させない」ためのガード。
* `prev` が notifiable で `next` も notifiable のとき(例: high→high の冪等
* 再送、medium→high のエスカレート)は false を返す。`prev` が notifiable
* でなく `next` が notifiable のときだけ true。これにより 1 行あたり最大
* 1 通の運用通知に揃える。
*
* Returns true only when severity transitions from a non-notifiable value
* (`low` / `unknown`) into `high` or `medium`. Idempotent retries
* (high → high) and lateral moves between notifiable levels (medium → high)
* deliberately return false so each row produces at most one operational
* email over its lifetime.
*/
export function severityBecameNotifiable(prev: ApiErrorSeverity, next: ApiErrorSeverity): boolean {
return !NOTIFIABLE_SEVERITIES.has(prev) && NOTIFIABLE_SEVERITIES.has(next);
}

/**
* `Authorization: Bearer ...` ヘッダから token を抜き出す。
* Extract the bearer token from an `Authorization` header value, or null when
Expand Down Expand Up @@ -150,6 +176,22 @@ app.put("/:id", async (c) => {
}

const db = c.get("db");
// Phase 3 / #809 の通知は「severity が初めて high/medium に到達したとき」
// のみ発火させる。`updateAiAnalysis` は冪等なので、リトライや severity を
// 含まない部分更新のたびに通知すると重複アラートが発生する。よって
// UPDATE 前に行を読み、pre.severity と updated.severity を比較する。
// 行が無ければここで 404 を返し、後続の UPDATE を発行しない。
//
// Phase 3 / #809: only alert when severity *transitions* into high/medium
// for the first time. `updateAiAnalysis` is idempotent — retries or partial
// callbacks (e.g. an empty body or a callback that only refreshes
// `ai_summary`) would otherwise resend the alert. We read the row first so
// we can compare pre vs post severity, and so a missing row short-circuits
// before the UPDATE.
const pre = await getApiErrorById(db, id);
if (!pre) {
return c.json({ error: "Not found" }, 404);
}
try {
const updated = await updateAiAnalysis(db, { id, ...normalized });
if (!updated) {
Expand All @@ -161,6 +203,28 @@ app.put("/:id", async (c) => {
// SSE 購読者へ AI 解析結果を配信 (Phase 2 / issue #807)。
// Notify SSE subscribers so the admin UI updates without a page reload.
publishApiErrorUpdate(updated);
// 重要エラーのメール通知 (Phase 3 / issue #809)。`pre.severity` が
// notifiable でなく、かつ `updated.severity` が notifiable のときだけ
// 1 回だけ発火する。冪等な再送やエスカレート済み行への部分更新では
// 発火しない。`MONITORING_NOTIFY_EMAIL` 未設定時は notifier 側で no-op。
//
// Email alert for high-impact errors (Phase 3 / #809). Fires exactly when
// severity transitions from a non-notifiable value (`low` / `unknown` /
// null) into `high` or `medium`. Idempotent retries and partial updates
// on already-escalated rows are deliberate no-ops here. The notifier
// itself further no-ops when `MONITORING_NOTIFY_EMAIL` is unset.
if (severityBecameNotifiable(pre.severity, updated.severity)) {
// fire-and-forget: notifier 側でエラーは swallow 済み。webhook 応答を
// Resend 呼び出しで遅延させない。
// fire-and-forget so the webhook response doesn't await Resend; the
// notifier itself swallows transport errors.
void notifyApiErrorAlert({
apiErrorId: updated.id,
sentryIssueId: updated.sentryIssueId,
severity: updated.severity,
title: updated.title,
});
}
// 外部 (GitHub Actions) 向けの webhook なので、`error` キーを成功時に流用する
// 内部 admin API の慣習ではなく、`data` キーで返して "error 有無で失敗判定"
// できる素直な形にする(admin/src/api/admin.ts と異なり消費者がまだ存在しない)。
Expand Down
Loading
Loading