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
11 changes: 11 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" });
Expand Down
7 changes: 7 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1108,6 +1109,12 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
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);
Expand Down
118 changes: 118 additions & 0 deletions src/review/maintainer-recap-wire.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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 };
}
1 change: 1 addition & 0 deletions src/selfhost/maintenance-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const MAINTENANCE_JOB_TYPES: ReadonlySet<string> = new Set([
"prune-retention",
"generate-weekly-value-report",
"generate-review-recap",
"generate-maintainer-recap",
"generate-signal-snapshots",
"notify-evaluate",
"notify-deliver",
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
58 changes: 58 additions & 0 deletions test/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<import("../../src/types").JobMessage>> => {
const sent: Array<import("../../src/types").JobMessage> = [];
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<unknown>[] = [];
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<import("../../src/types").JobMessage> = [];
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<unknown>[] = [];
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<import("../../src/types").JobMessage> = [];
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<unknown>[] = [];
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 {
Expand Down
Loading
Loading