diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 31b8ea45ac..a31ebe5749 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -4805,6 +4805,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 }, @@ -4817,12 +4830,19 @@ 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); 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. + 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 // irreversible close (#2562 gate-review follow-up), mirroring the per-repo cap's own sibling live-verify. @@ -4873,6 +4893,11 @@ 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 otherOpenIssues = await listOpenIssues(env, repoFullName); const authorLoginLower = authorLogin.toLowerCase(); const otherAuthorIssueNumbers = otherOpenIssues @@ -4910,7 +4935,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 +4948,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: [] }, }); @@ -5615,6 +5640,48 @@ 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("/")) + /* 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(); + 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" + ) { + 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 + ?? /* 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, + { createMissingLabel: issueSettings.createMissingLabel, mode: newAccountMode }, + ).catch( + /* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */ + () => 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..b753d091f9 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10501,6 +10501,362 @@ 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("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("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("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("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),