Skip to content

Commit d4aea8f

Browse files
authored
fix(settings): cap review-nag cooldown (#2634)
* fix(settings): cap review nag cooldown * fix(review): avoid a UI typecheck regression from a new agent-actions import focus-manifest.ts is part of the UI package's typechecked closure (its transitive deps get walked by apps/gittensory-ui's own tsc run). Importing MAX_REVIEW_NAG_COOLDOWN_DAYS from settings/agent-actions.ts pulled that module's own import of github/commands.ts -> utils/crypto.ts into the UI build for the first time, exposing a pre-existing latent Uint8Array<ArrayBufferLike>/BufferSource type mismatch in crypto.ts that the UI's tsc had never previously reached. Duplicates the small constant locally in focus-manifest.ts instead of importing it, keeping db/repositories.ts's own import from agent-actions.ts (never part of the UI closure) unaffected. Also rebases the branch's merge commit into a linear rebase onto current main. * fix(review): make the review-nag cooldown-cap regression test actually exercise the guard Gate review: the original regression test seeded an oversized reviewNagCooldownDays through upsertRepositorySettings, but both upsertRepositorySettings and getRepositorySettings already clamp that field on write AND read -- so the value read back inside resolveRepositorySettings was never actually oversized by the time maybeThrottleReviewNagPing saw it. The test could pass even with processors.ts's own Math.min(reviewNagCooldownDays, MAX_REVIEW_NAG_COOLDOWN_DAYS) guard removed entirely. Mocks resolveRepositorySettings directly (bypassing the DB/yml clamp layers entirely, not just the write-time one) to actually deliver an oversized value to the function under test, then proves the guard's effect behaviorally: three prior pings 400 days old fall outside the CORRECTLY capped 365-day window (no cooldown applied), whereas an uncapped "1-billion-day" window would count them and trip the threshold. Confirmed by mutation-testing: removing the Math.min throws a real RangeError (Invalid time value) building the Date.
1 parent 68d9f6a commit d4aea8f

10 files changed

Lines changed: 83 additions & 7 deletions

File tree

.gittensory.yml.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ settings:
325325
# a dedicated closeIssue primitive lands) with a clear reason. Off by default.
326326
# reviewNagPolicy: off # off | hold | close. Default: off.
327327
# reviewNagMaxPings: 3 # Positive integer. Pings above this within the cooldown window trigger the policy. Default: 3.
328-
# reviewNagCooldownDays: 5 # Positive integer. Window the ping count is measured over. Default: 5.
328+
# reviewNagCooldownDays: 5 # Positive integer up to 365. Window the ping count is measured over. Default: 5.
329329
# reviewNagLabel: review-nag-cooldown # Label applied alongside the hold/close action. Default: review-nag-cooldown.
330330

331331
# Shared repo-scoped exemption list (#2463): GitHub logins never throttled/closed by gittensory's

