diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 94a29b0497..b597787547 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -1130,3 +1130,13 @@ settings: # reviewRecap: # enabled: true # Bool. Default: false (no recap is ever built or posted). # cadenceDays: 7 # Positive integer. Days of activity each recap covers. Default: 7 (weekly). + +# Cross-repo maintainer recap digest (#1963, #2250): config-as-code override for the CRON-scheduled digest +# that folds gate-precision + outcome-calibration across every scanned repo into one report (distinct from +# the single-repo reviewRecap above). Operator-level, not per-repo -- only meaningful on the gittensory +# self-repo's own manifest (the repo this instance identifies as); a present block there wins over the +# GITTENSORY_MAINTAINER_RECAP / GITTENSORY_RECAP_CADENCE env vars, which stay the fallback when absent. +# maintainerRecap: +# enabled: true # Bool. Default: false (env vars decide instead). +# cadence: weekly # daily | weekly. Default: weekly. Invalid values fall back to weekly. +# channel: discord # discord (only supported channel today). Invalid values fall back to discord. diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 41d6450699..75fa9c41ae 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -1143,3 +1143,13 @@ settings: # reviewRecap: # enabled: true # Bool. Default: false (no recap is ever built or posted). # cadenceDays: 7 # Positive integer. Days of activity each recap covers. Default: 7 (weekly). + +# Cross-repo maintainer recap digest (#1963, #2250): config-as-code override for the CRON-scheduled digest +# that folds gate-precision + outcome-calibration across every scanned repo into one report (distinct from +# the single-repo reviewRecap above). Operator-level, not per-repo -- only meaningful on the gittensory +# self-repo's own manifest (the repo this instance identifies as); a present block there wins over the +# GITTENSORY_MAINTAINER_RECAP / GITTENSORY_RECAP_CADENCE env vars, which stay the fallback when absent. +# maintainerRecap: +# enabled: true # Bool. Default: false (env vars decide instead). +# cadence: weekly # daily | weekly. Default: weekly. Invalid values fall back to weekly. +# channel: discord # discord (only supported channel today). Invalid values fall back to discord. diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index 5e278047b0..a2c2ffb83a 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -286,6 +286,25 @@ export type FocusManifestReviewRecapConfig = { cadenceDays: number; }; +/** + * Config-as-code override for the CROSS-repo maintainer recap digest's cron knobs (#1963, #2250), declared + * under `maintainerRecap:`. Distinct from `reviewRecap:` above (that is the single-repo digest's own window/ + * enable knob); this instead overrides the GITTENSORY_MAINTAINER_RECAP / GITTENSORY_RECAP_CADENCE env vars + * that gate the cron-scheduled cross-repo digest (buildMaintainerRecap, #2239 / #2248) — read from the + * gittensory self-repo's manifest (resolveGittensorySelfRepoFullName), since the digest is an operator-level + * setting, not a per-contributor-repo one. Mirrors `reviewRecap:` exactly: no DB-backed counterpart, so the + * parsed value (or the default below when unset) IS the effective value. Not present (or present with no + * fields set) ⇒ the caller falls back to the env vars, byte-identical to before this override existed. + */ +export type FocusManifestMaintainerRecapConfig = { + present: boolean; + enabled: boolean; + cadence: "daily" | "weekly"; + /** Delivery channel for the digest. Discord-only for now (mirrors deliverRecapToDiscord, #2245) — Slack + * delivery for this cross-repo digest is a follow-up, so any other value falls back to "discord". */ + channel: "discord"; +}; + /** * Generic repository-settings override declared in `.gittensory.yml` under `settings:`. A partial of * {@link RepositorySettings} — every behaviour a maintainer can toggle in the dashboard can be set here @@ -822,6 +841,7 @@ export type FocusManifest = { contentLane: FocusManifestContentLaneConfig; repoDocGeneration: FocusManifestRepoDocGenerationConfig; reviewRecap: FocusManifestReviewRecapConfig; + maintainerRecap: FocusManifestMaintainerRecapConfig; warnings: string[]; }; @@ -943,6 +963,16 @@ const EMPTY_REVIEW_RECAP_CONFIG: FocusManifestReviewRecapConfig = { cadenceDays: DEFAULT_REVIEW_RECAP_CADENCE_DAYS, }; +const DEFAULT_MAINTAINER_RECAP_CADENCE: "daily" | "weekly" = "weekly"; +const DEFAULT_MAINTAINER_RECAP_CHANNEL: "discord" = "discord"; + +const EMPTY_MAINTAINER_RECAP_CONFIG: FocusManifestMaintainerRecapConfig = { + present: false, + enabled: false, + cadence: DEFAULT_MAINTAINER_RECAP_CADENCE, + channel: DEFAULT_MAINTAINER_RECAP_CHANNEL, +}; + const EMPTY_MANIFEST: FocusManifest = { present: false, source: "none", @@ -960,6 +990,7 @@ const EMPTY_MANIFEST: FocusManifest = { contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG }, + maintainerRecap: { ...EMPTY_MAINTAINER_RECAP_CONFIG }, warnings: [], }; @@ -990,6 +1021,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG }, + maintainerRecap: { ...EMPTY_MAINTAINER_RECAP_CONFIG }, }; } @@ -1573,6 +1605,32 @@ export function reviewRecapConfigToJson(config: FocusManifestReviewRecapConfig): return { enabled: config.enabled, cadenceDays: config.cadenceDays }; } +/** + * Parse the optional `maintainerRecap:` mapping (#1963, #2250). Mirrors {@link parseReviewRecapConfig}: every + * field has a concrete default (no DB layer to overlay onto), so the parsed value IS the effective value. An + * invalid `cadence`/`channel` falls back to its default via {@link normalizeEnum} (with a warning) rather than + * silently firing more often or targeting an unsupported channel. + */ +function parseMaintainerRecapConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestMaintainerRecapConfig { + if (value === undefined || value === null) return { ...EMPTY_MAINTAINER_RECAP_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest field "maintainerRecap" must be a mapping; ignoring it.'); + return { ...EMPTY_MAINTAINER_RECAP_CONFIG }; + } + const record = value as Record; + const enabled = normalizeOptionalBoolean(record.enabled, "maintainerRecap.enabled", warnings) ?? false; + const cadence = normalizeEnum<"daily" | "weekly">(record.cadence, "maintainerRecap.cadence", ["daily", "weekly"], DEFAULT_MAINTAINER_RECAP_CADENCE, warnings); + const channel = normalizeEnum<"discord">(record.channel, "maintainerRecap.channel", ["discord"], DEFAULT_MAINTAINER_RECAP_CHANNEL, warnings); + return { present: true, enabled, cadence, channel }; +} + +/** Serialize a maintainerRecap config back into the parse-compatible shape so a cached snapshot round-trips + * through {@link parseMaintainerRecapConfig} unchanged. Returns null when nothing is configured. */ +export function maintainerRecapConfigToJson(config: FocusManifestMaintainerRecapConfig): JsonValue { + if (!config.present) return null; + return { enabled: config.enabled, cadence: config.cadence, channel: config.channel }; +} + function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { if (value === undefined || value === null) return null; if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T; @@ -2931,6 +2989,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): contentLane: parseContentLaneConfig(record.contentLane, warnings), repoDocGeneration: parseRepoDocGenerationConfig(record.repoDocGeneration, warnings), reviewRecap: parseReviewRecapConfig(record.reviewRecap, warnings), + maintainerRecap: parseMaintainerRecapConfig(record.maintainerRecap, warnings), warnings, }; if ( @@ -2947,7 +3006,8 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): !manifest.features.present && !manifest.contentLane.present && !manifest.repoDocGeneration.present && - !manifest.reviewRecap.present + !manifest.reviewRecap.present && + !manifest.maintainerRecap.present ) { warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); manifest.present = false; diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 75a4feae84..ecff4b39af 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -337,6 +337,7 @@ export { repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, + maintainerRecapConfigToJson, settingsOverrideToJson, MAX_FOCUS_MANIFEST_BYTES, CONVERGED_FEATURE_KEYS, @@ -367,6 +368,7 @@ export { type FocusManifestRepoDocGenerationScope, type FocusManifestReviewConfig, type FocusManifestReviewRecapConfig, + type FocusManifestMaintainerRecapConfig, type FocusManifestSettings, type FocusManifestSource, type LabelingRule, diff --git a/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts index 52f18d9892..77c000204a 100644 --- a/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts +++ b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts @@ -60,8 +60,9 @@ function normalizeMapping(input: unknown, index: number, warnings: string[]): Li warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssue must be a boolean; ignoring it.`); } } - // Same parse contract as trustMaintainerAuthoredIssue just above (#priority-reward-maintainer-trust). - // Mirrors `src/review/linked-issue-label-propagation.ts`'s copy of this normalizer. + // Same parse contract as trustMaintainerAuthoredIssue just above (#priority-reward-maintainer-trust): + // malformed is warned-and-defaulted to undefined/strict, never silently coerced, never a reason to drop + // an otherwise-valid mapping. let trustMaintainerAuthoredIssueForReward: boolean | undefined; if (record.trustMaintainerAuthoredIssueForReward !== undefined) { if (typeof record.trustMaintainerAuthoredIssueForReward === "boolean") { diff --git a/src/index.ts b/src/index.ts index 8a8811b0a7..09a112b46e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +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 { isRecapEnabled, resolveMaintainerRecapManifestOverride, shouldFireMaintainerRecap } from "./review/maintainer-recap-wire"; import { isSweepWatchdogEnabled } from "./review/sweep-watchdog"; import { isPrReconciliationEnabled } from "./review/pr-reconciliation"; import { isRagEnabled } from "./review/rag-wire"; @@ -216,13 +216,18 @@ 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" }); + // Maintainer recap digest (#1963, #2248/#2250; 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). Enable/cadence can ALSO be + // set as code via the gittensory self-repo's `.gittensory.yml maintainerRecap:` block (config-as-code parity, + // #2250) -- a present manifest block wins over the env vars; absent, the env vars decide exactly as before. + // Enqueued ONLY when this tick matches the resolved cadence -- disabled (the 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 && isHourly) { + const maintainerRecapOverride = await resolveMaintainerRecapManifestOverride(env); + if (isRecapEnabled(env, maintainerRecapOverride) && shouldFireMaintainerRecap(env, hour, scheduledAt.getUTCDay(), maintainerRecapOverride)) { + jobs.push({ type: "generate-maintainer-recap", requestedBy: "schedule" }); + } } if (isFullSyncWindow) { jobs.push({ type: "generate-signal-snapshots", requestedBy: "schedule" }); diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 93aaa5c057..9e71ea7bee 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -551,7 +551,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 { isRecapEnabled, resolveMaintainerRecapManifestOverride, 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"; @@ -1110,12 +1110,14 @@ 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); + case "generate-maintainer-recap": { + // Convergence (maintainer recap digest, flag GITTENSORY_MAINTAINER_RECAP, #1963/#2248, config-as-code + // override #2250). Defense-in-depth: the cron only ENQUEUES this when enabled, but a stale in-flight job + // that lands after a flag-flip (env OR manifest) must still no-op, so disabled does zero work here too. + const maintainerRecapOverride = await resolveMaintainerRecapManifestOverride(env); + if (isRecapEnabled(env, maintainerRecapOverride)) 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 index 1ed0f42a39..10d5357b3e 100644 --- a/src/review/maintainer-recap-wire.ts +++ b/src/review/maintainer-recap-wire.ts @@ -10,12 +10,26 @@ 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 { 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"; -/** 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 { +/** 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 + * repo has no manifest at all) means "no override configured", not "disabled" -- the caller falls through to + * the env vars in that case, exactly as if this parameter were omitted. */ +export type MaintainerRecapManifestOverride = { present: boolean; enabled: boolean; cadence: RecapCadence }; + +/** True when the cross-repo maintainer recap digest is enabled. Config-as-code (#2250): a present + * `maintainerRecap` manifest block on the gittensory self-repo wins outright; otherwise falls back to the + * GITTENSORY_MAINTAINER_RECAP env flag (default OFF -- the cron enqueues no job and runMaintainerRecapJob is + * never invoked). Truthy env convention matches isOpsEnabled. */ +export function isRecapEnabled( + env: { GITTENSORY_MAINTAINER_RECAP?: string | undefined }, + manifestOverride?: MaintainerRecapManifestOverride | undefined, +): boolean { + if (manifestOverride?.present) return manifestOverride.enabled; return /^(1|true|yes|on)$/i.test(env.GITTENSORY_MAINTAINER_RECAP ?? ""); } @@ -52,9 +66,11 @@ function normalizeRecapDayOfWeek(value: string | undefined): number { * 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. + * `scheduledAt` (src/index.ts) -- no new Date parsing here. The hour/day-of-week knobs are env-only (not + * manifest-overridable); ONLY the cadence itself (daily vs weekly) honors a present manifest override (#2250), + * mirroring isRecapEnabled. 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: { @@ -64,9 +80,10 @@ export function shouldFireMaintainerRecap( }, hour: number, dayOfWeek: number, + manifestOverride?: MaintainerRecapManifestOverride | undefined, ): boolean { if (hour !== normalizeRecapHour(env.GITTENSORY_RECAP_HOUR)) return false; - const cadence = normalizeRecapCadence(env.GITTENSORY_RECAP_CADENCE); + const cadence = manifestOverride?.present ? manifestOverride.cadence : normalizeRecapCadence(env.GITTENSORY_RECAP_CADENCE); return cadence === "daily" || dayOfWeek === normalizeRecapDayOfWeek(env.GITTENSORY_RECAP_DAY); } @@ -87,6 +104,26 @@ async function recapScanRepos(env: Env): Promise { return configured.length > 0 ? configured : repos.map((repo) => repo.fullName); } +/** + * Config-as-code override lookup (#2250): read the `maintainerRecap` block off the gittensory self-repo's + * `.gittensory.yml` (resolveGittensorySelfRepoFullName) -- the digest is an operator-level setting, not a + * per-contributor-repo one, so ONE designated repo's manifest stands in for "the operator's own config" the + * same way weekly-value-report/ops-alerts/selftune are operator-level, env-gated jobs. A manifest load failure + * (network blip, malformed YAML) degrades to `{ present: false }` -- the caller then falls through to the env + * vars, exactly as if no override existed, so a manifest hiccup can never accidentally disable or silently + * reschedule the digest. + */ +export async function resolveMaintainerRecapManifestOverride(env: Env): Promise { + try { + const manifest = await loadRepoFocusManifest(env, resolveGittensorySelfRepoFullName(env)); + const config = manifest.maintainerRecap; + return { present: config.present, enabled: config.enabled, cadence: config.cadence }; + } catch (error) { + console.warn(JSON.stringify({ event: "maintainer_recap_manifest_override_error", message: errorMessage(error).slice(0, 200) })); + return { present: false, enabled: false, cadence: DEFAULT_RECAP_CADENCE }; + } +} + /** * 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 diff --git a/src/selfhost/config-lint.ts b/src/selfhost/config-lint.ts index 5b28cff9eb..01c4d08cd6 100644 --- a/src/selfhost/config-lint.ts +++ b/src/selfhost/config-lint.ts @@ -17,6 +17,7 @@ const TOP_LEVEL_FIELDS = [ "contentLane", "repoDocGeneration", "reviewRecap", + "maintainerRecap", ] as const; const TOP_LEVEL_FIELD_SET = new Set(TOP_LEVEL_FIELDS); diff --git a/src/services/focus-manifest-validation.ts b/src/services/focus-manifest-validation.ts index 035c078bc3..86ca8be1dc 100644 --- a/src/services/focus-manifest-validation.ts +++ b/src/services/focus-manifest-validation.ts @@ -6,6 +6,7 @@ import { repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, + maintainerRecapConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, @@ -69,6 +70,8 @@ function focusManifestToNormalizedJson(manifest: FocusManifest): Record { contentLane: contentLaneConfigToJson(manifest.contentLane), repoDocGeneration: repoDocGenerationConfigToJson(manifest.repoDocGeneration), reviewRecap: reviewRecapConfigToJson(manifest.reviewRecap), + maintainerRecap: maintainerRecapConfigToJson(manifest.maintainerRecap), }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 9e2f12a157..8ab8875abf 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -31,6 +31,7 @@ export { repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, + maintainerRecapConfigToJson, settingsOverrideToJson, type AutoReviewConfig, type CommentVerbosity, @@ -53,6 +54,7 @@ export { type FocusManifestRepoDocGenerationScope, type FocusManifestReviewConfig, type FocusManifestReviewRecapConfig, + type FocusManifestMaintainerRecapConfig, type FocusManifestSettings, type FocusManifestSource, type LabelingRule, diff --git a/test/unit/focus-manifest-validation.test.ts b/test/unit/focus-manifest-validation.test.ts index 431dc1534f..69f7df8fe5 100644 --- a/test/unit/focus-manifest-validation.test.ts +++ b/test/unit/focus-manifest-validation.test.ts @@ -98,6 +98,10 @@ repoDocGeneration: reviewRecap: enabled: true cadenceDays: 14 +maintainerRecap: + enabled: true + cadence: daily + channel: discord `, }); expect(result.status).toBe("ok"); @@ -111,9 +115,15 @@ reviewRecap: contentLane: { entryFileGlob: "data/*.json", collectionField: "records" }, repoDocGeneration: { enabled: true, scope: ["agents"] }, reviewRecap: { enabled: true, cadenceDays: 14 }, + maintainerRecap: { enabled: true, cadence: "daily", channel: "discord" }, }); }); + it("omits maintainerRecap from the normalized output when it is not configured", () => { + const result = buildFocusManifestValidation({ content: "wantedPaths: [src/]\n" }); + expect(result.normalized).not.toHaveProperty("maintainerRecap"); + }); + it("returns error when manifest content is not a mapping", () => { const result = buildFocusManifestValidation({ content: "[1, 2, 3]" }); expect(result.status).toBe("error"); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index bee35b14a6..490819391d 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -39,6 +39,7 @@ import { overlayReviewConfig, parseReviewConfigMapping, reviewRecapConfigToJson, + maintainerRecapConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestContentLaneConfig, @@ -47,6 +48,7 @@ import { type FocusManifestRepoDocGenerationConfig, type FocusManifestReviewConfig, type FocusManifestReviewRecapConfig, + type FocusManifestMaintainerRecapConfig, type FocusManifestSettings, type SelfHostAiModelConfig, } from "../../src/signals/focus-manifest"; @@ -451,6 +453,16 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { it.each(Object.entries(REVIEW_RECAP_FIELD_TOKENS))("documents reviewRecap.%s", (_field, token) => { expect(exampleContent).toContain(token); }); + + const MAINTAINER_RECAP_FIELD_TOKENS = { + enabled: "enabled:", + cadence: "cadence:", + channel: "channel:", + } satisfies Record, string>; + + it.each(Object.entries(MAINTAINER_RECAP_FIELD_TOKENS))("documents maintainerRecap.%s", (_field, token) => { + expect(exampleContent).toContain(token); + }); }); describe("matchesManifestPath", () => { @@ -820,6 +832,7 @@ describe("compileFocusManifestPolicy", () => { contentLane: { present: false, entryFileGlob: null, providerFileGlob: null, artifactGlob: null, collectionField: null, maxAppendedEntries: null, duplicateKeyFields: [], validatorId: null }, repoDocGeneration: { present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false, refreshIntervalDays: 7 }, reviewRecap: { present: false, enabled: false, cadenceDays: 7 }, + maintainerRecap: { present: false, enabled: false, cadence: "weekly", channel: "discord" }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -1801,6 +1814,74 @@ describe("parseFocusManifest gate config", () => { }); }); + describe("maintainerRecap: (#1963, #2250, cross-repo digest cron config-as-code override)", () => { + it("defaults to fully disabled/absent when the key is omitted, and does not make the manifest present on its own", () => { + const m = parseFocusManifest({}); + expect(m.maintainerRecap).toEqual({ present: false, enabled: false, cadence: "weekly", channel: "discord" }); + expect(m.present).toBe(false); + }); + + it("treats an explicit null the same as an omitted key", () => { + expect(parseFocusManifest({ maintainerRecap: null }).maintainerRecap).toEqual({ present: false, enabled: false, cadence: "weekly", channel: "discord" }); + }); + + it("warns and falls back to the default when the value is a non-mapping type (string or array)", () => { + const asString = parseFocusManifest({ maintainerRecap: "nope" as never }); + expect(asString.maintainerRecap.present).toBe(false); + expect(asString.warnings.some((w) => /"maintainerRecap" must be a mapping/.test(w))).toBe(true); + const asArray = parseFocusManifest({ maintainerRecap: ["nope"] as never }); + expect(asArray.maintainerRecap.present).toBe(false); + expect(asArray.warnings.some((w) => /"maintainerRecap" must be a mapping/.test(w))).toBe(true); + }); + + it("parses enabled: true and defaults cadence/channel, making the manifest present", () => { + const m = parseFocusManifest({ maintainerRecap: { enabled: true } }); + expect(m.maintainerRecap).toEqual({ present: true, enabled: true, cadence: "weekly", channel: "discord" }); + expect(m.present).toBe(true); + }); + + it("warns and defaults to false when enabled is a non-boolean value", () => { + const m = parseFocusManifest({ maintainerRecap: { enabled: "yes" as unknown as boolean } }); + expect(m.maintainerRecap.enabled).toBe(false); + expect(m.warnings.some((w) => /maintainerRecap\.enabled/.test(w))).toBe(true); + }); + + it("parses a valid cadence and defaults to weekly when omitted", () => { + const m = parseFocusManifest({ maintainerRecap: { enabled: true, cadence: "daily" } }); + expect(m.maintainerRecap.cadence).toBe("daily"); + const defaulted = parseFocusManifest({ maintainerRecap: { enabled: true } }); + expect(defaulted.maintainerRecap.cadence).toBe("weekly"); + }); + + it("warns and falls back to weekly when cadence is not daily/weekly", () => { + const m = parseFocusManifest({ maintainerRecap: { enabled: true, cadence: "biweekly" as never } }); + expect(m.maintainerRecap.cadence).toBe("weekly"); + expect(m.warnings.some((w) => /maintainerRecap\.cadence/.test(w))).toBe(true); + }); + + it("parses a valid channel and defaults to discord when omitted", () => { + const m = parseFocusManifest({ maintainerRecap: { enabled: true, channel: "discord" } }); + expect(m.maintainerRecap.channel).toBe("discord"); + const defaulted = parseFocusManifest({ maintainerRecap: { enabled: true } }); + expect(defaulted.maintainerRecap.channel).toBe("discord"); + }); + + it("warns and falls back to discord when channel is not a supported value (e.g. slack, not yet delivered for this digest)", () => { + const m = parseFocusManifest({ maintainerRecap: { enabled: true, channel: "slack" as never } }); + expect(m.maintainerRecap.channel).toBe("discord"); + expect(m.warnings.some((w) => /maintainerRecap\.channel/.test(w))).toBe(true); + }); + + it("round-trips through maintainerRecapConfigToJson → parseFocusManifest unchanged", () => { + const m = parseFocusManifest({ maintainerRecap: { enabled: true, cadence: "daily", channel: "discord" } }); + expect(parseFocusManifest({ maintainerRecap: maintainerRecapConfigToJson(m.maintainerRecap) }).maintainerRecap).toEqual(m.maintainerRecap); + }); + + it("maintainerRecapConfigToJson returns null for an absent config", () => { + expect(maintainerRecapConfigToJson(parseFocusManifest(null).maintainerRecap)).toBeNull(); + }); + }); + it("parses aiReviewAllAuthors from the settings: block (generic override)", () => { const parsed = parseFocusManifest({ settings: { aiReviewAllAuthors: true , closeOwnerAuthors: false} }); expect(parsed.settings.aiReviewAllAuthors).toBe(true); diff --git a/test/unit/maintainer-recap-wire.test.ts b/test/unit/maintainer-recap-wire.test.ts index 948fa86044..ea56ff71d8 100644 --- a/test/unit/maintainer-recap-wire.test.ts +++ b/test/unit/maintainer-recap-wire.test.ts @@ -1,8 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { isRecapEnabled, runMaintainerRecapJob, shouldFireMaintainerRecap } from "../../src/review/maintainer-recap-wire"; +import { isRecapEnabled, resolveMaintainerRecapManifestOverride, runMaintainerRecapJob, shouldFireMaintainerRecap } from "../../src/review/maintainer-recap-wire"; import { updatePullRequestSlopAssessment, upsertPullRequestFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import { createTestEnv } from "../helpers/d1"; +const SELF_REPO = "JSONbored/gittensory"; + 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 @@ -51,6 +54,16 @@ describe("isRecapEnabled — default OFF, truthy convention", () => { 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); }); + + it("a present manifest override wins outright over the env flag, in both directions (#2250)", () => { + expect(isRecapEnabled({ GITTENSORY_MAINTAINER_RECAP: "false" }, { present: true, enabled: true, cadence: "weekly" })).toBe(true); + expect(isRecapEnabled({ GITTENSORY_MAINTAINER_RECAP: "true" }, { present: true, enabled: false, cadence: "weekly" })).toBe(false); + }); + + it("falls back to the env flag when the manifest override is not present", () => { + expect(isRecapEnabled({ GITTENSORY_MAINTAINER_RECAP: "true" }, { present: false, enabled: false, cadence: "weekly" })).toBe(true); + expect(isRecapEnabled({ GITTENSORY_MAINTAINER_RECAP: "false" }, undefined)).toBe(false); + }); }); describe("shouldFireMaintainerRecap — cadence gate (#2248)", () => { @@ -96,6 +109,54 @@ describe("shouldFireMaintainerRecap — cadence gate (#2248)", () => { const env = { GITTENSORY_RECAP_HOUR: "not-a-number", GITTENSORY_RECAP_DAY: "nope" }; expect(shouldFireMaintainerRecap(env, 14, 1)).toBe(true); // falls back to 14 / Monday }); + + it("a present manifest override's cadence wins over the env cadence, in both directions (#2250)", () => { + const dailyOverride = { present: true, enabled: true, cadence: "daily" } as const; + // env says weekly, manifest says daily -> fires on a non-Monday too (hour still gates). + expect(shouldFireMaintainerRecap({ GITTENSORY_RECAP_CADENCE: "weekly" }, 14, 3, dailyOverride)).toBe(true); + const weeklyOverride = { present: true, enabled: true, cadence: "weekly" } as const; + // env says daily, manifest says weekly -> does NOT fire on a non-Monday. + expect(shouldFireMaintainerRecap({ GITTENSORY_RECAP_CADENCE: "daily" }, 14, 3, weeklyOverride)).toBe(false); + expect(shouldFireMaintainerRecap({ GITTENSORY_RECAP_CADENCE: "daily" }, 14, 1, weeklyOverride)).toBe(true); + }); + + it("falls back to the env cadence when the manifest override is not present", () => { + const notPresent = { present: false, enabled: false, cadence: "daily" } as const; + expect(shouldFireMaintainerRecap({ GITTENSORY_RECAP_CADENCE: "weekly" }, 14, 3, notPresent)).toBe(false); + }); +}); + +describe("resolveMaintainerRecapManifestOverride — config-as-code lookup (#2250)", () => { + it("returns the self-repo's configured maintainerRecap block when present", async () => { + const env = createTestEnv(); + await upsertRepoFocusManifest(env, SELF_REPO, { maintainerRecap: { enabled: true, cadence: "daily", channel: "discord" } }); + + expect(await resolveMaintainerRecapManifestOverride(env)).toEqual({ present: true, enabled: true, cadence: "daily" }); + }); + + it("returns present: false when the self-repo has no maintainerRecap block configured", async () => { + const env = createTestEnv(); + await upsertRepoFocusManifest(env, SELF_REPO, { wantedPaths: ["src/"] }); + + expect(await resolveMaintainerRecapManifestOverride(env)).toEqual({ present: false, enabled: false, cadence: "weekly" }); + }); + + it("degrades to present: false (never throws) when the manifest load itself fails", async () => { + const env = createTestEnv(); + // loadRepoFocusManifest reads signal_snapshots (the persisted-record cache) before any live fetch fallback. + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + if (/"signal_snapshots"|signal_snapshots/i.test(sql)) throw new Error("poisoned query"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const warnings = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(await resolveMaintainerRecapManifestOverride(env)).toEqual({ present: false, enabled: false, cadence: "weekly" }); + expect(warnings.mock.calls.map((c) => String(c[0])).some((line) => line.includes("maintainer_recap_manifest_override_error"))).toBe(true); + }); }); describe("runMaintainerRecapJob — cross-repo digest (#1963, #2248)", () => { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 8ed1d6acba..141c848abb 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -468,15 +468,18 @@ describe("queue processors", () => { 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 }); + let discordFetchCalled = false; + // The disabled-check ALSO resolves the self-repo's manifest override (#2250), which may fall through to a + // live GitHub fetch for its .gittensory.yml when uncached -- stub that fetch as a generic 404 so the + // manifest loader degrades to "no override", and only flag a call to the Discord webhook itself. + vi.stubGlobal("fetch", async (url: RequestInfo | URL) => { + if (String(url).includes("discord.com")) discordFetchCalled = true; + return new Response(null, { status: 404 }); }); await processJob(env, { type: "generate-maintainer-recap", requestedBy: "test" }); - expect(fetchCalled).toBe(false); + expect(discordFetchCalled).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(); diff --git a/test/unit/selfhost-config-lint.test.ts b/test/unit/selfhost-config-lint.test.ts index 14dfce56b6..8c6fca20a2 100644 --- a/test/unit/selfhost-config-lint.test.ts +++ b/test/unit/selfhost-config-lint.test.ts @@ -80,6 +80,14 @@ reviewRecap: expect(result.recognizedFields).toEqual(["reviewRecap"]); }); + it("recognizes a standalone maintainerRecap: block instead of flagging it as unknown (#1963, #2250)", () => { + const result = lintManifestText("maintainerRecap:\n enabled: true\n cadence: daily\n channel: discord\n"); + + expect(result.ok).toBe(true); + expect(result.warnings).toEqual([]); + expect(result.recognizedFields).toEqual(["maintainerRecap"]); + }); + it("flags legacy blockedPaths with a migration-specific warning, not the generic unknown-field message", () => { const result = lintManifestText("wantedPaths: [src/]\nblockedPaths: [dist/]\n");