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
23 changes: 8 additions & 15 deletions src/review/maintainer-recap-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@
import { listRepositories } from "../db/repositories";
import { isAgentConfigured } from "../settings/autonomy";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
import { buildMaintainerRecap, type MaintainerRecapRepoInput } from "../services/maintainer-recap";
import { deliverRecapToDiscord } from "../services/notify-discord";
import { runMaintainerRecap, type MaintainerRecapRepoInput, type RunMaintainerRecapResult } from "../services/maintainer-recap";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { resolveGittensorySelfRepoFullName } from "../config/gittensory-repo-focus-manifest";
import { errorMessage, nowIso } from "../utils/json";
import type { RecapReport } from "../types";
import { errorMessage } from "../utils/json";

/** A manifest-sourced enable/cadence override (#2250) -- the `maintainerRecap` block of the gittensory
* self-repo's `.gittensory.yml` (see FocusManifestMaintainerRecapConfig). `present: false` (no block, or the
Expand Down Expand Up @@ -125,14 +123,11 @@ export async function resolveMaintainerRecapManifestOverride(env: Env): Promise<
}

/**
* Build the cross-repo RecapReport (#2239) over the recap's scan repos and deliver it to Discord. A per-repo
* aggregator failure is logged and that repo is skipped -- one repo's D1 hiccup must not blank the whole
* digest (mirrors ops-wire.ts's runOpsAlerts). deliverRecapToDiscord itself never throws (best-effort webhook).
* Load aggregator inputs for every scan repo, then delegate to {@link runMaintainerRecap} for build → format →
* dual-channel delivery. A per-repo aggregator failure is logged and that repo is skipped one repo's D1 hiccup
* must not blank the whole digest (mirrors ops-wire.ts's runOpsAlerts).
*/
export async function runMaintainerRecapJob(
env: Env,
windowDays?: number,
): Promise<{ report: RecapReport; delivery: { sent: boolean; reason?: string } }> {
export async function runMaintainerRecapJob(env: Env, windowDays?: number): Promise<RunMaintainerRecapResult> {
const resolvedWindowDays = windowDays ?? DEFAULT_RECAP_WINDOW_DAYS;
const repoNames = await recapScanRepos(env);
const repos: MaintainerRecapRepoInput[] = [];
Expand All @@ -149,7 +144,5 @@ export async function runMaintainerRecapJob(
);
}
}
const report = buildMaintainerRecap({ generatedAt: nowIso(), windowDays: resolvedWindowDays, repos });
const delivery = await deliverRecapToDiscord(env, report);
return { report, delivery };
return runMaintainerRecap(env, { windowDays: resolvedWindowDays, repos });
}
49 changes: 49 additions & 0 deletions src/services/maintainer-recap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
// PREDICTION precision; this is MULTI-repo and sourced from the realized gate-block + recommendation-outcome
// calibration ledgers (blocked-then-merged false positives, maintainer overrides, recommendation reversals).
import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN, PUBLIC_UNSAFE_PATTERN } from "../signals/redaction";
import { deliverRecapToDiscord, deliverRecapToSlack } from "./notify-discord";
import type { GatePrecisionReport } from "./gate-precision";
import type { OutcomeCalibration } from "./outcome-calibration";
import type { MaintainerRecapRepo, RecapReport } from "../types";
import { nowIso } from "../utils/json";

const DEFAULT_WINDOW_DAYS = 7;
const MIN_WINDOW_DAYS = 1;
Expand Down Expand Up @@ -150,3 +152,50 @@ export function formatMaintainerRecap(report: RecapReport): string {
];
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
}

export type RunMaintainerRecapResult =
| { skipped: true; reason: "disabled" }
| {
skipped: false;
report: RecapReport;
formatted: string;
delivery: {
discord: { sent: boolean; reason?: string };
slack: { sent: boolean; reason?: string };
};
};