apps/gittensory-ui/public/openapi.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8618,7 +8618,8 @@
86188618
"reviewNagCooldownDays": {
86198619
"type": "integer",
86208620
"minimum": 0,
8621-
"exclusiveMinimum": true
8621+
"exclusiveMinimum": true,
8622+
"maximum": 365
86228623
},
86238624
"reviewNagLabel": {
86248625
"type": "string"

src/db/repositories.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
upstreamSourceSnapshots,
5858
webhookEvents,
5959
} from "./schema";
60+
import { MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions";
6061
import type {
6162
Advisory,
6263
AdvisoryFinding,
@@ -562,7 +563,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
562563
contributorCapLabel: row.contributorCapLabel,
563564
reviewNagPolicy: normalizeReviewNagPolicy(row.reviewNagPolicy),
564565
reviewNagMaxPings: normalizePositiveIntWithDefault(row.reviewNagMaxPings, 3),
565-
reviewNagCooldownDays: normalizePositiveIntWithDefault(row.reviewNagCooldownDays, 5),
566+
reviewNagCooldownDays: normalizeReviewNagCooldownDays(row.reviewNagCooldownDays, 5),
566567
reviewNagLabel: row.reviewNagLabel,
567568
autoCloseExemptLogins: parseAutoCloseExemptLogins(row.autoCloseExemptLoginsJson),
568569
requireFreshRebaseWindowMinutes: normalizeOpenItemCap(row.requireFreshRebaseWindowMinutes),
@@ -649,7 +650,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
649650
contributorCapLabel: settings.contributorCapLabel ?? "over-contributor-limit",
650651
reviewNagPolicy: normalizeReviewNagPolicy(settings.reviewNagPolicy),
651652
reviewNagMaxPings: normalizePositiveIntWithDefault(settings.reviewNagMaxPings, 3),
652-
reviewNagCooldownDays: normalizePositiveIntWithDefault(settings.reviewNagCooldownDays, 5),
653+
reviewNagCooldownDays: normalizeReviewNagCooldownDays(settings.reviewNagCooldownDays, 5),
653654
reviewNagLabel: settings.reviewNagLabel ?? "review-nag-cooldown",
654655
autoCloseExemptLogins: normalizeAutoCloseExemptLogins(settings.autoCloseExemptLogins).logins,
655656
requireFreshRebaseWindowMinutes: normalizeOpenItemCap(settings.requireFreshRebaseWindowMinutes),
@@ -5798,6 +5799,11 @@ function normalizePositiveIntWithDefault(value: number | null | undefined, fallb
57985799
return value;
57995800
}
58005801

5802+
function normalizeReviewNagCooldownDays(value: number | null | undefined, fallback: number): number {
5803+
const normalized = normalizePositiveIntWithDefault(value, fallback);
5804+
return Math.min(normalized, MAX_REVIEW_NAG_COOLDOWN_DAYS);
5805+
}
5806+
58015807
function parseAutonomyPolicy(value: string): AutonomyPolicy {
58025808
return normalizeAutonomyPolicy(parseJson<unknown>(value, null));
58035809
}

src/openapi/schemas.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from "zod";
2+
import { MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions";
23
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
34

45
extendZodWithOpenApi(z);
@@ -647,7 +648,7 @@ export const RepositorySettingsSchema = z
647648
contributorCapLabel: z.string().optional(),
648649
reviewNagPolicy: z.enum(["off", "hold", "close"]).optional(),
649650
reviewNagMaxPings: z.number().int().positive().optional(),
650-
reviewNagCooldownDays: z.number().int().positive().optional(),
651+
reviewNagCooldownDays: z.number().int().positive().max(MAX_REVIEW_NAG_COOLDOWN_DAYS).optional(),
651652
reviewNagLabel: z.string().optional(),
652653
autoCloseExemptLogins: z.array(z.string()).optional(),
653654
accountAgeThresholdDays: z.number().int().positive().nullable().optional(),

src/queue/processors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ import { aiReviewCacheInputFingerprint } from "../review/ai-review-cache-input";
237237
import {
238238
downgradeCloseToHold,
239239
downgradeMergeToHold,
240+
MAX_REVIEW_NAG_COOLDOWN_DAYS,
240241
isProtectedAutomationAuthor,
241242
planAgentMaintenanceActions,
242243
type PlannedAgentAction,
@@ -8858,7 +8859,7 @@ async function maybeThrottleReviewNagPing(
88588859
/* v8 ignore next -- resolveRepositorySettings always resolves a concrete positive integer (NOT NULL DEFAULT 3); the undefined side is defensive against the field's optional TS type. */
88598860
const maxPings = settings.reviewNagMaxPings ?? 3;
88608861
/* v8 ignore next -- resolveRepositorySettings always resolves a concrete positive integer (NOT NULL DEFAULT 5); the undefined side is defensive against the field's optional TS type. */
8861-
const cooldownDays = settings.reviewNagCooldownDays ?? 5;
8862+
const cooldownDays = Math.min(settings.reviewNagCooldownDays ?? 5, MAX_REVIEW_NAG_COOLDOWN_DAYS);
88628863
const sinceIso = new Date(Date.now() - cooldownDays * 24 * 60 * 60 * 1000).toISOString();
88638864
const priorPings = await countRecentAuditEventsForActorAndTarget(env, commenter, REVIEW_NAG_PING_EVENT_TYPE, targetKey, sinceIso);
88648865
const pingCount = priorPings + 1; // this ping counts too

src/settings/agent-actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const DEFAULT_CONTRIBUTOR_CAP_LABEL = "over-contributor-limit";
3131
// configurable per-repo via `.gittensory.yml` (`settings.reviewNagLabel`); the planner uses the resolved label
3232
// and falls back to this default, mirroring DEFAULT_BLACKLIST_LABEL's shape.
3333
export const DEFAULT_REVIEW_NAG_LABEL = "review-nag-cooldown";
34+
// Keep the review-nag lookback operationally bounded so repo-controlled config cannot overflow Date arithmetic.
35+
export const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365;
3436
// A PR that PASSES the gate but touches a hard-guardrail path is NOT ready to auto-merge — it is withheld
3537
// for a human (the merge/approve/close dispositions are suppressed below). Labeling it `ready-to-merge`
3638
// would be misleading (the label promises an auto-merge that never happens), so a guarded passing PR gets

src/signals/focus-manifest.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,13 @@ function normalizeOptionalString(value: JsonValue | undefined, field: string, wa
741741
return null;
742742
}
743743

744+
// Keep the review-nag lookback operationally bounded so repo-controlled config cannot overflow Date
745+
// arithmetic. Duplicated from settings/agent-actions.ts's own MAX_REVIEW_NAG_COOLDOWN_DAYS (same value,
746+
// same rationale) rather than imported: this module is part of the UI package's typechecked closure, and
747+
// agent-actions.ts transitively imports github/commands.ts -> utils/crypto.ts, pulling a heavier
748+
// GitHub-App-specific dependency chain into the UI build for one small constant.
749+
const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365;
750+
744751
/**
745752
* Parse the optional `settings:` mapping — a partial repository-settings override. Only recognized
746753
* fields are kept; unknown/invalid values are dropped with a warning and never throw.
@@ -859,7 +866,10 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
859866
const reviewNagMaxPings = normalizeOptionalPositiveInteger(r.reviewNagMaxPings, "settings.reviewNagMaxPings", warnings);
860867
if (reviewNagMaxPings !== null) out.reviewNagMaxPings = reviewNagMaxPings;
861868
const reviewNagCooldownDays = normalizeOptionalPositiveInteger(r.reviewNagCooldownDays, "settings.reviewNagCooldownDays", warnings);
862-
if (reviewNagCooldownDays !== null) out.reviewNagCooldownDays = reviewNagCooldownDays;
869+
if (reviewNagCooldownDays !== null && reviewNagCooldownDays <= MAX_REVIEW_NAG_COOLDOWN_DAYS) out.reviewNagCooldownDays = reviewNagCooldownDays;
870+
if (reviewNagCooldownDays !== null && reviewNagCooldownDays > MAX_REVIEW_NAG_COOLDOWN_DAYS) {
871+
warnings.push(`Manifest field "settings.reviewNagCooldownDays" must be at most ${MAX_REVIEW_NAG_COOLDOWN_DAYS}; ignoring it.`);
872+
}
863873
const reviewNagLabel = normalizeOptionalString(r.reviewNagLabel, "settings.reviewNagLabel", warnings);
864874
if (reviewNagLabel !== null) out.reviewNagLabel = reviewNagLabel;
865875
// Shared repo-scoped exemption list (#2463): only set it when at least one VALID login survives

test/unit/data-spine.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ describe("data spine repositories", () => {
366366
// back to its default rather than being silently coerced.
367367
await upsertRepositorySettings(env, { repoFullName: "owner/badnagrepo", reviewNagPolicy: "delete-everything" as never, reviewNagMaxPings: -1, reviewNagCooldownDays: 2.5 as never });
368368
expect(await getRepositorySettings(env, "owner/badnagrepo")).toMatchObject({ reviewNagPolicy: "off", reviewNagMaxPings: 3, reviewNagCooldownDays: 5 });
369+
await upsertRepositorySettings(env, { repoFullName: "owner/bigwindowrepo", reviewNagMaxPings: 1_000, reviewNagCooldownDays: 1_000_000_000 });
370+
expect(await getRepositorySettings(env, "owner/bigwindowrepo")).toMatchObject({ reviewNagMaxPings: 1_000, reviewNagCooldownDays: 365 });
371+
await env.DB.prepare("update repository_settings set review_nag_cooldown_days = ? where repo_full_name = ?").bind(1_000_000_000, "owner/bigwindowrepo").run();
372+
expect(await getRepositorySettings(env, "owner/bigwindowrepo")).toMatchObject({ reviewNagMaxPings: 1_000, reviewNagCooldownDays: 365 });
369373
expect(updated.slopAiAdvisory).toBe(false);
370374
expect(await getRepoSyncState(env, "missing/repo")).toBeNull();
371375
expect(await getPullRequest(env, "owner/repo", 404)).toBeNull();

test/unit/focus-manifest.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,9 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () =
13821382
expect(invalid.warnings.some((w) => /settings\.reviewNagPolicy/.test(w))).toBe(true);
13831383
expect(invalid.warnings.some((w) => /settings\.reviewNagMaxPings/.test(w))).toBe(true);
13841384
expect(invalid.warnings.some((w) => /settings\.reviewNagCooldownDays/.test(w))).toBe(true);
1385+
const tooLarge = parseFocusManifest({ settings: { reviewNagCooldownDays: 366 } });
1386+
expect(tooLarge.settings.reviewNagCooldownDays).toBeUndefined();
1387+
expect(tooLarge.warnings.some((w) => /settings\.reviewNagCooldownDays/.test(w) && /365/.test(w))).toBe(true);
13851388
});
13861389

13871390
it("parses + resolves the account-age throttle settings from the settings: block, overlaying the DB (#2561)", () => {

test/unit/queue.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12243,6 +12243,54 @@ describe("queue processors", () => {
1224312243
expect(seen.closed).toBe(false);
1224412244
});
1224512245

12246+
it("REGRESSION (gate-flagged): caps an oversized review-nag cooldown at MAX_REVIEW_NAG_COOLDOWN_DAYS before Date arithmetic, even when the resolved settings object itself carries an oversized value", async () => {
12247+
// upsertRepositorySettings/getRepositorySettings both clamp reviewNagCooldownDays on write AND read, so
12248+
// seeding an oversized value through the normal repository layer (even via a raw DB update bypassing the
12249+
// write-time clamp) can never actually reach maybeThrottleReviewNagPing uncapped -- the read-time clamp in
12250+
// getRepositorySettings neutralizes it first. Mock resolveRepositorySettings directly so this test proves
12251+
// processors.ts's OWN Math.min(reviewNagCooldownDays, MAX_REVIEW_NAG_COOLDOWN_DAYS) guard, not the DB layer.
12252+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
12253+
await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", reviewNagPolicy: "hold", reviewNagMaxPings: 3 });
12254+
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 206, title: "Huge cooldown", state: "open", user: { login: "chatty" }, author_association: "NONE", labels: [], body: "" });
12255+
// Three prior pings, all 400 DAYS ago -- outside the 365-day cap, but well within an uncapped
12256+
// "1,000,000,000-day" window. If the guard clamps correctly, these fall outside the window and don't
12257+
// count; if the guard were removed, the uncapped window would count all three, crossing maxPings=3.
12258+
vi.setSystemTime(new Date("2025-04-24T00:00:00.000Z"));
12259+
for (let i = 0; i < 3; i += 1) {
12260+
await repositoriesModule.recordAuditEvent(env, { eventType: "github_app.review_nag_ping", actor: "chatty", targetKey: "JSONbored/gittensory#206", outcome: "completed" });
12261+
}
12262+
vi.setSystemTime(new Date("2026-05-29T00:00:00.000Z")); // ~400 days later
12263+
const baseSettings = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory");
12264+
const resolveSettingsSpy = vi
12265+
.spyOn(repositorySettingsModule, "resolveRepositorySettings")
12266+
.mockResolvedValueOnce({ ...baseSettings, reviewNagCooldownDays: 1_000_000_000 });
12267+
const seen = { comments: [] as string[], labels: [] as string[], closed: false };
12268+
stubReviewNagFetch(206, seen);
12269+
12270+
await processJob(env, {
12271+
type: "github-webhook",
12272+
deliveryId: "nag-huge-cooldown",
12273+
eventName: "issue_comment",
12274+
payload: {
12275+
action: "created",
12276+
installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
12277+
repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
12278+
issue: { number: 206, title: "Huge cooldown", state: "open", pull_request: {}, user: { login: "chatty" }, author_association: "NONE" },
12279+
comment: { id: 1, body: "@gittensory help", user: { login: "chatty", type: "User" }, author_association: "NONE" },
12280+
},
12281+
});
12282+
12283+
// The 400-day-old pings fell outside the CAPPED 365-day window, so this is only the 1st ping this
12284+
// window — under maxPings=3, never throttled. An uncapped window would have counted all 3 prior pings
12285+
// (pingCount=4 > maxPings=3) and applied the cooldown instead.
12286+
const applied = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'github_app.review_nag_cooldown_applied'").first<{ n: number }>();
12287+
expect(applied?.n).toBe(0);
12288+
expect(seen.closed).toBe(false);
12289+
expect(seen.comments.some((c) => c.includes("cooldown limit"))).toBe(false);
12290+
expect(resolveSettingsSpy).toHaveBeenCalled();
12291+
resolveSettingsSpy.mockRestore();
12292+
});
12293+
1224612294
it("records pings under the configured threshold without acting; the normal @gittensory reply still proceeds", async () => {
1224712295
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
1224812296
await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", reviewNagPolicy: "close", reviewNagMaxPings: 3 });

0 commit comments

Comments
 (0)