Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-rework-rate-metric.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-sdk": minor
---

Add rework rate OTEL metrics (#265) — tracks PR revision patterns as the emerging 5th DORA metric
1 change: 1 addition & 0 deletions packages/squad-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export * from './runtime/i18n.js';
export * from './runtime/benchmarks.js';
export * from './runtime/otel-init.js';
export * from './runtime/otel-metrics.js';
export * from './runtime/rework.js';
export { getMeter, getTracer } from './runtime/otel.js';
export { safeTimestamp } from './utils/safe-timestamp.js';
export { EventBus as RuntimeEventBus } from './runtime/event-bus.js';
Expand Down
70 changes: 70 additions & 0 deletions packages/squad-sdk/src/runtime/otel-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { getMeter } from './otel.js';
import type { UsageEvent } from './streaming.js';
import type { PrReworkResult, ReworkSummary } from './rework.js';

// ============================================================================
// #261 — Token Usage Metrics
Expand Down Expand Up @@ -251,6 +252,74 @@ export function recordTokensPerSecond(tokensPerSec: number): void {
m.tokensPerSecGauge.record(tokensPerSec);
}

// ============================================================================
// #265 — Rework Rate Metrics
// ============================================================================

interface ReworkMetrics {
rateGauge: ReturnType<ReturnType<typeof getMeter>['createGauge']>;
cyclesHistogram: ReturnType<ReturnType<typeof getMeter>['createHistogram']>;
rejectionRateGauge: ReturnType<ReturnType<typeof getMeter>['createGauge']>;
timeHistogram: ReturnType<ReturnType<typeof getMeter>['createHistogram']>;
}

let _reworkMetrics: ReworkMetrics | undefined;

function ensureReworkMetrics(): ReworkMetrics {
if (!_reworkMetrics) {
const meter = getMeter('squad-sdk');
_reworkMetrics = {
rateGauge: meter.createGauge('squad.rework.rate', {
description: 'Current rework rate percentage',
unit: '%',
}),
cyclesHistogram: meter.createHistogram('squad.rework.cycles', {
description: 'Review cycles per PR',
}),
rejectionRateGauge: meter.createGauge('squad.rework.rejection_rate', {
description: 'Percentage of PRs with changes requested',
unit: '%',
}),
timeHistogram: meter.createHistogram('squad.rework.time_ms', {
description: 'Time spent in rework in milliseconds',
unit: 'ms',
}),
};
}
return _reworkMetrics;
}

/** Record rework metrics for a single PR analysis result. */
export function recordReworkMetrics(result: PrReworkResult): void {
const m = ensureReworkMetrics();
const attrs = {
'pr.number': result.number,
'pr.author': result.author,
};
m.rateGauge.record(result.reworkRate, attrs);
m.cyclesHistogram.record(result.reviewCycles, attrs);
if (result.reworkTimeMs !== null) {
m.timeHistogram.record(result.reworkTimeMs, attrs);
}
}

/** Record aggregate rework summary metrics across multiple PRs. */
export function recordReworkSummary(summary: ReworkSummary): void {
const m = ensureReworkMetrics();
if (summary.avgReworkRate != null) {
m.rateGauge.record(summary.avgReworkRate, { scope: 'summary' });
}
if (summary.rejectionRate != null) {
m.rejectionRateGauge.record(summary.rejectionRate, { scope: 'summary' });
}
if (summary.avgReviewCycles != null) {
m.cyclesHistogram.record(summary.avgReviewCycles, { scope: 'summary' });
}
if (summary.avgReworkTimeHours != null) {
m.timeHistogram.record(summary.avgReworkTimeHours * 3_600_000, { scope: 'summary' });
}
}

// ============================================================================
// Reset (for testing)
// ============================================================================
Expand All @@ -261,4 +330,5 @@ export function _resetMetrics(): void {
_agentMetrics = undefined;
_sessionPoolMetrics = undefined;
_latencyMetrics = undefined;
_reworkMetrics = undefined;
}
180 changes: 180 additions & 0 deletions packages/squad-sdk/src/runtime/rework.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Rework Rate — Pure calculation helpers.
*
* Computes PR rework metrics from review and commit data.
* No I/O, no side effects — safe to import in tests.
*
* @module runtime/rework
*/

/** Minimal PR shape from `gh pr list --json ...` */
export interface PrInfo {
number: number;
title: string;
author?: { login: string };
mergedAt?: string;
additions?: number;
deletions?: number;
changedFiles?: number;
}

/** Review data from `gh pr view --json reviews` */
export interface PrReview {
submittedAt?: string;
state?: string;
}

/** Commit data from `gh pr view --json commits` */
export interface PrCommit {
committedDate?: string;
commit?: { committedDate?: string };
oid?: string;
}

/** Per-PR rework analysis result */
export interface PrReworkResult {
number: number;
title: string;
author: string;
mergedAt: string | undefined;
totalCommits: number;
reworkCommits: number;
reworkRate: number;
reviewCycles: number;
hadChangesRequested: boolean;
reworkTimeMs: number | null;
totalReviews: number;
additions: number;
deletions: number;
}

/** Aggregate summary across analyzed PRs */
export interface ReworkSummary {
totalPrs: number;
avgReworkRate?: number;
prsWithRework?: number;
prsWithChangesRequested?: number;
avgReviewCycles?: number;
totalReworkCommits?: number;
totalCommits?: number;
avgReworkTimeHours?: number | null;
rejectionRate?: number;
}

function getCommitDate(c: PrCommit): string | undefined {
return c.committedDate ?? c.commit?.committedDate;
}

/**
* Calculate rework metrics for a single PR.
* Rework = commits pushed after the first review.
*/
export function calculatePrRework(
pr: PrInfo,
reviews: PrReview[],
commits: PrCommit[],
): PrReworkResult {
const sortedReviews = reviews
.filter((r): r is PrReview & { submittedAt: string } => !!r.submittedAt)
.sort((a, b) => new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime());

const sortedCommits = commits
.filter((c) => !!getCommitDate(c))
.sort((a, b) => new Date(getCommitDate(a)!).getTime() - new Date(getCommitDate(b)!).getTime());

const firstReview = sortedReviews[0];
const firstReviewTime = firstReview ? new Date(firstReview.submittedAt).getTime() : null;

// Count review cycles: changes-requested → approved transitions
let reviewCycles = 0;
let hadChangesRequested = false;
let firstChangesRequested: string | null = null;
let lastApproval: string | null = null;
let pendingChangeRequest = false;

for (const review of sortedReviews) {
const state = (review.state ?? '').toUpperCase();
if (state === 'CHANGES_REQUESTED') {
hadChangesRequested = true;
pendingChangeRequest = true;
if (!firstChangesRequested) firstChangesRequested = review.submittedAt;
} else if (state === 'APPROVED' && pendingChangeRequest) {
reviewCycles++;
pendingChangeRequest = false;
lastApproval = review.submittedAt;
} else if (state === 'APPROVED') {
lastApproval = review.submittedAt;
}
}

// Count post-review commits (rework)
const postReviewCommits = firstReviewTime
? sortedCommits.filter((c) => new Date(getCommitDate(c)!).getTime() > firstReviewTime)
: [];

const totalCommits = sortedCommits.length;
const reworkCommits = postReviewCommits.length;
const reworkRate = totalCommits > 0 ? reworkCommits / totalCommits : 0;

// Rework time: first changes-requested to last approval
const reworkTimeMs =
firstChangesRequested && lastApproval
? new Date(lastApproval).getTime() - new Date(firstChangesRequested).getTime()
: null;

return {
number: pr.number,
title: pr.title,
author: pr.author?.login ?? 'unknown',
mergedAt: pr.mergedAt,
totalCommits,
reworkCommits,
reworkRate: Math.round(reworkRate * 100),
reviewCycles,
hadChangesRequested,
reworkTimeMs,
totalReviews: sortedReviews.length,
additions: pr.additions ?? 0,
deletions: pr.deletions ?? 0,
};
}

/**
* Calculate aggregate rework summary across all analyzed PRs.
*/
export function calculateReworkSummary(results: PrReworkResult[]): ReworkSummary {
if (results.length === 0) return { totalPrs: 0 };

const totalPrs = results.length;
const avgReworkRate = Math.round(
results.reduce((s, r) => s + r.reworkRate, 0) / totalPrs,
);
const prsWithRework = results.filter((r) => r.reworkRate > 0).length;
const prsWithChangesRequested = results.filter((r) => r.hadChangesRequested).length;
const avgReviewCycles = +(
results.reduce((s, r) => s + r.reviewCycles, 0) / totalPrs
).toFixed(1);
const totalReworkCommits = results.reduce((s, r) => s + r.reworkCommits, 0);
const totalCommits = results.reduce((s, r) => s + r.totalCommits, 0);

// Average rework time (only for PRs that had rework time)
const reworkTimes = results
.filter((r): r is PrReworkResult & { reworkTimeMs: number } => r.reworkTimeMs !== null)
.map((r) => r.reworkTimeMs);
const avgReworkTimeHours =
reworkTimes.length > 0
? +(reworkTimes.reduce((s, t) => s + t, 0) / reworkTimes.length / 3_600_000).toFixed(1)
: null;

return {
totalPrs,
avgReworkRate,
prsWithRework,
prsWithChangesRequested,
avgReviewCycles,
totalReworkCommits,
totalCommits,
avgReworkTimeHours,
rejectionRate: Math.round((prsWithChangesRequested / totalPrs) * 100),
};
}
Loading
Loading