/**
* End-to-end maintainer recap orchestration (#2252): build (or accept an injected report) →
* {@link formatMaintainerRecap} → fan out to Discord + Slack independently. Each deliverer is best-effort and
* never throws, so a single-channel outage does not abort the other. When `enabled === false`, short-circuits
* before any I/O (the flag-OFF arm mirrored by the cron/job processor).
*/
export async function runMaintainerRecap(
env: Env,
options: {
windowDays?: number;
generatedAt?: string;
repos?: MaintainerRecapRepoInput[];
/** Pre-built report for test injection; skips {@link buildMaintainerRecap} when set. */
report?: RecapReport;
/** When explicitly false, short-circuits before build/format/delivery. Default: run. */
enabled?: boolean;
} = {},
): Promise<RunMaintainerRecapResult> {
if (options.enabled === false) return { skipped: true, reason: "disabled" };

const report =
options.report ??
buildMaintainerRecap({
generatedAt: options.generatedAt ?? nowIso(),
windowDays: options.windowDays,
repos: options.repos ?? [],
});
const formatted = formatMaintainerRecap(report);
const [discord, slack] = await Promise.all([
deliverRecapToDiscord(env, report, formatted),
deliverRecapToSlack(env, report, formatted),
]);
return { skipped: false, report, formatted, delivery: { discord, slack } };
}
52 changes: 46 additions & 6 deletions src/services/notify-discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,15 @@ export async function notifyActionToDiscord(
* operator-level digest spanning many repos (`report.repos`), so there is no single repo to route by: it posts to
* the flat global `DISCORD_WEBHOOK_URL`. Best-effort and observable, mirroring `sendReviewRecapToDiscord`: an
* unset/invalid webhook or a send failure is recorded to the audit ledger (`maintainer_recap_notification.discord`)
* and returned as `{ sent: false, reason }` but never thrown, so a Discord outage never breaks the recap job. The
* `RecapReport` is already public-safe (buildMaintainerRecap sanitizes every free-text field), so no re-scrub here.
* and returned as `{ sent: false, reason }` but never thrown, so a Discord outage never breaks the recap job.
* When {@link runMaintainerRecap} passes a {@link formatMaintainerRecap} body, the embed description uses that
* redacted markdown instead of raw {@link RecapReport.summary} lines.
*/
export async function deliverRecapToDiscord(env: Env, report: RecapReport): Promise<{ sent: boolean; reason?: string }> {
export async function deliverRecapToDiscord(
env: Env,
report: RecapReport,
formattedBody?: string,
): Promise<{ sent: boolean; reason?: string }> {
const targetKey = `maintainer-recap:${report.windowDays}d`;
const auditMeta = { windowDays: report.windowDays, repoCount: report.repos.length };
const url = envString(env, "DISCORD_WEBHOOK_URL");
Expand All @@ -169,12 +174,13 @@ export async function deliverRecapToDiscord(env: Env, report: RecapReport): Prom
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "denied", detail: reason, metadata: auditMeta });
return { sent: false, reason };
}
const description = (formattedBody ?? report.summary.join("\n")).slice(0, 1800);
const body = {
username: "Gittensory",
embeds: [
{
title: `Maintainer recap · ${report.repos.length} repo(s) · ${report.windowDays}d`,
description: report.summary.join("\n").slice(0, 1800),
description,
color: 0x0969da,
fields: [
{ name: "Reviewed", value: `${report.totals.reviewed}`, inline: true },
Expand All @@ -200,9 +206,43 @@ export async function deliverRecapToDiscord(env: Env, report: RecapReport): Prom
}
}

/**
* Deliver a maintainer recap digest (#2246, the Slack channel of #1963) as a Block Kit mrkdwn section. Sibling of
* {@link deliverRecapToDiscord} — posts the already-{@link formatMaintainerRecap}-redacted body to `SLACK_WEBHOOK_URL`.
* Best-effort: never throws; records `maintainer_recap_notification.slack` audit events.
*/
export async function deliverRecapToSlack(
env: Env,
report: RecapReport,
formattedBody: string,
): Promise<{ sent: boolean; reason?: string }> {
const targetKey = `maintainer-recap:${report.windowDays}d`;
const auditMeta = { windowDays: report.windowDays, repoCount: report.repos.length };
const webhookUrl = envString(env, "SLACK_WEBHOOK_URL");
if (!webhookUrl || !isValidSlackWebhook(webhookUrl)) {
const reason = webhookUrl ? "invalid_webhook" : "missing_webhook";
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "gittensory", targetKey, outcome: "denied", detail: reason, metadata: auditMeta });
return { sent: false, reason };
}
const body = {
text: `Maintainer recap (${report.windowDays}d)`,
blocks: [{ type: "section", text: { type: "mrkdwn", text: escapeSlackMrkdwnText(formattedBody).slice(0, 2900) } }],
};
try {
await postWebhook(webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }, "slack");
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "gittensory", targetKey, outcome: "completed", detail: "sent", metadata: auditMeta });
return { sent: true };
} catch (error) {
const detail = errorMessage(error).slice(0, 160);
console.warn(JSON.stringify({ event: "maintainer_recap_slack_failed", message: detail }));
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.slack", actor: "gittensory", targetKey, outcome: "error", detail, metadata: auditMeta });
return { sent: false, reason: detail };
}
}

