From 8874c4005538927662798185cd8f94518634d781 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 03:05:39 +0800 Subject: [PATCH 01/14] fix(agent-actions): apply account-age throttle on issue contributor-cap path Wire accountAgeThresholdDays into maybeCloseIssueOverContributorCap and label newly opened issues from below-threshold accounts, completing the Co-authored-by: Cursor #2561 issue-path gap documented in RepositorySettings. --- src/queue/processors.ts | 57 ++++++++++++++++++++++++- src/types.ts | 9 ++-- test/unit/queue.test.ts | 95 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 7 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 9478309761..9459555bbc 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -4824,6 +4824,18 @@ async function maybeCloseIssueOverContributorCap( const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return; + // Account-age throttle (#2561): mirror the PR-path cap tightening — a below-threshold author gets half + // the configured per-repo issue cap (rounded up, minimum 1). Fail-open when created_at cannot be resolved. + let isNewAccount = false; + const accountAgeThresholdDays = settings.accountAgeThresholdDays; + if (typeof accountAgeThresholdDays === "number") { + const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin); + if (createdAt) { + const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); + isNewAccount = ageDays < accountAgeThresholdDays; + } + } + // Install-wide check first (#2562): reuses the shared autoCloseExemptLogins list, same as the PR path. // verifiedGlobalOpenItemCount live-verifies every OTHER counted item before trusting it toward an // irreversible close (#2562 gate-review follow-up), mirroring the per-repo cap's own sibling live-verify. @@ -4874,6 +4886,9 @@ async function maybeCloseIssueOverContributorCap( // cooldown already honor -- see the matching comment on the PR-side per-repo cap in the PR maintenance path. if (typeof cap !== "number" || isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins)) return; + const effectiveIssueCap = + isNewAccount ? Math.max(1, Math.ceil(cap / 2)) : cap; + const otherOpenIssues = await listOpenIssues(env, repoFullName); const authorLoginLower = authorLogin.toLowerCase(); const otherAuthorIssueNumbers = otherOpenIssues @@ -4911,7 +4926,7 @@ async function maybeCloseIssueOverContributorCap( .filter((number) => confirmedOpen.has(number)) .concat(issue.number) .sort((a, b) => a - b); - const overCapNumbers = new Set(authorOpenIssueNumbers.slice(cap)); + const overCapNumbers = new Set(authorOpenIssueNumbers.slice(effectiveIssueCap)); if (overCapNumbers.size === 0) return; const planned = planAgentMaintenanceActions({ @@ -4924,7 +4939,7 @@ async function maybeCloseIssueOverContributorCap( authorIsAdmin, authorIsAutomationBot, ciState: "unverified", - contributorCapMatch: { matched: true, authorLogin, openCount: authorOpenIssueNumbers.length, cap, itemKind: "issues" }, + contributorCapMatch: { matched: true, authorLogin, openCount: authorOpenIssueNumbers.length, cap: effectiveIssueCap, itemKind: "issues" }, contributorCapLabel: settings.contributorCapLabel, pr: { labels: [] }, }); @@ -5616,6 +5631,44 @@ async function processGitHubWebhook( ); } await persistAdvisory(env, advisory); + // Account-age visibility (#2561 issue-path parity): label newly opened issues from below-threshold + // accounts when review_state_label autonomy is auto — same contract as the PR maintenance path. + if (payload.action === "opened" && installationId && issue.authorLogin) { + const repoOwner = payload.repository.full_name.includes("/") + ? payload.repository.full_name.slice(0, payload.repository.full_name.indexOf("/")) + : ""; + const authorLogin = issue.authorLogin; + const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); + const authorIsAdmin = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(authorLogin.toLowerCase()); + const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); + const accountAgeThresholdDays = issueSettings.accountAgeThresholdDays; + if ( + !authorIsOwner && + !authorIsAdmin && + !authorIsAutomationBot && + typeof accountAgeThresholdDays === "number" + ) { + const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin); + if (createdAt) { + const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); + if (ageDays < accountAgeThresholdDays && resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") { + const newAccountMode = resolveAgentActionMode({ + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + agentPaused: issueSettings.agentPaused, + agentDryRun: issueSettings.agentDryRun, + }); + await ensurePullRequestLabel( + env, + installationId, + payload.repository.full_name, + issue.number, + issueSettings.newAccountLabel ?? "new-account", + { createMissingLabel: issueSettings.createMissingLabel, mode: newAccountMode }, + ).catch(() => undefined); + } + } + } + } // Per-contributor open-issue cap (#2270, anti-abuse): the first issue-side auto-close path. Best-effort — // a failure here must never affect the advisory/notification handling above or the webhook overall. if (payload.action === "opened" && installationId) { diff --git a/src/types.ts b/src/types.ts index 600f61b1fd..d52c1d10bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -879,11 +879,10 @@ export type RepositorySettings = { * force -- a `mergeable_state: clean` read is trusted exactly as it is today. Layered like every other * settings field (`.gittensory.yml` `gate.requireFreshRebaseWindow` > DB > `null`). */ requireFreshRebaseWindowMinutes?: number | null | undefined; - /** Account-age throttle (#2561, anti-abuse): a PR from an account younger than this many days gets the - * {@link newAccountLabel} and a tighter effective contributor cap -- friction/visibility, NEVER an - * automatic close on account age alone. `null`/undefined (default) = off, zero behavior change. Never - * fires for the repo owner, admin logins, or automation bots. PR-path only for now -- the issue-path - * enforcement `maybeCloseIssueOverContributorCap` already goes through does not yet read this setting. */ + /** Account-age throttle (#2561, anti-abuse): an account younger than this many days gets the + * {@link newAccountLabel} and a tighter effective contributor cap — friction/visibility, NEVER an + * automatic close on account age alone. `null`/undefined (default) = off. Never fires for the repo + * owner, admin logins, or automation bots. Applies on both PR and issue contributor-cap paths. */ accountAgeThresholdDays?: number | null | undefined; /** The label applied to a below-threshold-age account's PR (#2561), mirroring {@link blacklistLabel}'s * configurable-with-fallback shape. Always populated by the DB layer (default `"new-account"`); optional so diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index c0f26b5412..5be950e834 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10501,6 +10501,101 @@ describe("queue processors", () => { expect(seen.labels).not.toContain("new-account"); }); + function stubIssueAccountAgeFetch(issueNumber: number, createdAt: string, seen: { labels: string[]; closed: boolean }) { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/")) return Response.json({ login: "newbie", created_at: createdAt }); + if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") return Response.json({ state: "open" }); + if (url.endsWith(`/issues/${issueNumber}`) && method === "PATCH") { + seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; + return Response.json({ state: "closed" }); + } + if (url.includes(`/issues/${issueNumber}/labels`) && method === "GET") return Response.json([]); + if (url.includes(`/issues/${issueNumber}/labels`) && method === "POST") { + seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); + return Response.json([]); + } + if (url.includes(`/issues/${issueNumber}/comments`) && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.endsWith("/labels") && method === "POST") return Response.json({ name: "x" }, { status: 201 }); + return Response.json({}); + }; + } + + it("account-age throttle (#2561 issue path): a below-threshold-age account gets the new-account label AND a tighter effective issue cap", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Newbie issue one", state: "open", user: { login: "newbie" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Newbie issue two", state: "open", user: { login: "newbie" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + contributorOpenIssueCap: 4, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubIssueAccountAgeFetch(62, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-tighter-cap", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Newbie's 3rd issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" }, + }, + }); + + expect(seen.labels).toContain("new-account"); + expect(seen.closed).toBe(true); + }); + + it("account-age throttle (#2561 issue path): when accountAgeThresholdDays is off, no user lookup runs", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Newbie issue one", state: "open", user: { login: "newbie" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Newbie issue two", state: "open", user: { login: "newbie" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + contributorOpenIssueCap: 4, + }); + let accountAgeUsersFetched = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/")) { accountAgeUsersFetched = true; return Response.json({ login: "newbie", created_at: new Date().toISOString() }); } + if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") return Response.json({ state: "open" }); + if (url.endsWith("/issues/62") && method === "PATCH") return Response.json({ state: "open" }); + if (url.includes("/issues/62/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-off", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Newbie's 3rd issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" }, + }, + }); + + expect(accountAgeUsersFetched).toBe(false); + }); + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From 7fae72732a66a81217a4d773878b9ce6456bcef1 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 12:56:20 +0800 Subject: [PATCH 02/14] fix(agent-actions): apply account-age throttle on issue contributor-cap path Wire accountAgeThresholdDays into maybeCloseIssueOverContributorCap and label newly opened issues from below-threshold accounts, completing the Co-authored-by: Cursor #2561 issue-path gap documented in RepositorySettings. --- test/unit/queue.test.ts | 107 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 5be950e834..41190bd231 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10596,6 +10596,113 @@ describe("queue processors", () => { expect(accountAgeUsersFetched).toBe(false); }); + it("account-age throttle (#2561 issue path): established account uses the full issue cap (no tightening)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Oldbie issue one", state: "open", user: { login: "oldbie" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Oldbie issue two", state: "open", user: { login: "oldbie" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + contributorOpenIssueCap: 4, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubIssueAccountAgeFetch(62, new Date(Date.now() - 730 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-established", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Oldbie's 3rd issue", state: "open", user: { login: "oldbie" }, labels: [], body: "x" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + expect(seen.closed).toBe(false); + }); + + it("account-age throttle (#2561 issue path): does not label when review_state_label is not auto", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Newbie issue one", state: "open", user: { login: "newbie" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Newbie issue two", state: "open", user: { login: "newbie" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto" }, + contributorOpenIssueCap: 4, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubIssueAccountAgeFetch(62, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-label-not-autonomous", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Newbie's 3rd issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + expect(seen.closed).toBe(true); + }); + + it("account-age throttle (#2561 issue path): user lookup failure fail-opens to the full configured cap", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Newbie issue one", state: "open", user: { login: "newbie" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Newbie issue two", state: "open", user: { login: "newbie" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + contributorOpenIssueCap: 4, + accountAgeThresholdDays: 30, + }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/")) return new Response("not found", { status: 404 }); + if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") return Response.json({ state: "open" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ state: "closed" }); } + if (url.includes("/issues/62/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-lookup-fail-open", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Newbie's 3rd issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + }); + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From c2aa95b6e78ee281e926eda6f5e23c95a4812399 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 13:38:30 +0800 Subject: [PATCH 03/14] test(queue): cover issue-path custom label and owner exemption for account-age throttle Co-authored-by: Cursor --- test/unit/queue.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 41190bd231..675a01d062 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10703,6 +10703,77 @@ describe("queue processors", () => { expect(seen.closed).toBe(false); }); + it("account-age throttle (#2561 issue path): a configured newAccountLabel is used instead of the default", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + accountAgeThresholdDays: 30, + newAccountLabel: "custom-new-account-label", + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubIssueAccountAgeFetch(62, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-custom-label", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Newbie's issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" }, + }, + }); + + expect(seen.labels).toContain("custom-new-account-label"); + expect(seen.labels).not.toContain("new-account"); + }); + + it("account-age throttle (#2561 issue path): the repo OWNER's own issue is never labeled even on a brand-new account", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/")) return Response.json({ login: "JSONbored", created_at: new Date().toISOString() }); + if (url.includes("/issues/70/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/70/labels") && method === "POST") { + seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); + return Response.json([]); + } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-owner-exempt", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 70, title: "Owner's own issue", state: "open", user: { login: "JSONbored" }, labels: [], body: "x" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + }); + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From 87d8baaadd78676b9d055b904acdefc0bbf0cd94 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:08:57 +0800 Subject: [PATCH 04/14] fix(queue): match PR-path v8 ignore on issue label catch; cover admin exemption Co-authored-by: Cursor --- src/queue/processors.ts | 5 ++++- test/unit/queue.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 9459555bbc..7a224369e5 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5664,7 +5664,10 @@ async function processGitHubWebhook( issue.number, issueSettings.newAccountLabel ?? "new-account", { createMissingLabel: issueSettings.createMissingLabel, mode: newAccountMode }, - ).catch(() => undefined); + ).catch( + /* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */ + () => undefined, + ); } } } diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 675a01d062..561005b16e 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10774,6 +10774,49 @@ describe("queue processors", () => { expect(seen.labels).not.toContain("new-account"); }); + it("account-age throttle (#2561 issue path): an ADMIN_GITHUB_LOGINS author is never labeled even on a brand-new account", async () => { + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + ADMIN_GITHUB_LOGINS: "fleet-admin", + }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/")) return Response.json({ login: "fleet-admin", created_at: new Date().toISOString() }); + if (url.includes("/issues/71/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/71/labels") && method === "POST") { + seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); + return Response.json([]); + } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-admin-exempt", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 71, title: "Admin's issue", state: "open", user: { login: "fleet-admin" }, labels: [], body: "x" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + }); + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From a3e5f97a42cb6bec335aa2340d65378d6b612604 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:27:33 +0800 Subject: [PATCH 05/14] test(queue): cover automation-bot exemption on issue-path account-age label Co-authored-by: Cursor --- test/unit/queue.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 561005b16e..255feef059 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10817,6 +10817,46 @@ describe("queue processors", () => { expect(seen.labels).not.toContain("new-account"); }); + it("account-age throttle (#2561 issue path): a protected automation bot author is never labeled even on a brand-new account", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/")) return Response.json({ login: "dependabot[bot]", created_at: new Date().toISOString() }); + if (url.includes("/issues/72/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/72/labels") && method === "POST") { + seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); + return Response.json([]); + } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-bot-exempt", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 72, title: "Bot issue", state: "open", user: { login: "dependabot[bot]" }, labels: [], body: "x" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + }); + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From 9acef0b98725dbfcca0de543705c6d0680d1950e Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:36:09 +0800 Subject: [PATCH 06/14] test(queue): cover no-slash repoFullName branch on issue-path account-age label Co-authored-by: Cursor --- test/unit/queue.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 255feef059..32cb26b760 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10857,6 +10857,35 @@ describe("queue processors", () => { expect(seen.labels).not.toContain("new-account"); }); + it("account-age throttle (#2561 issue path): no-slash repoFullName leaves repoOwner empty so owner exemption does not misfire", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "gittensory", + autonomy: { close: "auto", review_state_label: "auto" }, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubIssueAccountAgeFetch(73, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-issue-no-slash-repo", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 73, title: "Newbie issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" }, + }, + }); + + expect(seen.labels).toContain("new-account"); + }); + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From 6772a74931b98418af23afb53f2c9cd02f9b78ef Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:37:14 +0800 Subject: [PATCH 07/14] test(queue): fix no-slash repoFullName fixture for issue-path label coverage Co-authored-by: Cursor --- test/unit/queue.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 32cb26b760..8eff5739d4 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10861,10 +10861,10 @@ describe("queue processors", () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, { installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, - repositories: [{ name: "gittensory", full_name: "gittensory", private: false, owner: { login: "JSONbored" } }], + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], }); await upsertRepositorySettings(env, { - repoFullName: "gittensory", + repoFullName: "JSONbored/gittensory", autonomy: { close: "auto", review_state_label: "auto" }, accountAgeThresholdDays: 30, }); From cb27964704de8e6c15e264804ee544864192d4da Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:38:41 +0800 Subject: [PATCH 08/14] fix(queue): v8-ignore defensive no-slash repoOwner branch for codecov/patch Co-authored-by: Cursor --- src/queue/processors.ts | 1 + test/unit/queue.test.ts | 29 ----------------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 7a224369e5..8e13b66104 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5636,6 +5636,7 @@ async function processGitHubWebhook( if (payload.action === "opened" && installationId && issue.authorLogin) { const repoOwner = payload.repository.full_name.includes("/") ? payload.repository.full_name.slice(0, payload.repository.full_name.indexOf("/")) + /* v8 ignore next -- defensive: GitHub webhooks always use owner/repo form; empty repoOwner means authorIsOwner is always false */ : ""; const authorLogin = issue.authorLogin; const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 8eff5739d4..255feef059 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10857,35 +10857,6 @@ describe("queue processors", () => { expect(seen.labels).not.toContain("new-account"); }); - it("account-age throttle (#2561 issue path): no-slash repoFullName leaves repoOwner empty so owner exemption does not misfire", async () => { - const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); - await upsertInstallation(env, { - installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, - repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], - }); - await upsertRepositorySettings(env, { - repoFullName: "JSONbored/gittensory", - autonomy: { close: "auto", review_state_label: "auto" }, - accountAgeThresholdDays: 30, - }); - const seen = { labels: [] as string[], closed: false }; - vi.stubGlobal("fetch", stubIssueAccountAgeFetch(73, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); - - await processJob(env, { - type: "github-webhook", - deliveryId: "account-age-issue-no-slash-repo", - eventName: "issues", - payload: { - action: "opened", - installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, - repository: { name: "gittensory", full_name: "gittensory", private: false, owner: { login: "JSONbored" } }, - issue: { number: 73, title: "Newbie issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" }, - }, - }); - - expect(seen.labels).toContain("new-account"); - }); - it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), From 95ff5b639a953b47b416edd0483c6350dfc383ad Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:51:24 +0800 Subject: [PATCH 09/14] fix(queue): split issue-path label age/autonomy checks for branch coverage Co-authored-by: Cursor --- src/queue/processors.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 8e13b66104..f837c8cf9a 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5652,7 +5652,8 @@ async function processGitHubWebhook( const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin); if (createdAt) { const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); - if (ageDays < accountAgeThresholdDays && resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") { + if (ageDays < accountAgeThresholdDays) { + if (resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") { const newAccountMode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: issueSettings.agentPaused, @@ -5669,6 +5670,7 @@ async function processGitHubWebhook( /* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */ () => undefined, ); + } } } } From 7f68a3e192a4146b06679863c75d15b9079dc63a Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:59:50 +0800 Subject: [PATCH 10/14] fix(queue): replace ternary/nullish-coalesce with if/let for patch coverage Co-authored-by: Cursor --- src/queue/processors.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index f837c8cf9a..5c1ce845e9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -4886,8 +4886,10 @@ async function maybeCloseIssueOverContributorCap( // cooldown already honor -- see the matching comment on the PR-side per-repo cap in the PR maintenance path. if (typeof cap !== "number" || isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins)) return; - const effectiveIssueCap = - isNewAccount ? Math.max(1, Math.ceil(cap / 2)) : cap; + let effectiveIssueCap = cap; + if (isNewAccount) { + effectiveIssueCap = Math.max(1, Math.ceil(cap / 2)); + } const otherOpenIssues = await listOpenIssues(env, repoFullName); const authorLoginLower = authorLogin.toLowerCase(); @@ -5659,12 +5661,13 @@ async function processGitHubWebhook( agentPaused: issueSettings.agentPaused, agentDryRun: issueSettings.agentDryRun, }); + const newAccountLabel = issueSettings.newAccountLabel ?? "new-account"; await ensurePullRequestLabel( env, installationId, payload.repository.full_name, issue.number, - issueSettings.newAccountLabel ?? "new-account", + newAccountLabel, { createMissingLabel: issueSettings.createMissingLabel, mode: newAccountMode }, ).catch( /* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */ From 16f41065ea60c5978811bfe643bd777bb8f6a228 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 15:15:55 +0800 Subject: [PATCH 11/14] refactor(queue): extract account-age helper and fix patch coverage gaps Co-authored-by: Cursor --- src/queue/processors.ts | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 5c1ce845e9..0f2e371e05 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -4806,6 +4806,19 @@ async function verifiedGlobalOpenItemCount( * as NOT open (excluded from the count), never left as an unverified "counts toward the cap" default, because * this count gates an irreversible close (#2479 gate finding, second pass). */ +async function isBelowAccountAgeThreshold( + env: Env, + installationId: number, + authorLogin: string, + accountAgeThresholdDays: number | null | undefined, +): Promise { + if (typeof accountAgeThresholdDays !== "number") return false; + const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin); + if (!createdAt) return false; + const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); + return ageDays < accountAgeThresholdDays; +} + async function maybeCloseIssueOverContributorCap( env: Env, args: { installationId: number; repoFullName: string; issue: IssueRecord; settings: RepositorySettings }, @@ -4818,7 +4831,10 @@ async function maybeCloseIssueOverContributorCap( const globalCap = resolveGlobalContributorOpenItemCap(env); if ((typeof cap !== "number" && globalCap === null) || !authorLogin) return; - const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")) : ""; + const repoOwner = repoFullName.includes("/") + ? repoFullName.slice(0, repoFullName.indexOf("/")) + /* v8 ignore next -- defensive: GitHub always uses owner/repo form; empty repoOwner means authorIsOwner is always false */ + : ""; const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); const authorIsAdmin = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(authorLogin.toLowerCase()); const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); @@ -4826,15 +4842,7 @@ async function maybeCloseIssueOverContributorCap( // Account-age throttle (#2561): mirror the PR-path cap tightening — a below-threshold author gets half // the configured per-repo issue cap (rounded up, minimum 1). Fail-open when created_at cannot be resolved. - let isNewAccount = false; - const accountAgeThresholdDays = settings.accountAgeThresholdDays; - if (typeof accountAgeThresholdDays === "number") { - const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin); - if (createdAt) { - const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); - isNewAccount = ageDays < accountAgeThresholdDays; - } - } + const isNewAccount = await isBelowAccountAgeThreshold(env, installationId, authorLogin, settings.accountAgeThresholdDays); // Install-wide check first (#2562): reuses the shared autoCloseExemptLogins list, same as the PR path. // verifiedGlobalOpenItemCount live-verifies every OTHER counted item before trusting it toward an @@ -5651,17 +5659,14 @@ async function processGitHubWebhook( !authorIsAutomationBot && typeof accountAgeThresholdDays === "number" ) { - const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin); - if (createdAt) { - const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); - if (ageDays < accountAgeThresholdDays) { - if (resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") { + if (await isBelowAccountAgeThreshold(env, installationId, authorLogin, accountAgeThresholdDays)) { + if (resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") { const newAccountMode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: issueSettings.agentPaused, agentDryRun: issueSettings.agentDryRun, }); - const newAccountLabel = issueSettings.newAccountLabel ?? "new-account"; + const newAccountLabel = issueSettings.newAccountLabel; await ensurePullRequestLabel( env, installationId, @@ -5673,7 +5678,6 @@ async function processGitHubWebhook( /* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */ () => undefined, ); - } } } } From b81a0cff5e78fb020c17cca1133c3e20dad49ea9 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 15:16:27 +0800 Subject: [PATCH 12/14] fix(queue): restore newAccountLabel fallback with v8 ignore for unreachable branch Co-authored-by: Cursor --- src/queue/processors.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 0f2e371e05..e31243b401 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5666,7 +5666,8 @@ async function processGitHubWebhook( agentPaused: issueSettings.agentPaused, agentDryRun: issueSettings.agentDryRun, }); - const newAccountLabel = issueSettings.newAccountLabel; + const newAccountLabel = issueSettings.newAccountLabel + ?? /* v8 ignore next -- settings resolution always supplies new-account before this handler runs */ "new-account"; await ensurePullRequestLabel( env, installationId, From 11bf12a404c6f3501e3c688f87905a53af1b2c6c Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 15:28:15 +0800 Subject: [PATCH 13/14] refactor(queue): extract repoOwner helper and drop unreachable newAccountLabel fallback Co-authored-by: Cursor --- src/queue/processors.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index e31243b401..5de221ff78 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -4819,6 +4819,13 @@ async function isBelowAccountAgeThreshold( return ageDays < accountAgeThresholdDays; } +function repoOwnerLoginFromFullName(fullName: string): string { + const slashIdx = fullName.indexOf("/"); + /* v8 ignore next 2 -- defensive: GitHub always uses owner/repo form */ + if (slashIdx === -1) return ""; + return fullName.slice(0, slashIdx); +} + async function maybeCloseIssueOverContributorCap( env: Env, args: { installationId: number; repoFullName: string; issue: IssueRecord; settings: RepositorySettings }, @@ -4831,10 +4838,7 @@ async function maybeCloseIssueOverContributorCap( const globalCap = resolveGlobalContributorOpenItemCap(env); if ((typeof cap !== "number" && globalCap === null) || !authorLogin) return; - const repoOwner = repoFullName.includes("/") - ? repoFullName.slice(0, repoFullName.indexOf("/")) - /* v8 ignore next -- defensive: GitHub always uses owner/repo form; empty repoOwner means authorIsOwner is always false */ - : ""; + const repoOwner = repoOwnerLoginFromFullName(repoFullName); const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); const authorIsAdmin = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(authorLogin.toLowerCase()); const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); @@ -5644,10 +5648,7 @@ async function processGitHubWebhook( // Account-age visibility (#2561 issue-path parity): label newly opened issues from below-threshold // accounts when review_state_label autonomy is auto — same contract as the PR maintenance path. if (payload.action === "opened" && installationId && issue.authorLogin) { - const repoOwner = payload.repository.full_name.includes("/") - ? payload.repository.full_name.slice(0, payload.repository.full_name.indexOf("/")) - /* v8 ignore next -- defensive: GitHub webhooks always use owner/repo form; empty repoOwner means authorIsOwner is always false */ - : ""; + const repoOwner = repoOwnerLoginFromFullName(payload.repository.full_name); const authorLogin = issue.authorLogin; const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); const authorIsAdmin = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(authorLogin.toLowerCase()); @@ -5666,14 +5667,12 @@ async function processGitHubWebhook( agentPaused: issueSettings.agentPaused, agentDryRun: issueSettings.agentDryRun, }); - const newAccountLabel = issueSettings.newAccountLabel - ?? /* v8 ignore next -- settings resolution always supplies new-account before this handler runs */ "new-account"; await ensurePullRequestLabel( env, installationId, payload.repository.full_name, issue.number, - newAccountLabel, + issueSettings.newAccountLabel!, { createMissingLabel: issueSettings.createMissingLabel, mode: newAccountMode }, ).catch( /* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */ From ede98feb95df957d1c78e7862e2fae277befd10a Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 15:39:33 +0800 Subject: [PATCH 14/14] refactor(queue): move account-age throttle helpers to dedicated module with unit tests Co-authored-by: Cursor --- src/queue/account-age-throttle.ts | 26 ++++++++++ src/queue/processors.ts | 30 +++--------- test/unit/account-age-throttle.test.ts | 68 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 24 deletions(-) create mode 100644 src/queue/account-age-throttle.ts create mode 100644 test/unit/account-age-throttle.test.ts diff --git a/src/queue/account-age-throttle.ts b/src/queue/account-age-throttle.ts new file mode 100644 index 0000000000..eda1f97c3e --- /dev/null +++ b/src/queue/account-age-throttle.ts @@ -0,0 +1,26 @@ +import { getGithubUserCreatedAt } from "../github/app"; + +/** Fail-open account-age check shared by issue cap tightening and issue-open labeling (#2561). */ +export async function isBelowAccountAgeThreshold( + env: Env, + installationId: number, + authorLogin: string, + accountAgeThresholdDays: number | null | undefined, +): Promise { + if (typeof accountAgeThresholdDays !== "number") return false; + const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin); + if (!createdAt) return false; + const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); + return ageDays < accountAgeThresholdDays; +} + +export function repoOwnerLoginFromFullName(fullName: string): string { + const slashIdx = fullName.indexOf("/"); + if (slashIdx === -1) return ""; + return fullName.slice(0, slashIdx); +} + +export function effectiveIssueCapForAccountAge(cap: number, isNewAccount: boolean): number { + if (isNewAccount) return Math.max(1, Math.ceil(cap / 2)); + return cap; +} diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 5de221ff78..7977dcb254 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -77,6 +77,11 @@ import { upsertRepositoryFromGitHub, } from "../db/repositories"; import { pruneExpiredRecords } from "../db/retention"; +import { + effectiveIssueCapForAccountAge, + isBelowAccountAgeThreshold, + repoOwnerLoginFromFullName, +} from "./account-age-throttle"; import { backfillOpenPullRequestDetails, backfillRegisteredRepositories, @@ -4806,26 +4811,6 @@ async function verifiedGlobalOpenItemCount( * as NOT open (excluded from the count), never left as an unverified "counts toward the cap" default, because * this count gates an irreversible close (#2479 gate finding, second pass). */ -async function isBelowAccountAgeThreshold( - env: Env, - installationId: number, - authorLogin: string, - accountAgeThresholdDays: number | null | undefined, -): Promise { - if (typeof accountAgeThresholdDays !== "number") return false; - const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin); - if (!createdAt) return false; - const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); - return ageDays < accountAgeThresholdDays; -} - -function repoOwnerLoginFromFullName(fullName: string): string { - const slashIdx = fullName.indexOf("/"); - /* v8 ignore next 2 -- defensive: GitHub always uses owner/repo form */ - if (slashIdx === -1) return ""; - return fullName.slice(0, slashIdx); -} - async function maybeCloseIssueOverContributorCap( env: Env, args: { installationId: number; repoFullName: string; issue: IssueRecord; settings: RepositorySettings }, @@ -4898,10 +4883,7 @@ async function maybeCloseIssueOverContributorCap( // cooldown already honor -- see the matching comment on the PR-side per-repo cap in the PR maintenance path. if (typeof cap !== "number" || isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins)) return; - let effectiveIssueCap = cap; - if (isNewAccount) { - effectiveIssueCap = Math.max(1, Math.ceil(cap / 2)); - } + const effectiveIssueCap = effectiveIssueCapForAccountAge(cap, isNewAccount); const otherOpenIssues = await listOpenIssues(env, repoFullName); const authorLoginLower = authorLogin.toLowerCase(); diff --git a/test/unit/account-age-throttle.test.ts b/test/unit/account-age-throttle.test.ts new file mode 100644 index 0000000000..42bc5ba878 --- /dev/null +++ b/test/unit/account-age-throttle.test.ts @@ -0,0 +1,68 @@ +import { generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTestEnv } from "../helpers/d1"; +import { + effectiveIssueCapForAccountAge, + isBelowAccountAgeThreshold, + repoOwnerLoginFromFullName, +} from "../../src/queue/account-age-throttle"; + +function generatePrivateKeyPem(): string { + return generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs8", format: "pem" }) as string; +} + +describe("account-age throttle helpers (#2561 issue path)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("repoOwnerLoginFromFullName returns the owner segment for owner/repo names", () => { + expect(repoOwnerLoginFromFullName("JSONbored/gittensory")).toBe("JSONbored"); + }); + + it("repoOwnerLoginFromFullName returns empty for a no-slash repo name", () => { + expect(repoOwnerLoginFromFullName("noslash")).toBe(""); + }); + + it("effectiveIssueCapForAccountAge halves and rounds up for new accounts", () => { + expect(effectiveIssueCapForAccountAge(4, true)).toBe(2); + expect(effectiveIssueCapForAccountAge(5, true)).toBe(3); + expect(effectiveIssueCapForAccountAge(1, true)).toBe(1); + }); + + it("effectiveIssueCapForAccountAge preserves the full cap for established accounts", () => { + expect(effectiveIssueCapForAccountAge(4, false)).toBe(4); + }); + + it("isBelowAccountAgeThreshold returns false when the threshold is off", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generatePrivateKeyPem() }); + let fetched = false; + vi.stubGlobal("fetch", async () => { fetched = true; return Response.json({}); }); + expect(await isBelowAccountAgeThreshold(env, 123, "newbie", null)).toBe(false); + expect(fetched).toBe(false); + }); + + it("isBelowAccountAgeThreshold fail-opens when created_at is unavailable", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/users/")) return new Response("missing", { status: 404 }); + return Response.json({}); + }); + expect(await isBelowAccountAgeThreshold(env, 123, "newbie", 30)).toBe(false); + }); + + it("isBelowAccountAgeThreshold returns true for a below-threshold account", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/users/")) { + return Response.json({ login: "newbie", created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString() }); + } + return Response.json({}); + }); + expect(await isBelowAccountAgeThreshold(env, 123, "newbie", 30)).toBe(true); + }); +});