diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 9906ef4255..85b82fdbf8 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -333,3 +333,9 @@ settings: # above will reuse this list too), on top of the standing owner/admin/automation-bot exemption. # List of GitHub logins. Default: [] (no additional exemptions). # autoCloseExemptLogins: [some-trusted-regular] + + # Account-age throttle (#2561, anti-abuse): a PR from an account younger than this many days gets + # the newAccountLabel below -- friction/visibility only, NEVER an automatic close on account age alone. + # Never fires for the repo owner, admin logins, or automation bots. PR-path only. Off by default. + # accountAgeThresholdDays: null # Positive integer, or null/omitted to disable. Default: null (off). + # newAccountLabel: new-account # Label applied to a below-threshold-age account's PR. Default: new-account. diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 197086e44f..5cdb50d945 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8688,6 +8688,15 @@ "nullable": true, "minimum": 0, "exclusiveMinimum": true + }, + "accountAgeThresholdDays": { + "type": "integer", + "nullable": true, + "minimum": 0, + "exclusiveMinimum": true + }, + "newAccountLabel": { + "type": "string" } }, "required": [ diff --git a/migrations/0096_account_age_throttle.sql b/migrations/0096_account_age_throttle.sql new file mode 100644 index 0000000000..ea068487e9 --- /dev/null +++ b/migrations/0096_account_age_throttle.sql @@ -0,0 +1,6 @@ +-- Account-age throttle (#2561, anti-abuse): a friction/visibility signal for the classic ban-evasion pattern +-- (a banned login gets a fresh account the same day). Defaults are byte-identical to today: +-- account_age_threshold_days defaults to NULL (off), so existing repos see no behavior change until they +-- configure a threshold. Never auto-closes on account age alone -- label and/or a tighter effective cap only. +ALTER TABLE repository_settings ADD COLUMN account_age_threshold_days INTEGER; +ALTER TABLE repository_settings ADD COLUMN new_account_label TEXT NOT NULL DEFAULT 'new-account'; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index e928883cb2..3589785a29 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -511,6 +511,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise reviewNagLabel: "review-nag-cooldown", autoCloseExemptLogins: [], requireFreshRebaseWindowMinutes: null, + accountAgeThresholdDays: null, + newAccountLabel: "new-account", }; } return { @@ -564,6 +566,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise reviewNagLabel: row.reviewNagLabel, autoCloseExemptLogins: parseAutoCloseExemptLogins(row.autoCloseExemptLoginsJson), requireFreshRebaseWindowMinutes: normalizeOpenItemCap(row.requireFreshRebaseWindowMinutes), + accountAgeThresholdDays: normalizeOpenItemCap(row.accountAgeThresholdDays), + newAccountLabel: row.newAccountLabel, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -649,6 +653,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); diff --git a/src/github/app.ts b/src/github/app.ts index f13acbca91..ca8e6e9dad 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -404,6 +404,35 @@ export async function getRepositoryCollaboratorPermission( return payload.permission ?? null; } +/** Account-age throttle (#2561, anti-abuse): `GET /users/{login}` for `created_at`, so a repo can flag a + * freshly-created account as friction/visibility for the classic ban-evasion pattern (a banned login gets a + * fresh account the same day). `/users/{login}` is already classified "metadata" by `resolveGitHubCacheClass` + * (src/github/client.ts), so this reuses the existing response cache with no new caching infra. Returns null + * (fail-open) on any non-2xx/network error -- a lookup failure must never invent a "new account" finding. */ +export async function getGithubUserCreatedAt( + env: Env, + installationId: number, + login: string, +): Promise { + if (!login) return null; + try { + const token = await createInstallationToken(env, installationId); + const response = await timeoutFetch( + `https://api.github.com/users/${encodeURIComponent(login)}`, + { + headers: githubHeaders(`Bearer ${token}`), + githubRateLimitAdmission: true, + githubRateLimitAdmissionKey: githubRateLimitAdmissionKeyForInstallation(installationId), + }, + ); + if (!response.ok) return null; + const payload = (await response.json()) as { created_at?: unknown }; + return typeof payload.created_at === "string" ? payload.created_at : null; + } catch { + return null; + } +} + // The App JWT is valid ~9 min (iat backdated 60s, exp +540s). Re-signing (RS256) it on EVERY call is wasteful CPU // AND defeats response caching of App-level reads (/app/installations/{id}): the rotating JWT changes the // auth-scoped response-cache key on every call, so the metadata cache class never hits for its heaviest caller diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index d50d6f3ca7..81f3ea0bd7 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -650,6 +650,8 @@ export const RepositorySettingsSchema = z reviewNagCooldownDays: z.number().int().positive().optional(), reviewNagLabel: z.string().optional(), autoCloseExemptLogins: z.array(z.string()).optional(), + accountAgeThresholdDays: z.number().int().positive().nullable().optional(), + newAccountLabel: z.string().optional(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index a3270c15f7..e3005cae00 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -113,6 +113,7 @@ import { createOrUpdateOverriddenGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, + getGithubUserCreatedAt, getInstallationId, getRepositoryCollaboratorPermission, GITTENSORY_GATE_CHECK_NAME, @@ -1978,6 +1979,39 @@ async function runAgentMaintenancePlanAndExecute( settings.contributorBlacklist, ); + // Account-age throttle (#2561, anti-abuse): a friction/visibility signal for the classic ban-evasion pattern + // (a banned login gets a fresh account the same day) — NEVER an automatic close on account age alone. Off + // (null accountAgeThresholdDays, the default) ⇒ this block is a no-op, no extra GitHub API call at all. + // Fires for a CONTRIBUTOR only — same standing owner/admin/automation-bot exemption as every other + // anti-abuse mechanism above. The label is applied directly (fire-and-forget, matching mode gating) rather + // than threaded through the planner: this is advisory/visibility only, independent of the merit/CI/AI + // disposition the planner computes below. Gate finding: a direct label mutation still needs the SAME + // label-autonomy opt-in every other label write goes through (resolveAutonomy(..., "label") === "auto") — + // a repo that has not opted into automatic label actions must not have this throttle silently write labels. + let isNewAccount = false; + const accountAgeThresholdDays = settings.accountAgeThresholdDays; + if (typeof accountAgeThresholdDays === "number" && pr.authorLogin && !authorIsOwner && !authorIsAdmin && !authorIsAutomationBot) { + const createdAt = await getGithubUserCreatedAt(env, installationId, pr.authorLogin); + if (createdAt) { + const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000); + isNewAccount = ageDays < accountAgeThresholdDays; + } + if (isNewAccount && resolveAutonomy(settings.autonomy, "label") === "auto") { + const newAccountMode = resolveAgentActionMode({ + globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), + agentPaused: settings.agentPaused, + agentDryRun: settings.agentDryRun, + }); + await ensurePullRequestLabel(env, installationId, repoFullName, pr.number, settings.newAccountLabel ?? "new-account", { + createMissingLabel: settings.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-PR cap (#2270, anti-abuse): count this author's OTHER currently-open PRs on this repo // (otherOpenPullRequests already excludes the current PR — see reconcileLiveDuplicateSiblings) plus this one, // ranked by PR NUMBER (GitHub's own creation order, not webhook-arrival order) so a burst of near-simultaneous @@ -1985,9 +2019,14 @@ async function runAgentMaintenancePlanAndExecute( // older sibling that was already under the cap when it was opened stays open. The owner/admin/automation-bot // exemption is applied by the planner itself (defense-in-depth, mirroring how blacklistEntry above is resolved // unconditionally and exempted only inside planAgentMaintenanceActions). Disabled (null/undefined cap, the - // default) ⇒ this block is a no-op. + // default) ⇒ this block is a no-op. A below-account-age-threshold author (#2561) gets a TIGHTER effective + // cap (half, rounded up, minimum 1) — visibility/friction, still never a close on account age by itself + // (the close, if any, is still tagged/reasoned as the ordinary contributor-cap close). let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined; - const contributorOpenPrCap = settings.contributorOpenPrCap; + const contributorOpenPrCap = + isNewAccount && typeof settings.contributorOpenPrCap === "number" + ? Math.max(1, Math.ceil(settings.contributorOpenPrCap / 2)) + : settings.contributorOpenPrCap; if (typeof contributorOpenPrCap === "number" && pr.authorLogin) { const authorLoginLower = pr.authorLogin.toLowerCase(); const authorOpenPrNumbers = otherOpenPullRequests diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 4faf7199fc..85d49f17b8 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -150,6 +150,8 @@ export type FocusManifestSettings = Partial< | "reviewNagCooldownDays" | "reviewNagLabel" | "autoCloseExemptLogins" + | "accountAgeThresholdDays" + | "newAccountLabel" > >; @@ -867,6 +869,16 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) warnings.push(...exemptWarnings); if (logins.length > 0) out.autoCloseExemptLogins = logins; } + // Account-age throttle (#2561): an explicit yml `null` is load-bearing (clears a DB-configured threshold + // back to "off"), matching contributorOpenPrCap's own null-vs-omitted distinction above. + if (r.accountAgeThresholdDays === null) { + out.accountAgeThresholdDays = null; + } else { + const accountAgeThresholdDays = normalizeOptionalPositiveInteger(r.accountAgeThresholdDays, "settings.accountAgeThresholdDays", warnings); + if (accountAgeThresholdDays !== null) out.accountAgeThresholdDays = accountAgeThresholdDays; + } + const newAccountLabel = normalizeOptionalString(r.newAccountLabel, "settings.newAccountLabel", warnings); + if (newAccountLabel !== null) out.newAccountLabel = newAccountLabel; return out; } diff --git a/src/types.ts b/src/types.ts index 3c830da2e8..a4b5c887b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -666,6 +666,16 @@ 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. */ + 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 + * existing settings fixtures/callers need not be touched. */ + newAccountLabel?: string | undefined; /** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default * `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers * need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */ diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index c05465733d..6846e63ada 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -1384,6 +1384,24 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(invalid.warnings.some((w) => /settings\.reviewNagCooldownDays/.test(w))).toBe(true); }); + it("parses + resolves the account-age throttle settings from the settings: block, overlaying the DB (#2561)", () => { + const manifest = parseFocusManifest({ settings: { accountAgeThresholdDays: 14, newAccountLabel: "fresh-account" } }); + expect(manifest.settings.accountAgeThresholdDays).toBe(14); + expect(manifest.settings.newAccountLabel).toBe("fresh-account"); + const eff = resolveEffectiveSettings({ accountAgeThresholdDays: null, newAccountLabel: "new-account" } as unknown as RepositorySettings, manifest); + expect(eff.accountAgeThresholdDays).toBe(14); + // An explicit yml `null` clears a DB-configured threshold back to off (load-bearing null). + const cleared = resolveEffectiveSettings({ accountAgeThresholdDays: 30 } as unknown as RepositorySettings, parseFocusManifest({ settings: { accountAgeThresholdDays: null } })); + expect(cleared.accountAgeThresholdDays).toBeNull(); + // Omitted in yml ⇒ the DB-configured threshold survives untouched. + const noOverride = resolveEffectiveSettings({ accountAgeThresholdDays: 7 } as unknown as RepositorySettings, parseFocusManifest({})); + expect(noOverride.accountAgeThresholdDays).toBe(7); + // A non-positive threshold is dropped with a warning rather than silently coerced. + const invalid = parseFocusManifest({ settings: { accountAgeThresholdDays: 0 } }); + expect(invalid.settings.accountAgeThresholdDays).toBeUndefined(); + expect(invalid.warnings.some((w) => /settings\.accountAgeThresholdDays/.test(w))).toBe(true); + }); + it("parses + resolves autoCloseExemptLogins from the settings: block, overlaying the DB (#2463)", () => { const manifest = parseFocusManifest({ settings: { autoCloseExemptLogins: ["Trusted-Regular", "another-one", "-bad", 42 as never] } }); expect(manifest.settings.autoCloseExemptLogins).toEqual(["Trusted-Regular", "another-one"]); // invalid entries dropped diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 41ff6fdff4..11b4edf342 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -9,6 +9,7 @@ import { createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getAppInstallation, + getGithubUserCreatedAt, getInstallationId, getRepositoryCollaboratorPermission, isCacheableGithubUrl, @@ -627,6 +628,39 @@ describe("GitHub check runs", () => { ).rejects.toThrow(/Failed to fetch GitHub collaborator permission/); }); + it("getGithubUserCreatedAt fetches the account creation date, and fails OPEN (null) on any error (#2561)", async () => { + const privateKey = await generatePrivateKeyPem(); + await expect(getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "")).resolves.toBeNull(); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/users/newbie")) return Response.json({ login: "newbie", created_at: "2026-06-01T00:00:00Z" }); + if (url.includes("/users/missing-field")) return Response.json({ login: "missing-field" }); + if (url.includes("/users/malformed-field")) return Response.json({ login: "malformed-field", created_at: 12345 }); + if (url.includes("/users/not-found")) return new Response("not found", { status: 404 }); + if (url.includes("/users/network-error")) throw new Error("network down"); + return new Response("not found", { status: 404 }); + }); + + await expect( + getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "newbie"), + ).resolves.toBe("2026-06-01T00:00:00Z"); + await expect( + getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "missing-field"), + ).resolves.toBeNull(); + // Gate finding (#2561): a malformed (non-string) created_at must fail open, not be coerced by Date.parse. + await expect( + getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "malformed-field"), + ).resolves.toBeNull(); + await expect( + getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "not-found"), + ).resolves.toBeNull(); + await expect( + getGithubUserCreatedAt(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123, "network-error"), + ).resolves.toBeNull(); + }); + it("updates an existing Gittensory check run for the same head SHA", async () => { const privateKey = await generatePrivateKeyPem(); const methods: string[] = []; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 8cdc1a077a..aaf2139318 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6611,6 +6611,269 @@ describe("queue processors", () => { expect(seen.closed).toBe(true); }); + function stubAccountAgeFetch(prNumber: 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 === "https://api.gittensor.io/miners") return Response.json([]); + 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.includes(`/pulls/${prNumber}/files`)) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes(`/pulls/${prNumber}/reviews`)) return Response.json([]); + if (url.includes(`/pulls/${prNumber}/commits`)) return Response.json([]); + if (url.endsWith(`/pulls/${prNumber}`) && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: prNumber, state: "closed" }); } + if (url.endsWith(`/pulls/${prNumber}`)) return Response.json({ number: prNumber, state: "open", user: { login: "newbie" }, head: { sha: `s${prNumber}` }, mergeable_state: "clean" }); + if (url.includes(`/commits/s${prNumber}/check-runs`)) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes(`/commits/s${prNumber}/status`)) return Response.json({ state: "success", statuses: [] }); + if (url.includes(`/issues/${prNumber}/labels`) && method === "GET") return Response.json([]); + if (url.includes(`/issues/${prNumber}/labels`) && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.endsWith("/labels") && method === "POST") return Response.json({ name: "x" }, { status: 201 }); + if (url.includes(`/issues/${prNumber}/comments`)) return Response.json([]); + return Response.json({}); + }; + } + + it("account-age throttle (#2561): a below-threshold-age account gets the new-account label AND a tighter effective 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", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + // Two pre-existing open PRs from the same new author — a cap of 4 (tightened to 2 for a new account) + // means the 3rd PR is already over the tightened cap, even though it's well under the CONFIGURED cap of 4. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Newbie PR one", state: "open", user: { login: "newbie" }, head: { sha: "s60" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Newbie PR two", state: "open", user: { login: "newbie" }, head: { sha: "s61" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 4, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + // Account created 2 days ago — well under the 30-day threshold. + vi.stubGlobal("fetch", stubAccountAgeFetch(62, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-tighter-cap", + eventName: "pull_request", + 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" } }, + pull_request: { number: 62, title: "Newbie's 3rd PR", state: "open", user: { login: "newbie" }, head: { sha: "s62" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).toContain("new-account"); + // The tightened cap (ceil(4/2)=2) is already exceeded by the 3rd PR — closed despite being under the raw cap of 4. + expect(seen.closed).toBe(true); + }); + + it("account-age throttle (#2561): an account OLDER than the threshold is unaffected — no label, no cap 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", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 63, title: "Vet PR one", state: "open", user: { login: "newbie" }, head: { sha: "s63" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 64, title: "Vet PR two", state: "open", user: { login: "newbie" }, head: { sha: "s64" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 4, + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + // Account created 2 years ago — well over the 30-day threshold. + vi.stubGlobal("fetch", stubAccountAgeFetch(65, new Date(Date.now() - 730 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-unaffected", + eventName: "pull_request", + 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" } }, + pull_request: { number: 65, title: "Vet's 3rd PR", state: "open", user: { login: "newbie" }, head: { sha: "s65" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + // The RAW cap (4) is not yet exceeded by a 3rd PR — untouched. + expect(seen.closed).toBe(false); + }); + + it("account-age throttle (#2561): the repo OWNER's own PR 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", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + 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 === "https://api.gittensor.io/miners") return Response.json([]); + 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("/pulls/66/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/66/reviews")) return Response.json([]); + if (url.includes("/pulls/66/commits")) return Response.json([]); + if (url.endsWith("/pulls/66")) return Response.json({ number: 66, state: "open", user: { login: "JSONbored" }, head: { sha: "s66" }, mergeable_state: "clean" }); + if (url.includes("/commits/s66/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/s66/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/66/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/66/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/66/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-owner-exempt", + eventName: "pull_request", + 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" } }, + pull_request: { number: 66, title: "Owner's own PR", state: "open", user: { login: "JSONbored" }, head: { sha: "s66" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).not.toContain("new-account"); + }); + + it("account-age throttle (#2561): disabled (no threshold configured, the default) never fetches the GitHub user or labels", 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", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + autonomy: { close: "auto", label: "auto" }, + // accountAgeThresholdDays intentionally omitted — off by default. + }); + const seen = { labels: [] as string[], accountAgeUsersFetched: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + // Distinct from the UNRELATED, always-on public-contributor-profile lookup (src/github/public.ts), which + // hits this same bare /users/{login} URL but with NO authorization header (GITHUB_PUBLIC_TOKEN unset in + // this test) — only getGithubUserCreatedAt's account-age-specific call sends a Bearer installation token. + if (url.includes("/users/") && (init?.headers as Record | undefined)?.authorization) { + seen.accountAgeUsersFetched = true; + return Response.json({ login: "newbie", created_at: new Date().toISOString() }); + } + if (url.includes("/users/")) return Response.json({ login: "newbie" }); + if (url.includes("/pulls/67/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/67/reviews")) return Response.json([]); + if (url.includes("/pulls/67/commits")) return Response.json([]); + if (url.endsWith("/pulls/67")) return Response.json({ number: 67, state: "open", user: { login: "newbie" }, head: { sha: "s67" }, mergeable_state: "clean" }); + if (url.includes("/commits/s67/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/s67/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/67/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/67/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/67/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-disabled", + eventName: "pull_request", + 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" } }, + pull_request: { number: 67, title: "Newbie's PR", state: "open", user: { login: "newbie" }, head: { sha: "s67" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.accountAgeUsersFetched).toBe(false); + expect(seen.labels).not.toContain("new-account"); + }); + + it("account-age throttle (#2561): 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", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + autonomy: { close: "auto", label: "auto" }, + accountAgeThresholdDays: 30, + newAccountLabel: "custom-new-account-label", + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubAccountAgeFetch(68, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-custom-label", + eventName: "pull_request", + 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" } }, + pull_request: { number: 68, title: "Newbie's PR", state: "open", user: { login: "newbie" }, head: { sha: "s68" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.labels).toContain("custom-new-account-label"); + expect(seen.labels).not.toContain("new-account"); + }); + + it("account-age throttle (#2561): a below-threshold account is NOT labeled when the repo has not opted into label autonomy (regression, gate finding)", 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", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + gateCheckMode: "enabled", + // autonomy intentionally omitted — deny-by-default ("observe" for every action class, including "label"). + accountAgeThresholdDays: 30, + }); + const seen = { labels: [] as string[], closed: false }; + vi.stubGlobal("fetch", stubAccountAgeFetch(69, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen)); + + await processJob(env, { + type: "github-webhook", + deliveryId: "account-age-label-not-autonomous", + eventName: "pull_request", + 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" } }, + pull_request: { number: 69, title: "Newbie's PR", state: "open", user: { login: "newbie" }, head: { sha: "s69" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + 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),