diff --git a/web/scripts/__tests__/check-governance-health.test.ts b/web/scripts/__tests__/check-governance-health.test.ts index 09cbaf55..17430d55 100644 --- a/web/scripts/__tests__/check-governance-health.test.ts +++ b/web/scripts/__tests__/check-governance-health.test.ts @@ -14,6 +14,7 @@ import { computeMergeLatency, computeContestedRate, computePrCycleTime, + computeProposalLifecycleTiming, computeReviewLatency, computeRoleDiversity, computeVoterParticipationRate, @@ -680,6 +681,7 @@ describe('buildHealthReport', () => { expect(report.metrics.contestedDecisionRate).toBeDefined(); expect(report.metrics.crossRoleReviewRate).toBeDefined(); expect(report.metrics.voterParticipationRate).toBeDefined(); + expect(report.metrics.proposalLifecycleTiming).toBeDefined(); expect(report.warnings).toBeInstanceOf(Array); expect(report.recommendations).toBeInstanceOf(Array); }); @@ -942,6 +944,217 @@ describe('buildHealthReport', () => { true ); }); + + it('emits discussion phase warning when median > 72h with >= 5 samples', () => { + const proposals = Array.from({ length: 5 }, (_, i) => + makeProposal({ + number: i + 1, + createdAt: '2026-02-01T00:00:00Z', + phase: 'implemented', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-04T08:00:00Z' }, // 80h discussion + { phase: 'implemented', enteredAt: '2026-02-05T08:00:00Z' }, + ], + }) + ); + const report = buildHealthReport(minimalData({ proposals })); + expect( + report.warnings.some((w) => w.includes('Discussion phase median')) + ).toBe(true); + const recommendation = report.recommendations.find((r) => + r.includes('hivemoot:discussion') + ); + expect(recommendation).toBeDefined(); + }); + + it('emits full-cycle warning when median > 336h with >= 5 samples', () => { + const proposals = Array.from({ length: 5 }, (_, i) => + makeProposal({ + number: i + 1, + createdAt: '2026-02-01T00:00:00Z', + phase: 'implemented', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-10T00:00:00Z' }, // 216h discussion + { + phase: 'implemented', + enteredAt: '2026-02-17T16:00:00Z', // 400h full cycle + }, + ], + }) + ); + const report = buildHealthReport(minimalData({ proposals })); + expect(report.warnings.some((w) => w.includes('Full-cycle median'))).toBe( + true + ); + const recommendation = report.recommendations.find((r) => + r.includes('hivemoot:voting,hivemoot:extended-voting') + ); + expect(recommendation).toBeDefined(); + }); + + it('does not emit lifecycle warnings when sample size is below minimum (< 5)', () => { + const proposals = Array.from({ length: 4 }, (_, i) => + makeProposal({ + number: i + 1, + createdAt: '2026-02-01T00:00:00Z', + phase: 'implemented', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-04T08:00:00Z' }, // 80h > 72h + { + phase: 'implemented', + enteredAt: '2026-02-17T16:00:00Z', // 400h > 336h + }, + ], + }) + ); + const report = buildHealthReport(minimalData({ proposals })); + expect( + report.warnings.some((w) => w.includes('Discussion phase median')) + ).toBe(false); + expect(report.warnings.some((w) => w.includes('Full-cycle median'))).toBe( + false + ); + }); +}); + +// ────────────────────────────────────────────── +// computeProposalLifecycleTiming +// ────────────────────────────────────────────── + +describe('computeProposalLifecycleTiming', () => { + it('returns all-null durations and sampleSize 0 for empty proposals', () => { + const result = computeProposalLifecycleTiming([]); + expect(result.discussionMedianHours).toBeNull(); + expect(result.votingMedianHours).toBeNull(); + expect(result.fullCycleMedianHours).toBeNull(); + expect(result.sampleSize).toBe(0); + }); + + it('returns all-null durations and sampleSize 0 when proposals have no phaseTransitions', () => { + const result = computeProposalLifecycleTiming([ + makeProposal({ phase: 'implemented' }), + makeProposal({ phase: 'ready-to-implement' }), + ]); + expect(result.discussionMedianHours).toBeNull(); + expect(result.votingMedianHours).toBeNull(); + expect(result.fullCycleMedianHours).toBeNull(); + expect(result.sampleSize).toBe(0); + }); + + it('computes all three durations for a fully resolved proposal', () => { + // discussion: 24h, voting: 48h, full cycle: 72h + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'ready-to-implement', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-02T00:00:00Z' }, + { phase: 'ready-to-implement', enteredAt: '2026-02-04T00:00:00Z' }, + ], + }), + ]); + expect(result.discussionMedianHours).toBe(24); + expect(result.votingMedianHours).toBe(48); + expect(result.fullCycleMedianHours).toBe(72); + expect(result.sampleSize).toBe(1); + }); + + it('computes discussion duration only when voting transition exists but no terminal', () => { + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'voting', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-02T00:00:00Z' }, // 24h of discussion + ], + }), + ]); + expect(result.discussionMedianHours).toBe(24); + expect(result.votingMedianHours).toBeNull(); + expect(result.fullCycleMedianHours).toBeNull(); + expect(result.sampleSize).toBe(1); + }); + + it('computes fullCycle only when terminal transition exists but no voting transition', () => { + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'ready-to-implement', + phaseTransitions: [ + { phase: 'ready-to-implement', enteredAt: '2026-02-03T00:00:00Z' }, + ], + }), + ]); + expect(result.discussionMedianHours).toBeNull(); + expect(result.votingMedianHours).toBeNull(); + expect(result.fullCycleMedianHours).toBe(48); + expect(result.sampleSize).toBe(1); + }); + + it('counts extended-voting as the start of voting phase', () => { + // extended-voting starts 48h in; terminal at 72h + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'ready-to-implement', + phaseTransitions: [ + { phase: 'extended-voting', enteredAt: '2026-02-03T00:00:00Z' }, + { phase: 'ready-to-implement', enteredAt: '2026-02-04T00:00:00Z' }, + ], + }), + ]); + expect(result.discussionMedianHours).toBe(48); + expect(result.votingMedianHours).toBe(24); + expect(result.fullCycleMedianHours).toBe(72); + }); + + it('uses voting phase over extended-voting when both present', () => { + // voting at 24h, extended-voting at 48h, terminal at 72h + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'ready-to-implement', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-02T00:00:00Z' }, + { phase: 'extended-voting', enteredAt: '2026-02-03T00:00:00Z' }, + { phase: 'ready-to-implement', enteredAt: '2026-02-04T00:00:00Z' }, + ], + }), + ]); + expect(result.discussionMedianHours).toBe(24); + expect(result.votingMedianHours).toBe(48); + expect(result.fullCycleMedianHours).toBe(72); + }); + + it('computes median across multiple proposals', () => { + const proposals = [24, 48, 72].map((hours, i) => + makeProposal({ + number: i + 1, + createdAt: '2026-02-01T00:00:00Z', + phase: 'implemented', + phaseTransitions: [ + { + phase: 'voting', + enteredAt: `2026-02-0${Math.floor(hours / 24) + 1}T00:00:00Z`, + }, + { + phase: 'implemented', + enteredAt: `2026-02-0${Math.floor(hours / 24) + 2}T00:00:00Z`, + }, + ], + }) + ); + const result = computeProposalLifecycleTiming(proposals); + expect(result.sampleSize).toBe(3); + expect(result.fullCycleMedianHours).not.toBeNull(); + }); + + it('excludes proposals with empty phaseTransitions array', () => { + const result = computeProposalLifecycleTiming([ + makeProposal({ phaseTransitions: [] }), + ]); + expect(result.sampleSize).toBe(0); + }); }); // ────────────────────────────────────────────── diff --git a/web/scripts/check-governance-health.ts b/web/scripts/check-governance-health.ts index 91a32932..f394dd2e 100644 --- a/web/scripts/check-governance-health.ts +++ b/web/scripts/check-governance-health.ts @@ -123,6 +123,23 @@ export interface VoterParticipationMetric { eligibleVoterCount: number; } +/** + * Median durations for each phase of the proposal governance lifecycle. + * + * Requires `phaseTransitions` on proposals. Proposals without transitions are + * excluded from the sample. Durations are in hours. + */ +export interface ProposalLifecycleTimingMetric { + /** Median hours proposals spent in the discussion phase before voting opened. Null if no data. */ + discussionMedianHours: number | null; + /** Median hours proposals spent in the voting phase (including extended-voting) before resolution. Null if no data. */ + votingMedianHours: number | null; + /** Median hours from proposal creation to reaching a terminal state (ready-to-implement, implemented, rejected, inconclusive). Null if no data. */ + fullCycleMedianHours: number | null; + /** Number of resolved proposals that contributed to at least one duration measurement. */ + sampleSize: number; +} + export interface HealthReport { generatedAt: string; /** Days spanned by the earliest to latest proposal */ @@ -136,6 +153,7 @@ export interface HealthReport { contestedDecisionRate: ContestedRateMetric; crossRoleReviewRate: CrossRoleReviewMetric; voterParticipationRate: VoterParticipationMetric; + proposalLifecycleTiming: ProposalLifecycleTimingMetric; }; /** Human-readable warnings for metrics outside healthy thresholds */ warnings: string[]; @@ -394,6 +412,81 @@ export function computeDataWindowDays(proposals: Proposal[]): number { return Math.max(0, Math.ceil((latest - earliest) / (1000 * 60 * 60 * 24))); } +const PROPOSAL_TERMINAL_PHASES = new Set([ + 'ready-to-implement', + 'implemented', + 'rejected', + 'inconclusive', +]); +const PROPOSAL_VOTING_PHASES = new Set(['voting', 'extended-voting']); + +export function computeProposalLifecycleTiming( + proposals: Proposal[] +): ProposalLifecycleTimingMetric { + const discussionDurations: number[] = []; + const votingDurations: number[] = []; + const fullCycleDurations: number[] = []; + let sampleSize = 0; + + for (const proposal of proposals) { + const transitions = proposal.phaseTransitions; + if (!transitions || transitions.length === 0) continue; + + const createdTime = new Date(proposal.createdAt).getTime(); + if (!Number.isFinite(createdTime)) continue; + + const votingTransition = transitions.find((t) => + PROPOSAL_VOTING_PHASES.has(t.phase) + ); + const terminalTransition = transitions.find((t) => + PROPOSAL_TERMINAL_PHASES.has(t.phase) + ); + + let contributed = false; + + if (votingTransition) { + const votingTime = new Date(votingTransition.enteredAt).getTime(); + if (Number.isFinite(votingTime) && votingTime >= createdTime) { + discussionDurations.push((votingTime - createdTime) / 3600000); + contributed = true; + + if (terminalTransition) { + const terminalTime = new Date(terminalTransition.enteredAt).getTime(); + if (Number.isFinite(terminalTime) && terminalTime >= votingTime) { + votingDurations.push((terminalTime - votingTime) / 3600000); + } + } + } + } + + if (terminalTransition) { + const terminalTime = new Date(terminalTransition.enteredAt).getTime(); + if (Number.isFinite(terminalTime) && terminalTime >= createdTime) { + fullCycleDurations.push((terminalTime - createdTime) / 3600000); + contributed = true; + } + } + + if (contributed) sampleSize++; + } + + return { + discussionMedianHours: percentile( + [...discussionDurations].sort((a, b) => a - b), + 50 + ), + votingMedianHours: percentile( + [...votingDurations].sort((a, b) => a - b), + 50 + ), + fullCycleMedianHours: percentile( + [...fullCycleDurations].sort((a, b) => a - b), + 50 + ), + sampleSize, + }; +} + // ────────────────────────────────────────────── // Warning thresholds (configurable via env) // ────────────────────────────────────────────── @@ -420,6 +513,13 @@ const VOTER_PARTICIPATION_WARN = Number( const VOTER_PARTICIPATION_MIN_SAMPLE = Number( process.env.GH_VOTER_PARTICIPATION_MIN_SAMPLE ?? '3' ); +const DISCUSSION_WARN_HOURS = Number( + process.env.GH_DISCUSSION_WARN_HOURS ?? '72' +); +const LIFECYCLE_WARN_HOURS = Number( + process.env.GH_LIFECYCLE_WARN_HOURS ?? '336' +); +const LIFECYCLE_MIN_SAMPLE = Number(process.env.GH_LIFECYCLE_MIN_SAMPLE ?? '5'); export function buildHealthReport( data: ActivityData, @@ -450,6 +550,10 @@ export function buildHealthReport( eligibleVoterCount ); + const proposalLifecycleTiming = computeProposalLifecycleTiming( + data.proposals + ); + const warnings: string[] = []; const recommendations: string[] = []; @@ -534,6 +638,32 @@ export function buildHealthReport( ); } + if ( + proposalLifecycleTiming.sampleSize >= LIFECYCLE_MIN_SAMPLE && + proposalLifecycleTiming.discussionMedianHours !== null && + proposalLifecycleTiming.discussionMedianHours > DISCUSSION_WARN_HOURS + ) { + warnings.push( + `Discussion phase median (${formatHours(proposalLifecycleTiming.discussionMedianHours)}) exceeds ${DISCUSSION_WARN_HOURS}h — proposals may be stalling before voting` + ); + recommendations.push( + `Check 'gh issue list --label hivemoot:discussion' for proposals spending an unusually long time in discussion before calling for a vote.` + ); + } + + if ( + proposalLifecycleTiming.sampleSize >= LIFECYCLE_MIN_SAMPLE && + proposalLifecycleTiming.fullCycleMedianHours !== null && + proposalLifecycleTiming.fullCycleMedianHours > LIFECYCLE_WARN_HOURS + ) { + warnings.push( + `Full-cycle median (${formatHours(proposalLifecycleTiming.fullCycleMedianHours)}) exceeds ${LIFECYCLE_WARN_HOURS}h — median proposal-to-resolution exceeds two weeks` + ); + recommendations.push( + `Review stalled proposals with 'gh issue list --label hivemoot:voting,hivemoot:extended-voting' to identify voting cycles that have been open longer than expected.` + ); + } + return { generatedAt: new Date().toISOString(), dataWindowDays: computeDataWindowDays(data.proposals), @@ -546,6 +676,7 @@ export function buildHealthReport( contestedDecisionRate, crossRoleReviewRate, voterParticipationRate, + proposalLifecycleTiming, }, warnings, recommendations, @@ -609,6 +740,7 @@ function printReport(report: HealthReport): void { contestedDecisionRate, crossRoleReviewRate, voterParticipationRate, + proposalLifecycleTiming, } = report.metrics; console.log(`Governance Health Report`); @@ -683,6 +815,21 @@ function printReport(report: HealthReport): void { ); console.log(''); + console.log('Proposal Lifecycle Timing'); + console.log( + ` discussion median: ${formatHours(proposalLifecycleTiming.discussionMedianHours)}` + ); + console.log( + ` voting median: ${formatHours(proposalLifecycleTiming.votingMedianHours)}` + ); + console.log( + ` full-cycle median: ${formatHours(proposalLifecycleTiming.fullCycleMedianHours)}` + ); + console.log( + ` sample: ${proposalLifecycleTiming.sampleSize} resolved proposals` + ); + console.log(''); + if (report.warnings.length > 0) { console.log('Warnings:'); for (const w of report.warnings) {