From 45cf83929360d0a2b0e2392cdfd04809814de88e Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 03:05:39 +0800 Subject: [PATCH 1/9] 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 6b54ed6c62..5f85bfd3d4 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -4823,6 +4823,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. @@ -4873,6 +4885,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 @@ -4910,7 +4925,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({ @@ -4923,7 +4938,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: [] }, }); @@ -5613,6 +5628,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 7b16e56606..9df17033e0 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 63f3193ec2..59dd14cee6 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 d55f30343b5ccad3359519464dd765ebda86f57d Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 12:56:20 +0800 Subject: [PATCH 2/9] 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 59dd14cee6..8610d01130 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 83df2f700bf109ba2aa43cdb0a7fe3cf62ad3477 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 13:38:30 +0800 Subject: [PATCH 3/9] 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 8610d01130..2c8c0d1b79 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 873b998136551e6f45c7a848d09a5f3bd3753549 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:08:57 +0800 Subject: [PATCH 4/9] 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 5f85bfd3d4..64001b897e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5661,7 +5661,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 2c8c0d1b79..d9ec21339d 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 8b0a0a380ab4734e343aca637dad5b559d3bb655 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:27:33 +0800 Subject: [PATCH 5/9] 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 d9ec21339d..b753d091f9 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 d936ba5acb7fa18b74f865e38f97eb49fbe351f1 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:36:09 +0800 Subject: [PATCH 6/9] 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 b753d091f9..f9337f5995 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 09fd84d5847044d640a8f34f83a69b96e6b10305 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:37:14 +0800 Subject: [PATCH 7/9] 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 f9337f5995..f3999fd1e2 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 ea594a0199c7ff6efa2aa658cb141df08dbcdd6a Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:38:41 +0800 Subject: [PATCH 8/9] 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 64001b897e..4c305a78e7 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5633,6 +5633,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 f3999fd1e2..b753d091f9 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 38c77ddd9098a54501df0d3028601497818ae740 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 14:51:24 +0800 Subject: [PATCH 9/9] 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 4c305a78e7..bcbd183831 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -5649,7 +5649,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, @@ -5666,6 +5667,7 @@ async function processGitHubWebhook( /* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */ () => undefined, ); + } } } }