diff --git a/src/env.d.ts b/src/env.d.ts index 81ca26b400..36c45b03c6 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -338,6 +338,17 @@ declare global { * recording are wired, reading a promoted override into the live gate is a noted follow-up that must not * risk loosening the gate. See src/review/selftune-wire.ts. */ GITTENSORY_REVIEW_SELFTUNE?: string; + /** Maintainer recap digest (#1963, #2248): when truthy, a cross-repo RecapReport -- gittensory's OWN + * gate-precision + outcome-calibration data folded across every scanned repo (buildMaintainerRecap, + * #2239) -- is delivered to Discord on a cron cadence. GITTENSORY_RECAP_CADENCE ("daily" | "weekly", + * default "weekly"; an invalid value falls back to "weekly") picks how often; GITTENSORY_RECAP_HOUR + * (0-23, default 14) and GITTENSORY_RECAP_DAY (0-6, Sunday=0, default 1/Monday, only consulted when + * weekly) pick when, so the tick fires at most once per period. Default OFF -- unset/false means the + * cron enqueues NO recap job, byte-identical to today. See src/review/maintainer-recap-wire.ts. */ + GITTENSORY_MAINTAINER_RECAP?: string; + GITTENSORY_RECAP_CADENCE?: string; + GITTENSORY_RECAP_HOUR?: string; + GITTENSORY_RECAP_DAY?: string; /** #1941: route the live CI aggregate (the gate's check/status read) through ONE GraphQL statusCheckRollup * query instead of the paginated /check-runs + /status + /check-suites REST reads, moving that hot path onto * the separate GraphQL rate-limit bucket. Default OFF (byte-identical, proven REST aggregate); when ON the diff --git a/src/index.ts b/src/index.ts index d1c5d791f2..8a8811b0a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { processDlqBatch } from "./queue/dlq"; import { processJob } from "./queue/processors"; import { isOrbBrokerEnabled } from "./orb/broker"; import { isOpsEnabled } from "./review/ops-wire"; +import { isRecapEnabled, shouldFireMaintainerRecap } from "./review/maintainer-recap-wire"; import { isSweepWatchdogEnabled } from "./review/sweep-watchdog"; import { isPrReconciliationEnabled } from "./review/pr-reconciliation"; import { isRagEnabled } from "./review/rag-wire"; @@ -215,6 +216,14 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController): if (isHourly && hour === 9 && selfHostedReviews) { jobs.push({ type: "repo-doc-refresh-sweep", requestedBy: "schedule" }); } + // Maintainer recap digest (#1963, #2248; flag GITTENSORY_MAINTAINER_RECAP). Cross-repo RecapReport delivered + // to Discord on a configurable cadence (GITTENSORY_RECAP_CADENCE=daily|weekly, default weekly) at the + // configured hour/day-of-week (GITTENSORY_RECAP_HOUR / GITTENSORY_RECAP_DAY). Enqueued ONLY when the flag is + // ON and this tick matches the configured cadence -- flag-OFF (default) this job is never created, so the + // cron tick does ZERO new work and the enqueued set is byte-identical to today. + if (selfHostedReviews && isRecapEnabled(env) && isHourly && shouldFireMaintainerRecap(env, hour, scheduledAt.getUTCDay())) { + jobs.push({ type: "generate-maintainer-recap", requestedBy: "schedule" }); + } if (isFullSyncWindow) { jobs.push({ type: "generate-signal-snapshots", requestedBy: "schedule" }); jobs.push({ type: "build-burden-forecasts", requestedBy: "schedule" }); diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c2a2342375..0c5ebfcfdf 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -550,6 +550,7 @@ import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guard import { resolveUnlinkedIssueMatchDisposition } from "../review/unlinked-issue-guardrail"; import { DEFAULT_SCREENSHOT_TABLE_GATE, evaluateScreenshotTableGate } from "../review/screenshot-table-gate"; import { isOpsEnabled, runOpsAlerts } from "../review/ops-wire"; +import { isRecapEnabled, runMaintainerRecapJob } from "../review/maintainer-recap-wire"; import { isSweepWatchdogEnabled, runSweepLivenessWatchdog } from "../review/sweep-watchdog"; import { isPrReconciliationEnabled, runOpenPrReconciliation } from "../review/pr-reconciliation"; import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire"; @@ -1108,6 +1109,12 @@ export async function processJob(env: Env, message: JobMessage): Promise { case "generate-review-recap": await runReviewRecapJob(env, message.repoFullName, message.windowDays); return; + case "generate-maintainer-recap": + // Convergence (maintainer recap digest, flag GITTENSORY_MAINTAINER_RECAP, #1963/#2248). Defense-in-depth: + // the cron only ENQUEUES this when the flag is ON, but a stale in-flight job that lands after a flag-flip + // must still no-op, so flag-OFF does zero work here too. + if (isRecapEnabled(env)) await runMaintainerRecapJob(env, message.windowDays); + return; case "agent-regate-sweep": if (!message.repoFullName && message.requestedBy !== "test") { await fanOutAgentRegateSweepJobs(env, message.requestedBy); diff --git a/src/review/maintainer-recap-wire.ts b/src/review/maintainer-recap-wire.ts new file mode 100644 index 0000000000..1ed0f42a39 --- /dev/null +++ b/src/review/maintainer-recap-wire.ts @@ -0,0 +1,118 @@ +// Maintainer recap digest scheduling (#1963, #2248; flag GITTENSORY_MAINTAINER_RECAP). The cron-driven trigger +// for the CROSS-repo RecapReport digest (buildMaintainerRecap, #2239) -- distinct from generate-review-recap's +// single-repo ReviewRecap job, which is manually-triggerable only (review-recap.ts). Flag-gated and OFF by +// default, mirroring isOpsEnabled: flag-OFF, the cron enqueues no job and this module's exports are never +// invoked, so the deploy is byte-identical to today. +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 { errorMessage, nowIso } from "../utils/json"; +import type { RecapReport } from "../types"; + +/** True when the cross-repo maintainer recap digest is enabled. Flag-OFF (default) -- the cron enqueues no job + * and runMaintainerRecapJob is never invoked. Truthy follows the codebase convention (same as isOpsEnabled). */ +export function isRecapEnabled(env: { GITTENSORY_MAINTAINER_RECAP?: string | undefined }): boolean { + return /^(1|true|yes|on)$/i.test(env.GITTENSORY_MAINTAINER_RECAP ?? ""); +} + +export type RecapCadence = "daily" | "weekly"; + +const DEFAULT_RECAP_CADENCE: RecapCadence = "weekly"; +/** 14:00 UTC -- distinct from the weekly-value-report's Monday-12:00 slot so the two digests never collide. */ +const DEFAULT_RECAP_HOUR = 14; +/** Monday (UTC) -- same day the weekly-value-report's operator digest already uses. */ +const DEFAULT_RECAP_DAY_OF_WEEK = 1; +const MIN_HOUR = 0; +const MAX_HOUR = 23; +const MIN_DAY_OF_WEEK = 0; +const MAX_DAY_OF_WEEK = 6; +const DEFAULT_RECAP_WINDOW_DAYS = 7; + +function normalizeRecapCadence(value: string | undefined): RecapCadence { + return value === "daily" || value === "weekly" ? value : DEFAULT_RECAP_CADENCE; +} + +function normalizeRecapHour(value: string | undefined): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return DEFAULT_RECAP_HOUR; + return Math.max(MIN_HOUR, Math.min(MAX_HOUR, Math.round(numeric))); +} + +function normalizeRecapDayOfWeek(value: string | undefined): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return DEFAULT_RECAP_DAY_OF_WEEK; + return Math.max(MIN_DAY_OF_WEEK, Math.min(MAX_DAY_OF_WEEK, Math.round(numeric))); +} + +/** + * True on the one cron tick per period the maintainer recap should fire: "daily" fires every day at the + * configured hour; "weekly" fires ONLY on the configured day-of-week at that hour, so the tick fires at most + * once per period. Caller passes the SAME `hour` / `dayOfWeek` enqueueScheduledJobs already derived from + * `scheduledAt` (src/index.ts) -- no new Date parsing here. An invalid GITTENSORY_RECAP_CADENCE value falls + * back to the "weekly" default rather than silently firing daily, so a typo'd env var can't quietly spam the + * digest more often than intended. + */ +export function shouldFireMaintainerRecap( + env: { + GITTENSORY_RECAP_CADENCE?: string | undefined; + GITTENSORY_RECAP_HOUR?: string | undefined; + GITTENSORY_RECAP_DAY?: string | undefined; + }, + hour: number, + dayOfWeek: number, +): boolean { + if (hour !== normalizeRecapHour(env.GITTENSORY_RECAP_HOUR)) return false; + const cadence = normalizeRecapCadence(env.GITTENSORY_RECAP_CADENCE); + return cadence === "daily" || dayOfWeek === normalizeRecapDayOfWeek(env.GITTENSORY_RECAP_DAY); +} + +/** The repos this recap scans. Mirrors ops-wire.ts's opsScanRepos / pr-reconciliation.ts's watchedRepos: prefer + * agent-configured repos when any opted in (the acting-autonomy surface), else fall back to every registered + * repo so the digest still reports before the agent is enabled anywhere. */ +async function recapScanRepos(env: Env): Promise { + const repos = (await listRepositories(env)).filter((repo) => repo.isRegistered); + const configured: string[] = []; + for (const repo of repos) { + try { + const settings = await resolveRepositorySettings(env, repo.fullName); + if (isAgentConfigured(settings.autonomy)) configured.push(repo.fullName); + } catch { + /* a settings blip on one repo must not abort the whole scan */ + } + } + return configured.length > 0 ? configured : repos.map((repo) => repo.fullName); +} + +/** + * 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). + */ +export async function runMaintainerRecapJob( + env: Env, + windowDays?: number, +): Promise<{ report: RecapReport; delivery: { sent: boolean; reason?: string } }> { + const resolvedWindowDays = windowDays ?? DEFAULT_RECAP_WINDOW_DAYS; + const repoNames = await recapScanRepos(env); + const repos: MaintainerRecapRepoInput[] = []; + for (const repoFullName of repoNames) { + try { + const [gatePrecision, calibration] = await Promise.all([ + loadGatePrecisionReport(env, repoFullName, { windowDays: resolvedWindowDays }), + buildRepoOutcomeCalibration(env, repoFullName, resolvedWindowDays), + ]); + repos.push({ gatePrecision, calibration }); + } catch (error) { + console.warn( + JSON.stringify({ event: "maintainer_recap_repo_error", repo: repoFullName, message: errorMessage(error).slice(0, 200) }), + ); + } + } + const report = buildMaintainerRecap({ generatedAt: nowIso(), windowDays: resolvedWindowDays, repos }); + const delivery = await deliverRecapToDiscord(env, report); + return { report, delivery }; +} diff --git a/src/selfhost/maintenance-admission.ts b/src/selfhost/maintenance-admission.ts index caa07a38f4..90383bd0d5 100644 --- a/src/selfhost/maintenance-admission.ts +++ b/src/selfhost/maintenance-admission.ts @@ -53,6 +53,7 @@ export const MAINTENANCE_JOB_TYPES: ReadonlySet = new Set([ "prune-retention", "generate-weekly-value-report", "generate-review-recap", + "generate-maintainer-recap", "generate-signal-snapshots", "notify-evaluate", "notify-deliver", diff --git a/src/types.ts b/src/types.ts index d4c0ff1ca1..6332b9bb5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -157,6 +157,15 @@ export type JobMessage = repoFullName: string; windowDays?: number; } + | { + // Cross-repo maintainer recap digest (#1963, #2248): folds gate-precision + outcome-calibration across + // every scanned repo into ONE RecapReport (buildMaintainerRecap, #2239) and delivers it to Discord -- + // distinct from "generate-review-recap" above, which is single-repo. No `repoFullName`: this is always + // a global job, enqueued by the cron on a configurable daily/weekly cadence (GITTENSORY_RECAP_CADENCE). + type: "generate-maintainer-recap"; + requestedBy: "schedule" | "api" | "test"; + windowDays?: number; + } | { // Scheduled re-gate sweep (#777). No `repoFullName` = fan-out: enqueue one per agent-configured repo. // With `repoFullName` = recompute the gate verdict for that repo's stale open PRs (advisory/audit only). diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 3f7b65b72f..c4913bba0b 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -816,6 +816,64 @@ describe("worker entrypoint", () => { ]), ); }); + + it("enqueues the maintainer recap digest on the default weekly cadence (Monday 14:00 UTC) ONLY when GITTENSORY_MAINTAINER_RECAP is ON (#2248, flag-OFF is byte-identical)", async () => { + const sentFor = async (recapFlag?: string): Promise> => { + const sent: Array = []; + const env = createTestEnv({ + ...(recapFlag === undefined ? {} : { GITTENSORY_MAINTAINER_RECAP: recapFlag }), + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + await worker.scheduled(controllerFor("2026-06-01T14:00:00.000Z"), env, executionContext(waitUntil)); // Monday, 14:00 UTC + await Promise.all(waitUntil); + return sent; + }; + + // Flag OFF (default) → no recap job; the enqueued set is unchanged from today. + expect((await sentFor()).some((m) => m.type === "generate-maintainer-recap")).toBe(false); + expect((await sentFor("false")).some((m) => m.type === "generate-maintainer-recap")).toBe(false); + // Flag ON, on the default weekly cadence tick → exactly one recap job. + const on = await sentFor("true"); + expect(on.filter((m) => m.type === "generate-maintainer-recap")).toEqual([{ type: "generate-maintainer-recap", requestedBy: "schedule" }]); + }); + + it("does NOT enqueue the maintainer recap digest outside its configured cadence even when GITTENSORY_MAINTAINER_RECAP is ON", async () => { + const sent: Array = []; + const env = createTestEnv({ + GITTENSORY_MAINTAINER_RECAP: "true", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + await worker.scheduled(controllerFor("2026-06-02T14:00:00.000Z"), env, executionContext(waitUntil)); // Tuesday, not the weekly default day + await Promise.all(waitUntil); + expect(sent.some((m) => m.type === "generate-maintainer-recap")).toBe(false); + }); + + it("honors a custom GITTENSORY_RECAP_CADENCE=daily, firing every day at the configured hour", async () => { + const sent: Array = []; + const env = createTestEnv({ + GITTENSORY_MAINTAINER_RECAP: "true", + GITTENSORY_RECAP_CADENCE: "daily", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + await worker.scheduled(controllerFor("2026-06-02T14:00:00.000Z"), env, executionContext(waitUntil)); // Tuesday — not the weekly default day + await Promise.all(waitUntil); + expect(sent.filter((m) => m.type === "generate-maintainer-recap")).toEqual([{ type: "generate-maintainer-recap", requestedBy: "schedule" }]); + }); }); function controllerFor(iso: string): ScheduledController { diff --git a/test/unit/maintainer-recap-wire.test.ts b/test/unit/maintainer-recap-wire.test.ts new file mode 100644 index 0000000000..948fa86044 --- /dev/null +++ b/test/unit/maintainer-recap-wire.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isRecapEnabled, runMaintainerRecapJob, shouldFireMaintainerRecap } from "../../src/review/maintainer-recap-wire"; +import { updatePullRequestSlopAssessment, upsertPullRequestFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const HOOK = "https://discord.com/api/webhooks/123/abc"; + +// 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 { + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + if (pattern.test(sql)) throw new Error("poisoned query"); + return realPrepare(sql); + }) as typeof env.DB.prepare; +} + +// Mark a repo registered so recapScanRepos picks it up (mirrors ops-wire.test.ts's seedRegisteredRepo). +async function seedRegisteredRepo(env: Env, fullName: string): Promise { + const [owner, name] = fullName.split("/"); + await (env.DB as unknown as { prepare: (s: string) => { bind: (...v: unknown[]) => { run: () => Promise } } }) + .prepare("INSERT INTO repositories (full_name, owner, name, is_installed, is_registered) VALUES (?, ?, ?, 1, 1)") + .bind(fullName, owner, name) + .run(); +} + +// A resolved, merged PR carrying a slop assessment so it counts in buildRepoOutcomeCalibration's slop bands. +async function seedMergedPr(env: Env, repoFullName: string, number: number): Promise { + await upsertPullRequestFromGitHub(env, repoFullName, { number, title: `PR ${number}`, state: "closed", merged_at: "2026-06-01T00:00:00.000Z" }); + await updatePullRequestSlopAssessment(env, repoFullName, number, { slopRisk: 0, slopBand: "clean" }); +} + +// Only RECORDS calls to the Discord webhook itself -- recapScanRepos's resolveRepositorySettings also fetches +// each repo's .gittensory.yml (loadRepoFocusManifest), which must keep succeeding (generic 204) but not be +// mistaken for a webhook post. +function stubDiscordFetch(): Array<{ body: string }> { + const calls: Array<{ body: string }> = []; + vi.stubGlobal("fetch", async (url: RequestInfo | URL, init?: RequestInit) => { + if (String(url) === HOOK) calls.push({ body: init?.body ? String(init.body) : "" }); + return new Response(null, { status: 204 }); + }); + return calls; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("isRecapEnabled — default OFF, truthy convention", () => { + it("is OFF for unset / false / empty, ON for 1/true/yes/on", () => { + for (const off of [undefined, "", "false", "no", "0", "off"]) expect(isRecapEnabled({ GITTENSORY_MAINTAINER_RECAP: off })).toBe(false); + for (const on of ["1", "true", "yes", "on", "TRUE", "On"]) expect(isRecapEnabled({ GITTENSORY_MAINTAINER_RECAP: on })).toBe(true); + }); +}); + +describe("shouldFireMaintainerRecap — cadence gate (#2248)", () => { + it("fires the weekly default (Monday 14:00 UTC) and nowhere else", () => { + expect(shouldFireMaintainerRecap({}, 14, 1)).toBe(true); // Monday @ 14:00 UTC + expect(shouldFireMaintainerRecap({}, 14, 2)).toBe(false); // wrong day + expect(shouldFireMaintainerRecap({}, 15, 1)).toBe(false); // wrong hour + }); + + it("an explicit weekly cadence behaves exactly like the default", () => { + expect(shouldFireMaintainerRecap({ GITTENSORY_RECAP_CADENCE: "weekly" }, 14, 1)).toBe(true); + expect(shouldFireMaintainerRecap({ GITTENSORY_RECAP_CADENCE: "weekly" }, 14, 2)).toBe(false); + }); + + it("daily cadence fires every day at the configured hour, ignoring day-of-week", () => { + const env = { GITTENSORY_RECAP_CADENCE: "daily" }; + expect(shouldFireMaintainerRecap(env, 14, 1)).toBe(true); + expect(shouldFireMaintainerRecap(env, 14, 3)).toBe(true); + expect(shouldFireMaintainerRecap(env, 14, 6)).toBe(true); + expect(shouldFireMaintainerRecap(env, 15, 3)).toBe(false); // still hour-gated + }); + + it("an invalid cadence value falls back to weekly (not daily), so a typo can't quietly fire more often", () => { + const env = { GITTENSORY_RECAP_CADENCE: "biweekly" }; + expect(shouldFireMaintainerRecap(env, 14, 1)).toBe(true); // Monday still fires (weekly default) + expect(shouldFireMaintainerRecap(env, 14, 2)).toBe(false); // Tuesday does not — proves it is NOT daily + }); + + it("respects a custom configured hour and day-of-week", () => { + const env = { GITTENSORY_RECAP_CADENCE: "weekly", GITTENSORY_RECAP_HOUR: "3", GITTENSORY_RECAP_DAY: "5" }; + expect(shouldFireMaintainerRecap(env, 3, 5)).toBe(true); + expect(shouldFireMaintainerRecap(env, 3, 1)).toBe(false); // the default Monday no longer applies + expect(shouldFireMaintainerRecap(env, 14, 5)).toBe(false); // the default hour no longer applies + }); + + it("clamps an out-of-range (but finite) hour/day to the nearest bound", () => { + const env = { GITTENSORY_RECAP_HOUR: "99", GITTENSORY_RECAP_DAY: "-3" }; + expect(shouldFireMaintainerRecap(env, 23, 0)).toBe(true); // 99 → 23 (MAX_HOUR), -3 → 0 (MIN_DAY_OF_WEEK) + expect(shouldFireMaintainerRecap(env, 14, 1)).toBe(false); // the (unclamped) default no longer matches + }); + + it("falls back to the default hour/day on a non-finite value", () => { + const env = { GITTENSORY_RECAP_HOUR: "not-a-number", GITTENSORY_RECAP_DAY: "nope" }; + expect(shouldFireMaintainerRecap(env, 14, 1)).toBe(true); // falls back to 14 / Monday + }); +}); + +describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => { + it("aggregates gate-precision + calibration across every registered repo (none agent-configured → fallback to all) and delivers to Discord", async () => { + const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK }); + await seedRegisteredRepo(env, "owner/alpha"); + await seedMergedPr(env, "owner/alpha", 1); + await seedRegisteredRepo(env, "owner/beta"); + await seedMergedPr(env, "owner/beta", 1); + await seedMergedPr(env, "owner/beta", 2); + const posted = stubDiscordFetch(); + + const { report, delivery } = await runMaintainerRecapJob(env); + + expect(delivery).toEqual({ sent: true }); + 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) + expect(posted).toHaveLength(1); + }); + + it("threads a custom windowDays through to the report and the per-repo aggregators", async () => { + const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK }); + await seedRegisteredRepo(env, "owner/alpha"); + await seedMergedPr(env, "owner/alpha", 1); + stubDiscordFetch(); + + const { report } = await runMaintainerRecapJob(env, 30); + + expect(report.windowDays).toBe(30); + }); + + it("prefers agent-configured repos over the full registered set when at least one is configured", async () => { + const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK }); + await seedRegisteredRepo(env, "owner/configured"); + await seedMergedPr(env, "owner/configured", 1); + await upsertRepositorySettings(env, { repoFullName: "owner/configured", autonomy: { merge: "auto" } }); + await seedRegisteredRepo(env, "owner/unconfigured"); + await seedMergedPr(env, "owner/unconfigured", 1); + stubDiscordFetch(); + + const { report } = await runMaintainerRecapJob(env); + + expect(report.repos.map((r) => r.repoFullName)).toEqual(["owner/configured"]); + }); + + it("falls back to every registered repo when settings resolution errors for every repo (a settings blip must not abort the scan)", async () => { + const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK }); + await seedRegisteredRepo(env, "owner/alpha"); + await seedMergedPr(env, "owner/alpha", 1); + await seedRegisteredRepo(env, "owner/beta"); + await seedMergedPr(env, "owner/beta", 1); + // resolveRepositorySettings reads repository_settings; poisoning it makes every repo's lookup throw, so + // recapScanRepos's inner catch fires for each and `configured` stays empty. + poisonDbPrepare(env, /"repository_settings"/i); + stubDiscordFetch(); + + const { report } = await runMaintainerRecapJob(env); + + expect(report.repos.map((r) => r.repoFullName).sort()).toEqual(["owner/alpha", "owner/beta"]); + }); + + it("fails safe per-repo: an aggregator error is logged and the repo is skipped; the job still delivers a (zeroed) report", async () => { + const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK }); + await seedRegisteredRepo(env, "owner/alpha"); + await seedMergedPr(env, "owner/alpha", 1); + // gate-precision reads pull_requests (Drizzle, quoted table name) per repo. + poisonDbPrepare(env, /"pull_requests"/i); + const warnings = vi.spyOn(console, "warn").mockImplementation(() => {}); + stubDiscordFetch(); + + const { report, delivery } = await runMaintainerRecapJob(env); // resolves (never throws) + + expect(report.repos).toEqual([]); + expect(delivery).toEqual({ sent: true }); + 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(); + }); + + it("still delivers a zeroed report to Discord when there are no registered repos at all", async () => { + const env = createTestEnv({ DISCORD_WEBHOOK_URL: HOOK }); + stubDiscordFetch(); + + const { report, delivery } = await runMaintainerRecapJob(env); + + expect(report.repos).toEqual([]); + expect(report.totals.gateFalsePositiveRate).toBeNull(); + expect(delivery).toEqual({ sent: true }); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 28c860d7a8..d72a9082a0 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -452,6 +452,34 @@ describe("queue processors", () => { vi.unstubAllGlobals(); }); + it("runs the maintainer recap job through the queue processor when GITTENSORY_MAINTAINER_RECAP is ON (#1963, #2248)", async () => { + const env = createTestEnv({ DISCORD_WEBHOOK_URL: "https://discord.com/api/webhooks/123/abc", GITTENSORY_MAINTAINER_RECAP: "true" }); + await env.DB.prepare("INSERT INTO repositories (full_name, owner, name, is_installed, is_registered) VALUES (?, ?, ?, 1, 1)").bind("JSONbored/gittensory", "JSONbored", "gittensory").run(); + vi.stubGlobal("fetch", async () => new Response(null, { status: 204 })); + + await processJob(env, { type: "generate-maintainer-recap", requestedBy: "test" }); + + const row = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? order by created_at desc limit 1").bind("maintainer_recap_notification.discord").first(); + expect(row).toMatchObject({ outcome: "completed", detail: "sent" }); + vi.unstubAllGlobals(); + }); + + it("skips the maintainer recap job as a no-op when GITTENSORY_MAINTAINER_RECAP is OFF (default, #2248)", async () => { + const env = createTestEnv({ DISCORD_WEBHOOK_URL: "https://discord.com/api/webhooks/123/abc" }); + let fetchCalled = false; + vi.stubGlobal("fetch", async () => { + fetchCalled = true; + return new Response(null, { status: 204 }); + }); + + await processJob(env, { type: "generate-maintainer-recap", requestedBy: "test" }); + + expect(fetchCalled).toBe(false); + const row = await env.DB.prepare("select count(*) as count from audit_events where event_type = ?").bind("maintainer_recap_notification.discord").first<{ count: number }>(); + expect(row?.count).toBe(0); + vi.unstubAllGlobals(); + }); + it("routes upstream drift jobs through queue processors", async () => { const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index c5fdeacf24..4765b27099 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 84bd36440b87bba963f47944ddef154d) +// Generated by Wrangler by running `wrangler types` (hash: 20fa12547178d4e155857d65d878ebff) // Runtime types generated with workerd@1.20260701.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -33,6 +33,7 @@ interface __BaseEnv_Env { GITTENSORY_REVIEW_MEMORY: "false"; GITTENSORY_REVIEW_CONTENT_LANE: "false"; GITTENSORY_REVIEW_SELFTUNE: "false"; + GITTENSORY_MAINTAINER_RECAP: "false"; GITHUB_STATUS_ROLLUP_GRAPHQL: "false"; GITTENSORY_REVIEW_PLANNER: "false"; GITTENSORY_REVIEW_DRAFT: "false"; @@ -69,6 +70,7 @@ declare namespace NodeJS { | "GITTENSORY_AUTO_FILE_DRIFT_ISSUES" | "GITTENSORY_DRIFT_ISSUE_REPO" | "GITTENSORY_DUPLICATE_WINNER" + | "GITTENSORY_MAINTAINER_RECAP" | "GITTENSORY_OPEN_PR_FILE_COLLISION" | "GITTENSORY_PR_RECONCILIATION" | "GITTENSORY_PUBLIC_STATS" diff --git a/wrangler.jsonc b/wrangler.jsonc index 51bb085b28..a53a529316 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -129,6 +129,11 @@ // identical to today. Config-application (reading a promoted override into the live gate) is a deferred // follow-up — see src/review/selftune-wire.ts. "GITTENSORY_REVIEW_SELFTUNE": "false", + // Maintainer recap digest (#1963, #2248): deliver a cross-repo RecapReport -- gittensory's own gate- + // precision + outcome-calibration data folded across every scanned repo -- to Discord on a cron cadence + // (GITTENSORY_RECAP_CADENCE=daily|weekly, default weekly; GITTENSORY_RECAP_HOUR / GITTENSORY_RECAP_DAY + // pick when). Default OFF — flag-OFF the cron enqueues no recap job, byte-identical to today. + "GITTENSORY_MAINTAINER_RECAP": "false", // #1941: route the live CI aggregate (the gate's check/status read) through ONE GraphQL statusCheckRollup query // instead of the paginated /check-runs + /status + /check-suites REST reads — moving that hot path onto the // SEPARATE GraphQL rate-limit bucket. Default OFF: the gate uses the proven REST aggregate, byte-identical. When