/** Slack incoming-webhook URL validation — only `https://hooks.slack.com/services/…`. Exported so other
* Slack senders (e.g. the recap digest's {@link deliverRecapToSlack}, review-recap.ts) reuse the SAME
* validation instead of re-typing the host/path allowlist. */
* Slack senders (e.g. review-recap.ts's per-repo {@link deliverRecapToSlack}) reuse the SAME validation
* instead of re-typing the host/path allowlist. */
export function isValidSlackWebhook(url: string): boolean {
try {
const parsed = new URL(url);
Expand Down
28 changes: 19 additions & 9 deletions test/unit/maintainer-recap-wire.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isRecapEnabled, resolveMaintainerRecapManifestOverride, runMaintainerRecapJob, shouldFireMaintainerRecap } from "../../src/review/maintainer-recap-wire";
import type { RunMaintainerRecapResult } from "../../src/services/maintainer-recap";
import { updatePullRequestSlopAssessment, upsertPullRequestFromGitHub, upsertRepositorySettings } from "../../src/db/repositories";
import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader";
import { createTestEnv } from "../helpers/d1";
Expand All @@ -8,6 +9,12 @@ const SELF_REPO = "JSONbored/gittensory";

const HOOK = "https://discord.com/api/webhooks/123/abc";

function ranRecap(result: RunMaintainerRecapResult): Extract<RunMaintainerRecapResult, { skipped: false }> {
expect(result.skipped).toBe(false);
if (result.skipped) throw new Error("expected recap job to run");
return result;
}

// Wrap env.DB.prepare so any SQL matching `pattern` throws, exercising a fail-safe catch; every other
// query delegates to the real test DB unchanged. Mirrors ops-wire.test.ts's poisonDbPrepare.
function poisonDbPrepare(env: Env, pattern: RegExp): void {
Expand Down Expand Up @@ -169,9 +176,10 @@ describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => {
await seedMergedPr(env, "owner/beta", 2);
const posted = stubDiscordFetch();

const { report, delivery } = await runMaintainerRecapJob(env);
const { report, delivery } = ranRecap(await runMaintainerRecapJob(env));

expect(delivery).toEqual({ sent: true });
expect(delivery.discord).toEqual({ sent: true });
expect(delivery.slack.sent).toBe(false);
expect(report.windowDays).toBe(7); // default when omitted
expect(report.repos.map((r) => r.repoFullName).sort()).toEqual(["owner/alpha", "owner/beta"]);
expect(report.totals.merged).toBe(3); // 1 (alpha) + 2 (beta)
Expand All @@ -184,7 +192,7 @@ describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => {
await seedMergedPr(env, "owner/alpha", 1);
stubDiscordFetch();

const { report } = await runMaintainerRecapJob(env, 30);
const { report } = ranRecap(await runMaintainerRecapJob(env, 30));

expect(report.windowDays).toBe(30);
});
Expand All @@ -198,7 +206,7 @@ describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => {
await seedMergedPr(env, "owner/unconfigured", 1);
stubDiscordFetch();

const { report } = await runMaintainerRecapJob(env);
const { report } = ranRecap(await runMaintainerRecapJob(env));

expect(report.repos.map((r) => r.repoFullName)).toEqual(["owner/configured"]);
});
Expand All @@ -214,7 +222,7 @@ describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => {
poisonDbPrepare(env, /"repository_settings"/i);
stubDiscordFetch();

const { report } = await runMaintainerRecapJob(env);
const { report } = ranRecap(await runMaintainerRecapJob(env));

expect(report.repos.map((r) => r.repoFullName).sort()).toEqual(["owner/alpha", "owner/beta"]);
});
Expand All @@ -228,10 +236,11 @@ describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => {
const warnings = vi.spyOn(console, "warn").mockImplementation(() => {});
stubDiscordFetch();

const { report, delivery } = await runMaintainerRecapJob(env); // resolves (never throws)
const { report, delivery } = ranRecap(await runMaintainerRecapJob(env)); // resolves (never throws)

expect(report.repos).toEqual([]);
expect(delivery).toEqual({ sent: true });
expect(delivery.discord).toEqual({ sent: true });
expect(delivery.slack.sent).toBe(false);
const logged = warnings.mock.calls.map((c) => String(c[0])).find((line) => line.includes("maintainer_recap_repo_error") && line.includes("owner/alpha"));
expect(logged).toBeDefined();
});
Expand All @@ -240,10 +249,11 @@ describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => {
const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK });
stubDiscordFetch();

const { report, delivery } = await runMaintainerRecapJob(env);
const { report, delivery } = ranRecap(await runMaintainerRecapJob(env));

expect(report.repos).toEqual([]);
expect(report.totals.gateFalsePositiveRate).toBeNull();
expect(delivery).toEqual({ sent: true });
expect(delivery.discord).toEqual({ sent: true });
expect(delivery.slack.sent).toBe(false);
});
});
Loading