diff --git a/migrations/0076_close_owner_authors.sql b/migrations/0076_close_owner_authors.sql new file mode 100644 index 0000000000..c9e9d618d7 --- /dev/null +++ b/migrations/0076_close_owner_authors.sql @@ -0,0 +1,4 @@ +-- Per-repo toggle: allow auto-closing the repo OWNER's/maintainer's own PRs (default 0 = exempt, the prior +-- hardwired behavior — owner PRs merge or hold for manual review, never auto-close). Configurable so maintainers +-- aren't locked into one opinion. +ALTER TABLE repository_settings ADD COLUMN close_owner_authors INTEGER NOT NULL DEFAULT 0; diff --git a/src/api/routes.ts b/src/api/routes.ts index 21d3ca5e3a..3db0c2aa8b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -2258,6 +2258,7 @@ export function createApp() { aiReviewProvider: updated.aiReviewProvider ?? null, aiReviewModel: updated.aiReviewModel ?? null, aiReviewAllAuthors: updated.aiReviewAllAuthors, + closeOwnerAuthors: updated.closeOwnerAuthors, }); }); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index a2772045fb..72ba12e582 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -432,6 +432,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: false, + closeOwnerAuthors: false, autoLabelEnabled: true, gittensorLabel: "gittensor", blacklistLabel: "slop", @@ -475,6 +476,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider), aiReviewModel: row.aiReviewModel ?? null, aiReviewAllAuthors: row.aiReviewAllAuthors, + closeOwnerAuthors: row.closeOwnerAuthors, autoLabelEnabled: row.autoLabelEnabled, gittensorLabel: row.gittensorLabel, blacklistLabel: row.blacklistLabel, @@ -522,6 +524,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial detail.name), ciRequiredContextsVerified: hasVerifiedRequiredContexts(requiredContexts), diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index daf0e30fc0..4577418a81 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -92,6 +92,10 @@ export type AgentActionPlanInput = { // accumulator like automation/readme-refresh, or dependabot/renovate). These are NEVER auto-closed — a noise // heuristic (duplicate/slop) must not kill a recurring maintainer-managed PR. They may still auto-merge. authorIsAutomationBot: boolean; + // Per-repo toggle (#configurable-owner-close): when TRUE, the repo OWNER's own PRs are eligible for auto-close + // like a contributor's (still gated by the `close` autonomy class + adverse-signal conditions). Default/undefined + // ⇒ owner PRs are exempt (merge or manual-hold only). Automation-bot PRs stay exempt regardless. + closeOwnerAuthors?: boolean | undefined; // Live CI aggregate over ALL of the PR's checks — required OR not, including non-required ones like // codecov/patch and every commit-status (reviewbot parity). "passed" = every check completed and none // failed; "failed" = at least one check failed; "pending" = at least one check still running; "unverified" @@ -326,6 +330,11 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne const ciUnverified = input.ciState === "unverified"; const reviewGood = gatePassing && ciPassed; const isContributor = !input.authorIsOwner && !input.authorIsAutomationBot; + // The owner-close exemption is PER-REPO CONFIGURABLE (#configurable-owner-close): by default the repo owner's + // own PRs are exempt from auto-close (closeOwnerAuthors !== true ⇒ merge or manual-hold only), but a maintainer + // can opt in to closing them like a contributor's. Automation bots stay exempt regardless (a noise heuristic + // must not kill a recurring maintainer-managed accumulator). + const closeEligible = isContributor || (input.authorIsOwner && input.closeOwnerAuthors === true); const mergeableClean = input.pr.mergeableState === "clean"; const isConflict = input.pr.mergeableState === "dirty"; // conflicts with base — can't merge as-is // RC3: a prior merge attempt failed terminally for THIS exact head SHA (403/405/409/conflict) → never re-plan @@ -351,7 +360,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne // have folded in optional / third-party checks and must keep the hard-guardrail manual hold. // (Rebase-if-behind already ran above, so a red CI here is on the latest base — not a stale-base artifact.) (#ci-fail-closes-guarded) const redVerifiedRequiredCi = ciFailed && input.ciRequiredContextsVerified === true; - const willClose = isContributor && acting("close") && (redVerifiedRequiredCi || (!guardrailHit && (ciFailed || conclusion === "failure" || isConflict))); + const willClose = closeEligible && acting("close") && (redVerifiedRequiredCi || (!guardrailHit && (ciFailed || conclusion === "failure" || isConflict))); // Linked-issue HARD-RULE close (#linked-issue-hard-rules). A DETERMINISTIC verdict about the LINKED ISSUE // (owner-assigned / missing point-label / maintainer-only) — NOT an AI verdict, so there is no hallucination // to guard against: this close fires REGARDLESS of `guardrailHit`. It still only ever closes a CONTRIBUTOR @@ -360,7 +369,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne const linkedIssueHardRule = input.linkedIssueHardRule; // Base condition: a CONTRIBUTOR PR links an issue tripping a deterministic hard rule AND the `close` autonomy // class is acting. (The owner/automation exemption lives in `isContributor`.) - const linkedIssueViolated = linkedIssueHardRule?.violated === true && isContributor && acting("close"); + const linkedIssueViolated = linkedIssueHardRule?.violated === true && closeEligible && acting("close"); // Flag-then-close double-check (#linked-issue-verify-before-close). Default behavior when the caller doesn't // pass the config is IMMEDIATE close (back-compat). When verifyBeforeClose is on, the close is a TWO-PASS // label-state machine: Pass 1 flags (adds the pending-closure label + a warning comment) and Pass 2 — the next diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 376acc2b7b..53ad21103a 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -79,6 +79,7 @@ export type FocusManifestSettings = Partial< | "aiReviewProvider" | "aiReviewModel" | "aiReviewAllAuthors" + | "closeOwnerAuthors" | "autoLabelEnabled" | "gittensorLabel" | "createMissingLabel" @@ -551,7 +552,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (blacklistLabel !== null) out.blacklistLabel = blacklistLabel; const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); if (publicSurface !== null) out.publicSurface = publicSurface; - for (const key of ["aiReviewByok", "aiReviewAllAuthors", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { + for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } diff --git a/src/types.ts b/src/types.ts index 14c840f9d0..8352cd5cf2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -548,6 +548,11 @@ export type RepositorySettings = { * AI themselves. Default false — opt-in via `.gittensory.yml gate.aiReview.allAuthors`. Independent of * `aiReviewMode`: `off` still means no AI; this only widens WHO an enabled review covers. */ aiReviewAllAuthors: boolean; + /** When TRUE, the repo OWNER's (and maintainer's) own PRs are eligible for auto-CLOSE like a contributor's + * (still subject to the `close` autonomy class + the same adverse-signal conditions). Default FALSE — owner + * PRs are exempt from auto-close (merge or manual-hold only). Per-repo configurable so maintainers choose + * rather than inheriting a hardwired opinion. */ + closeOwnerAuthors: boolean; autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts index a293aa3241..1649ec3829 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -371,6 +371,16 @@ describe("planAgentMaintenanceActions (#778)", () => { const plan = classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, authorIsOwner: true, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } }))); expect(plan).toContain("merge"); }); + + it("DOES auto-close a failing owner PR when closeOwnerAuthors is enabled (per-repo opt-in)", () => { + const plan = classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], authorIsOwner: true, closeOwnerAuthors: true, ciState: "passed", pr: { labels: [], slopRisk: 95 } }))); + expect(plan).toContain("close"); + }); + + it("still does NOT close an AUTOMATION-bot PR even when closeOwnerAuthors is enabled (bots stay exempt)", () => { + const plan = classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], authorIsOwner: false, authorIsAutomationBot: true, closeOwnerAuthors: true, ciState: "passed", pr: { labels: [], slopRisk: 95 } }))); + expect(plan).not.toContain("close"); + }); }); describe("automation-bot guard: never auto-close maintainer-managed accumulator/dependency PRs", () => { diff --git a/test/unit/ai-review-advisory.test.ts b/test/unit/ai-review-advisory.test.ts index 538576d0dc..2ee3a9a9cf 100644 --- a/test/unit/ai-review-advisory.test.ts +++ b/test/unit/ai-review-advisory.test.ts @@ -136,7 +136,7 @@ describe("runAiReviewForAdvisory", () => { // is what lets it through — only the new flag. const adv = advisory(); const result = await runAiReviewForAdvisory(aiEnv(async () => ({ response: notesOnlyJson() })), { - settings: { aiReviewMode: "advisory", gatePack: "gittensor", aiReviewAllAuthors: true } as RepositorySettings, + settings: { aiReviewMode: "advisory", gatePack: "gittensor", aiReviewAllAuthors: true , closeOwnerAuthors: false} as RepositorySettings, advisory: adv, repoFullName: "acme/widgets", pr, diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index b64fae15ea..8798d7f09b 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -924,12 +924,12 @@ describe("parseFocusManifest gate config", () => { expect((gateConfigToJson(m.gate) as { aiReview: { allAuthors: boolean } }).aiReview.allAuthors).toBe(true); expect(parseFocusManifest({ gate: gateConfigToJson(m.gate) }).gate).toEqual(m.gate); // round-trips expect(parseFocusManifest({ gate: { aiReview: { allAuthors: "yes" } } }).warnings.some((w) => /gate\.aiReview\.allAuthors/.test(w))).toBe(true); - const eff = resolveEffectiveSettings({ aiReviewAllAuthors: false } as unknown as RepositorySettings, m); + const eff = resolveEffectiveSettings({ aiReviewAllAuthors: false , closeOwnerAuthors: false} as unknown as RepositorySettings, m); expect(eff.aiReviewAllAuthors).toBe(true); // Absent ⇒ null ⇒ the gate alias leaves the DB value untouched. const noFlag = parseFocusManifest({ gate: { aiReview: { mode: "advisory" } } }); expect(noFlag.gate.aiReviewAllAuthors).toBeNull(); - expect(resolveEffectiveSettings({ aiReviewAllAuthors: true } as unknown as RepositorySettings, noFlag).aiReviewAllAuthors).toBe(true); + expect(resolveEffectiveSettings({ aiReviewAllAuthors: true , closeOwnerAuthors: false} as unknown as RepositorySettings, noFlag).aiReviewAllAuthors).toBe(true); }); it("parses the features: block (per-repo converged-feature toggles), round-trips it, and makes the manifest present", () => { @@ -951,9 +951,9 @@ describe("parseFocusManifest gate config", () => { }); it("parses aiReviewAllAuthors from the settings: block (generic override)", () => { - const parsed = parseFocusManifest({ settings: { aiReviewAllAuthors: true } }); + const parsed = parseFocusManifest({ settings: { aiReviewAllAuthors: true , closeOwnerAuthors: false} }); expect(parsed.settings.aiReviewAllAuthors).toBe(true); - expect(resolveEffectiveSettings({ aiReviewAllAuthors: false } as unknown as RepositorySettings, parsed).aiReviewAllAuthors).toBe(true); + expect(resolveEffectiveSettings({ aiReviewAllAuthors: false , closeOwnerAuthors: false} as unknown as RepositorySettings, parsed).aiReviewAllAuthors).toBe(true); }); it("parses gate.aiReview provider + model (config-as-code) and rejects an unknown provider", () => { diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index 4667e61089..8384f6199b 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -49,7 +49,7 @@ function settings(overrides: Partial = {}): RepositorySettin privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, ...overrides, }; } diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index f52390b57d..2818d8b34c 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -83,7 +83,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, ...overrides, }; } diff --git a/test/unit/repository-settings-enforcement.test.ts b/test/unit/repository-settings-enforcement.test.ts index 1db2943a88..e0071bdcb3 100644 --- a/test/unit/repository-settings-enforcement.test.ts +++ b/test/unit/repository-settings-enforcement.test.ts @@ -37,7 +37,7 @@ function settings(over: Partial = {}): RepositorySettings { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, aiReviewProvider: null, aiReviewModel: null, ...over, diff --git a/test/unit/routes-ai-byok.test.ts b/test/unit/routes-ai-byok.test.ts index 894872208a..ca75d028ec 100644 --- a/test/unit/routes-ai-byok.test.ts +++ b/test/unit/routes-ai-byok.test.ts @@ -39,7 +39,7 @@ describe("maintainer AI-review config route", () => { env, ); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ aiReviewMode: "block", aiReviewByok: true, aiReviewProvider: "anthropic", aiReviewModel: "claude-3-5-sonnet-latest", aiReviewAllAuthors: true }); + expect(await res.json()).toMatchObject({ aiReviewMode: "block", aiReviewByok: true, aiReviewProvider: "anthropic", aiReviewModel: "claude-3-5-sonnet-latest", aiReviewAllAuthors: true , closeOwnerAuthors: false}); const settings = await getRepositorySettings(env, REPO); expect(settings.aiReviewMode).toBe("block"); expect(settings.aiReviewAllAuthors).toBe(true); // persisted + read back (DB column round-trip) diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index 8d69ab6d81..039e0fd8c9 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -76,7 +76,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, ...overrides, }; } diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 74258f5a51..b55a3b20b2 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1851,7 +1851,7 @@ function repoSettings(repoFullName: string): RepositorySettings { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }; } diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index b9b58fc1bb..3e0a260fc5 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -1680,7 +1680,7 @@ describe("v2 signal builders", () => { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }, }); expect(comment).toContain("Author: `unknown`"); diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 693e07f2b7..a16ed1e508 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -408,7 +408,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }; const collisions = buildCollisionReport(repo.fullName, issues, pullRequests); const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); @@ -461,7 +461,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }; const collisions = buildCollisionReport(repo.fullName, issues, pullRequests); const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); @@ -534,7 +534,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }; const collisions = buildCollisionReport(repo.fullName, issues, pullRequests); const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); @@ -628,7 +628,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }; const undetected = detectGittensorContributor("newbie", currentPr, [currentPr], []); const cachedDetected = detectGittensorContributor("oktofeesh1", currentPr, [currentPr, { ...currentPr, number: 10, mergedAt: "2026-05-01T00:00:00.000Z" }], []); @@ -697,7 +697,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }; const comment = buildPublicPrIntelligenceComment({ repo, pr: currentPr, profile, detection, queueHealth, collisions, preflight, settings }); @@ -810,7 +810,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }, }); expect(publicPreflight.findings.map((finding) => finding.code)).toContain("linked_issue_bounty_historical"); diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts index 2baf391440..6913f9b96c 100644 --- a/test/unit/unified-comment-parity.test.ts +++ b/test/unit/unified-comment-parity.test.ts @@ -73,7 +73,7 @@ const settings: RepositorySettings = { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, - aiReviewAllAuthors: false, + aiReviewAllAuthors: false, closeOwnerAuthors: false, }; function buildFixtures() {