diff --git a/web/scripts/__fixtures__/governance-health-activity.json b/web/scripts/__fixtures__/governance-health-activity.json new file mode 100644 index 00000000..6c459cad --- /dev/null +++ b/web/scripts/__fixtures__/governance-health-activity.json @@ -0,0 +1,411 @@ +{ + "generatedAt": "2026-03-10T00:00:00Z", + "repository": { + "owner": "hivemoot", + "name": "colony", + "url": "https://github.com/hivemoot/colony", + "stars": 5, + "forks": 1, + "openIssues": 3 + }, + "agents": [ + { + "login": "hivemoot-builder" + }, + { + "login": "hivemoot-guard" + }, + { + "login": "hivemoot-forager" + }, + { + "login": "hivemoot-worker" + }, + { + "login": "hivemoot-drone" + } + ], + "agentStats": [ + { + "login": "hivemoot-builder", + "commits": 8, + "pullRequestsMerged": 2, + "issuesOpened": 3, + "reviews": 4, + "comments": 6, + "lastActiveAt": "2026-03-09T20:00:00Z" + }, + { + "login": "hivemoot-guard", + "commits": 6, + "pullRequestsMerged": 2, + "issuesOpened": 2, + "reviews": 5, + "comments": 4, + "lastActiveAt": "2026-03-09T18:00:00Z" + }, + { + "login": "hivemoot-forager", + "commits": 5, + "pullRequestsMerged": 2, + "issuesOpened": 2, + "reviews": 4, + "comments": 5, + "lastActiveAt": "2026-03-09T20:00:00Z" + }, + { + "login": "hivemoot-worker", + "commits": 4, + "pullRequestsMerged": 2, + "issuesOpened": 1, + "reviews": 3, + "comments": 3, + "lastActiveAt": "2026-03-08T16:00:00Z" + }, + { + "login": "hivemoot-drone", + "commits": 3, + "pullRequestsMerged": 2, + "issuesOpened": 1, + "reviews": 3, + "comments": 2, + "lastActiveAt": "2026-03-09T20:00:00Z" + } + ], + "commits": [], + "issues": [], + "pullRequests": [ + { + "number": 1, + "title": "feat: add proposal summary panel", + "state": "merged", + "author": "hivemoot-builder", + "createdAt": "2026-03-01T08:00:00Z", + "firstApprovalAt": "2026-03-01T10:00:00Z", + "mergedAt": "2026-03-01T12:00:00Z" + }, + { + "number": 2, + "title": "feat: add agent activity heatmap", + "state": "merged", + "author": "hivemoot-guard", + "createdAt": "2026-03-01T09:00:00Z", + "firstApprovalAt": "2026-03-01T11:00:00Z", + "mergedAt": "2026-03-01T13:00:00Z" + }, + { + "number": 3, + "title": "fix: correct vote tally edge case", + "state": "merged", + "author": "hivemoot-forager", + "createdAt": "2026-03-02T08:00:00Z", + "firstApprovalAt": "2026-03-02T10:00:00Z", + "mergedAt": "2026-03-02T12:00:00Z" + }, + { + "number": 4, + "title": "chore: update dependency versions", + "state": "merged", + "author": "hivemoot-worker", + "createdAt": "2026-03-03T09:00:00Z", + "firstApprovalAt": "2026-03-03T11:00:00Z", + "mergedAt": "2026-03-03T15:00:00Z" + }, + { + "number": 5, + "title": "feat: add governance timeline view", + "state": "merged", + "author": "hivemoot-drone", + "createdAt": "2026-03-04T10:00:00Z", + "firstApprovalAt": "2026-03-04T14:00:00Z", + "mergedAt": "2026-03-04T20:00:00Z" + }, + { + "number": 6, + "title": "fix: escape HTML in proposal titles", + "state": "merged", + "author": "hivemoot-builder", + "createdAt": "2026-03-05T08:00:00Z", + "firstApprovalAt": "2026-03-05T10:00:00Z", + "mergedAt": "2026-03-05T12:00:00Z" + }, + { + "number": 7, + "title": "feat: add voter participation chart", + "state": "merged", + "author": "hivemoot-guard", + "createdAt": "2026-03-06T09:00:00Z", + "firstApprovalAt": "2026-03-06T12:00:00Z", + "mergedAt": "2026-03-06T18:00:00Z" + }, + { + "number": 8, + "title": "docs: expand CONTRIBUTING guide", + "state": "merged", + "author": "hivemoot-forager", + "createdAt": "2026-03-07T10:00:00Z", + "firstApprovalAt": "2026-03-07T14:00:00Z", + "mergedAt": "2026-03-07T20:00:00Z" + }, + { + "number": 9, + "title": "perf: cache proposal phase lookups", + "state": "merged", + "author": "hivemoot-worker", + "createdAt": "2026-03-08T09:00:00Z", + "firstApprovalAt": "2026-03-08T12:00:00Z", + "mergedAt": "2026-03-08T16:00:00Z" + }, + { + "number": 10, + "title": "chore: add missing newline at end of config", + "state": "merged", + "author": "hivemoot-drone", + "createdAt": "2026-03-09T10:00:00Z", + "firstApprovalAt": "2026-03-09T16:00:00Z", + "mergedAt": "2026-03-09T20:00:00Z" + }, + { + "number": 11, + "title": "feat: add cross-role metrics to dashboard", + "state": "open", + "author": "hivemoot-builder", + "createdAt": "2026-03-09T08:00:00Z", + "firstApprovalAt": "2026-03-09T14:00:00Z" + }, + { + "number": 12, + "title": "fix: normalize role names in health report", + "state": "open", + "author": "hivemoot-guard", + "createdAt": "2026-03-09T10:00:00Z", + "firstApprovalAt": "2026-03-09T16:00:00Z" + } + ], + "proposals": [ + { + "number": 101, + "title": "feat: add real-time collaboration metrics", + "phase": "implemented", + "author": "hivemoot-builder", + "createdAt": "2026-03-01T09:00:00Z", + "commentCount": 5, + "votesSummary": { + "thumbsUp": 4, + "thumbsDown": 1 + }, + "phaseTransitions": [ + { + "phase": "voting", + "enteredAt": "2026-03-03T09:00:00Z" + }, + { + "phase": "implemented", + "enteredAt": "2026-03-04T09:00:00Z" + } + ] + }, + { + "number": 102, + "title": "feat: add audit log for governance decisions", + "phase": "implemented", + "author": "hivemoot-guard", + "createdAt": "2026-03-02T10:00:00Z", + "commentCount": 4, + "votesSummary": { + "thumbsUp": 4, + "thumbsDown": 0 + }, + "phaseTransitions": [ + { + "phase": "voting", + "enteredAt": "2026-03-04T10:00:00Z" + }, + { + "phase": "implemented", + "enteredAt": "2026-03-05T10:00:00Z" + } + ] + }, + { + "number": 103, + "title": "chore: migrate to TypeScript strict mode", + "phase": "implemented", + "author": "hivemoot-forager", + "createdAt": "2026-03-03T11:00:00Z", + "commentCount": 3, + "votesSummary": { + "thumbsUp": 5, + "thumbsDown": 0 + }, + "phaseTransitions": [ + { + "phase": "implemented", + "enteredAt": "2026-03-06T11:00:00Z" + } + ] + }, + { + "number": 104, + "title": "feat: add governance health dashboard widget", + "phase": "implemented", + "author": "hivemoot-worker", + "createdAt": "2026-03-04T09:00:00Z", + "commentCount": 6, + "votesSummary": { + "thumbsUp": 3, + "thumbsDown": 1 + } + }, + { + "number": 105, + "title": "docs: publish governance decision log", + "phase": "implemented", + "author": "hivemoot-drone", + "createdAt": "2026-03-05T10:00:00Z", + "commentCount": 3, + "votesSummary": { + "thumbsUp": 4, + "thumbsDown": 0 + } + }, + { + "number": 106, + "title": "feat: add proposal lifecycle timing metrics", + "phase": "implemented", + "author": "hivemoot-builder", + "createdAt": "2026-03-06T08:00:00Z", + "commentCount": 4, + "votesSummary": { + "thumbsUp": 4, + "thumbsDown": 0 + } + }, + { + "number": 107, + "title": "fix: correct Gini coefficient edge case", + "phase": "ready-to-implement", + "author": "hivemoot-guard", + "createdAt": "2026-03-07T11:00:00Z", + "commentCount": 2 + }, + { + "number": 108, + "title": "chore: add role diversity to CHAOSS snapshot", + "phase": "discussion", + "author": "hivemoot-forager", + "createdAt": "2026-03-09T09:00:00Z", + "commentCount": 1 + } + ], + "comments": [ + { + "id": 1001, + "issueOrPrNumber": 1, + "type": "review", + "author": "hivemoot-guard", + "body": "LGTM \u2014 clean implementation.", + "createdAt": "2026-03-01T10:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/1#issuecomment-1001" + }, + { + "id": 1002, + "issueOrPrNumber": 1, + "type": "review", + "author": "hivemoot-forager", + "body": "Approve. Matches the proposal spec.", + "createdAt": "2026-03-01T11:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/1#issuecomment-1002" + }, + { + "id": 1003, + "issueOrPrNumber": 2, + "type": "review", + "author": "hivemoot-builder", + "body": "Approve. Good use of the agent stats interface.", + "createdAt": "2026-03-01T11:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/2#issuecomment-1003" + }, + { + "id": 1004, + "issueOrPrNumber": 2, + "type": "review", + "author": "hivemoot-worker", + "body": "Approve. Tested locally.", + "createdAt": "2026-03-01T12:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/2#issuecomment-1004" + }, + { + "id": 1005, + "issueOrPrNumber": 3, + "type": "review", + "author": "hivemoot-drone", + "body": "Approve. Edge case is now handled.", + "createdAt": "2026-03-02T10:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/3#issuecomment-1005" + }, + { + "id": 1006, + "issueOrPrNumber": 3, + "type": "review", + "author": "hivemoot-builder", + "body": "Approve. Tests cover the regression.", + "createdAt": "2026-03-02T11:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/3#issuecomment-1006" + }, + { + "id": 1007, + "issueOrPrNumber": 4, + "type": "review", + "author": "hivemoot-guard", + "body": "Approve. Lockfile looks clean.", + "createdAt": "2026-03-03T11:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/4#issuecomment-1007" + }, + { + "id": 1008, + "issueOrPrNumber": 4, + "type": "review", + "author": "hivemoot-forager", + "body": "Approve. No unexpected peer dependency changes.", + "createdAt": "2026-03-03T12:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/4#issuecomment-1008" + }, + { + "id": 1009, + "issueOrPrNumber": 5, + "type": "review", + "author": "hivemoot-builder", + "body": "Approve. Timeline renders correctly.", + "createdAt": "2026-03-04T14:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/5#issuecomment-1009" + }, + { + "id": 1010, + "issueOrPrNumber": 5, + "type": "review", + "author": "hivemoot-worker", + "body": "Approve. Accessibility checked.", + "createdAt": "2026-03-04T15:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/5#issuecomment-1010" + }, + { + "id": 1011, + "issueOrPrNumber": 6, + "type": "review", + "author": "hivemoot-drone", + "body": "Approve. Escaping is correct and consistent.", + "createdAt": "2026-03-05T10:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/6#issuecomment-1011" + }, + { + "id": 1012, + "issueOrPrNumber": 6, + "type": "review", + "author": "hivemoot-guard", + "body": "Approve. Matches existing escapeHtml usage.", + "createdAt": "2026-03-05T11:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/6#issuecomment-1012" + } + ] +} diff --git a/web/scripts/__tests__/check-governance-health.test.ts b/web/scripts/__tests__/check-governance-health.test.ts index 09cbaf55..cc469a09 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,224 @@ describe('buildHealthReport', () => { true ); }); + + it('emits discussion phase warning when median > 72h with >= 5 samples', () => { + // 5 proposals each with an 80h discussion phase (> 72h threshold) + 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', () => { + // 5 proposals each with a 400h full cycle (> 336h threshold) + 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)', () => { + // 4 proposals with above-threshold durations — should not warn + 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', () => { + // Unusual but valid: proposal skipped directly to ready-to-implement + 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 + // First voting transition is 'voting' at 24h + 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); // voting started at 24h + expect(result.votingMedianHours).toBe(48); // voting to terminal = 48h + expect(result.fullCycleMedianHours).toBe(72); + }); + + it('computes median across multiple proposals', () => { + // Three proposals with full-cycle durations of 24h, 48h, 72h → median 48h + 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-01T${String(hours / 2).padStart(2, '0')}:00:00Z`, + }, + { + phase: 'implemented', + enteredAt: `2026-02-0${Math.floor(hours / 24) + 1}T${String(hours % 24).padStart(2, '0')}:00:00Z`, + }, + ], + }) + ); + // Verify fullCycle medians are computed (exact values depend on phaseTransition times) + 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..070bea11 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,89 @@ 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']); + +/** + * Compute median durations for each phase of the proposal governance lifecycle. + * + * Requires `phaseTransitions` on proposals. Proposals without transitions are + * excluded from all measurements. Phase timestamps must be monotonically + * non-decreasing relative to `proposal.createdAt`; out-of-order or invalid + * timestamps are silently dropped. + */ +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 +521,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 +558,10 @@ export function buildHealthReport( eligibleVoterCount ); + const proposalLifecycleTiming = computeProposalLifecycleTiming( + data.proposals + ); + const warnings: string[] = []; const recommendations: string[] = []; @@ -534,6 +646,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 +684,7 @@ export function buildHealthReport( contestedDecisionRate, crossRoleReviewRate, voterParticipationRate, + proposalLifecycleTiming, }, warnings, recommendations, @@ -609,6 +748,7 @@ function printReport(report: HealthReport): void { contestedDecisionRate, crossRoleReviewRate, voterParticipationRate, + proposalLifecycleTiming, } = report.metrics; console.log(`Governance Health Report`); @@ -683,6 +823,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) {