diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 410793dc..cc075141 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,9 +101,12 @@ The script evaluates open PRs against the approved #307 criteria: - at least 2 distinct approvals - CI status is `SUCCESS` - references at least one **open** linked issue (the issue must remain open at merge time) +- no `CHANGES_REQUESTED` reviews **Important:** If you close the linked issue before the PR merges, the PR becomes ineligible for fast-track. Keep the issue open until the PR is merged. +**High-approval waiver (#445):** A PR with 6 or more distinct approvals and no `CHANGES_REQUESTED` reviews qualifies for fast-track even without an open linked issue. This covers PRs where the governance process completed but the linked issue was closed prematurely. The script labels these with `[high-approval waiver]` in its output. + Use `npm run fast-track-candidates -- --json` for machine-readable output. ## External Outreach Metrics diff --git a/web/scripts/__tests__/fast-track-candidates.test.ts b/web/scripts/__tests__/fast-track-candidates.test.ts index c5d75ed8..8bbac02b 100644 --- a/web/scripts/__tests__/fast-track-candidates.test.ts +++ b/web/scripts/__tests__/fast-track-candidates.test.ts @@ -3,6 +3,8 @@ import { countDistinctApprovals, evaluateEligibility, hasAllowedPrefix, + hasChangesRequested, + HIGH_APPROVAL_WAIVER_THRESHOLD, isMergeReady, normalizeMergeStateStatus, } from '../fast-track-candidates'; @@ -20,6 +22,31 @@ describe('hasAllowedPrefix', () => { }); }); +describe('hasChangesRequested', () => { + it('returns true when any review is CHANGES_REQUESTED', () => { + expect( + hasChangesRequested([ + { state: 'APPROVED', author: { login: 'hivemoot-scout' } }, + { state: 'CHANGES_REQUESTED', author: { login: 'hivemoot-heater' } }, + ]) + ).toBe(true); + }); + + it('returns false when no reviews are CHANGES_REQUESTED', () => { + expect( + hasChangesRequested([ + { state: 'APPROVED', author: { login: 'hivemoot-scout' } }, + { state: 'COMMENTED', author: { login: 'hivemoot-builder' } }, + ]) + ).toBe(false); + }); + + it('returns false for empty or missing reviews', () => { + expect(hasChangesRequested([])).toBe(false); + expect(hasChangesRequested(undefined)).toBe(false); + }); +}); + describe('countDistinctApprovals', () => { it('counts unique approvers and ignores non-approved states', () => { expect( @@ -66,6 +93,7 @@ describe('evaluateEligibility', () => { expect(result.approvals).toBe(2); expect(result.ciState).toBe('SUCCESS'); expect(result.linkedOpenIssues).toEqual([307]); + expect(result.highApprovalWaiver).toBe(false); }); it('explains all failed criteria', () => { @@ -113,6 +141,72 @@ describe('evaluateEligibility', () => { ); }); + it('applies high-approval waiver when 6+ approvals and no linked open issue', () => { + const approvers = Array.from( + { length: HIGH_APPROVAL_WAIVER_THRESHOLD }, + (_, i) => ({ state: 'APPROVED', author: { login: `agent-${i}` } }) + ); + const result = evaluateEligibility({ + number: 105, + title: 'fix: long-standing bug with high quorum', + url: 'https://example.test/pr/105', + latestReviews: approvers, + statusCheckRollup: [{ status: 'COMPLETED', conclusion: 'SUCCESS' }], + closingIssuesReferences: [], + }); + + expect(result.eligible).toBe(true); + expect(result.highApprovalWaiver).toBe(true); + expect(result.reasons).toEqual([]); + }); + + it('does not apply waiver when 6+ approvals but CHANGES_REQUESTED present', () => { + const approvers = Array.from( + { length: HIGH_APPROVAL_WAIVER_THRESHOLD }, + (_, i) => ({ state: 'APPROVED', author: { login: `agent-${i}` } }) + ); + const result = evaluateEligibility({ + number: 106, + title: 'fix: high approvals but reviewer blocked', + url: 'https://example.test/pr/106', + latestReviews: [ + ...approvers, + { state: 'CHANGES_REQUESTED', author: { login: 'strict-reviewer' } }, + ], + statusCheckRollup: [{ status: 'COMPLETED', conclusion: 'SUCCESS' }], + closingIssuesReferences: [], + }); + + expect(result.eligible).toBe(false); + expect(result.highApprovalWaiver).toBe(false); + expect(result.reasons).toContain( + 'must reference at least one OPEN linked issue' + ); + expect(result.reasons).toContain( + 'cannot have a pending CHANGES_REQUESTED review' + ); + }); + + it('does not apply waiver when fewer than 6 approvals', () => { + const result = evaluateEligibility({ + number: 107, + title: 'fix: only 5 approvals', + url: 'https://example.test/pr/107', + latestReviews: Array.from({ length: 5 }, (_, i) => ({ + state: 'APPROVED', + author: { login: `agent-${i}` }, + })), + statusCheckRollup: [{ status: 'COMPLETED', conclusion: 'SUCCESS' }], + closingIssuesReferences: [], + }); + + expect(result.eligible).toBe(false); + expect(result.highApprovalWaiver).toBe(false); + expect(result.reasons).toContain( + 'must reference at least one OPEN linked issue' + ); + }); + it('does not treat same-number issues in other repos as open', () => { const result = evaluateEligibility( { diff --git a/web/scripts/fast-track-candidates.ts b/web/scripts/fast-track-candidates.ts index 647af151..fb10a6ff 100644 --- a/web/scripts/fast-track-candidates.ts +++ b/web/scripts/fast-track-candidates.ts @@ -54,6 +54,7 @@ export interface EligibilityResult { approvals: number; ciState: string; linkedOpenIssues: number[]; + highApprovalWaiver: boolean; } interface CandidateRecord { @@ -66,6 +67,7 @@ interface CandidateRecord { approvals: number; ciState: string; linkedOpenIssues: number[]; + highApprovalWaiver: boolean; } interface Report { @@ -132,6 +134,14 @@ export function hasAllowedPrefix(title: string): boolean { return FAST_TRACK_PREFIXES.some((prefix) => normalized.startsWith(prefix)); } +export function hasChangesRequested( + latestReviews: ReviewNode[] | undefined +): boolean { + return (latestReviews ?? []).some( + (review) => review.state === 'CHANGES_REQUESTED' + ); +} + export function countDistinctApprovals( latestReviews: ReviewNode[] | undefined ): number { @@ -200,6 +210,11 @@ function getLinkedOpenIssues( .sort((a, b) => a - b); } +// High-approval waiver threshold (Issue #445). +// PRs with this many distinct approvals and no CHANGES_REQUESTED reviews are +// eligible for fast-track even without an open linked issue. +export const HIGH_APPROVAL_WAIVER_THRESHOLD = 6; + export function evaluateEligibility( pr: PullRequestNode, issueStates: Map = new Map(), @@ -209,6 +224,7 @@ export function evaluateEligibility( const approvals = countDistinctApprovals(pr.latestReviews); const ciState = getCiState(pr); const linkedOpenIssues = getLinkedOpenIssues(pr, issueStates, repo); + const changesRequested = hasChangesRequested(pr.latestReviews); if (!hasAllowedPrefix(pr.title)) { reasons.push( @@ -224,10 +240,22 @@ export function evaluateEligibility( reasons.push(`CI checks must be SUCCESS (found ${ciState})`); } - if (linkedOpenIssues.length === 0) { + // Linked issue requirement, with high-approval waiver. + // A PR with 6+ distinct approvals and no CHANGES_REQUESTED reviews is + // eligible even without an open linked issue — the quorum signal from + // multiple reviewers across multiple sessions provides equivalent governance + // assurance (#445). + const highApprovalWaiver = + approvals >= HIGH_APPROVAL_WAIVER_THRESHOLD && !changesRequested; + + if (linkedOpenIssues.length === 0 && !highApprovalWaiver) { reasons.push('must reference at least one OPEN linked issue'); } + if (changesRequested) { + reasons.push('cannot have a pending CHANGES_REQUESTED review'); + } + if (hasThumbsDownVeto(pr.reactionGroups)) { reasons.push('cannot have a 👎 veto reaction on the PR'); } @@ -238,6 +266,7 @@ export function evaluateEligibility( approvals, ciState, linkedOpenIssues, + highApprovalWaiver, }; } @@ -385,6 +414,7 @@ function buildReport(prs: PullRequestNode[], repo: string): Report { approvals: evaluation.approvals, ciState: evaluation.ciState, linkedOpenIssues: evaluation.linkedOpenIssues, + highApprovalWaiver: evaluation.highApprovalWaiver, }; }); @@ -421,9 +451,11 @@ function printHumanReport(report: Report): void { } else { console.log('Eligible PRs:'); for (const pr of eligible) { - const linked = pr.linkedOpenIssues.map((num) => `#${num}`).join(', '); + const linked = + pr.linkedOpenIssues.map((num) => `#${num}`).join(', ') || 'none'; + const waiver = pr.highApprovalWaiver ? ' [high-approval waiver]' : ''; console.log( - `- #${pr.number} (${pr.approvals} approvals, CI ${pr.ciState}, merge ${pr.mergeStateStatus}, linked ${linked}) ${pr.url}` + `- #${pr.number} (${pr.approvals} approvals, CI ${pr.ciState}, merge ${pr.mergeStateStatus}, linked ${linked})${waiver} ${pr.url}` ); } }