Skip to content

Commit c743b4d

Browse files
authored
Merge pull request #24 from tstromberg/main
Improve verbiage, expand velocity measurement scope
2 parents 3f89667 + 9b8a3d9 commit c743b4d

File tree

12 files changed

+454
-179
lines changed

12 files changed

+454
-179
lines changed

cmd/prcost/repository.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,15 @@ func analyzeRepository(ctx context.Context, owner, repo string, sampleSize, days
103103
prSummaryInfos := make([]cost.PRSummaryInfo, len(prs))
104104
for i, pr := range prs {
105105
prSummaryInfos[i] = cost.PRSummaryInfo{
106-
Owner: pr.Owner,
107-
Repo: pr.Repo,
108-
Merged: pr.Merged,
109-
State: pr.State,
106+
Owner: pr.Owner,
107+
Repo: pr.Repo,
108+
Author: pr.Author,
109+
AuthorType: pr.AuthorType,
110+
CreatedAt: pr.CreatedAt,
111+
UpdatedAt: pr.UpdatedAt,
112+
ClosedAt: pr.ClosedAt,
113+
Merged: pr.Merged,
114+
State: pr.State,
110115
}
111116
}
112117

@@ -214,10 +219,15 @@ func analyzeOrganization(ctx context.Context, org string, sampleSize, days int,
214219
prSummaryInfos := make([]cost.PRSummaryInfo, len(prs))
215220
for i, pr := range prs {
216221
prSummaryInfos[i] = cost.PRSummaryInfo{
217-
Owner: pr.Owner,
218-
Repo: pr.Repo,
219-
Merged: pr.Merged,
220-
State: pr.State,
222+
Owner: pr.Owner,
223+
Repo: pr.Repo,
224+
Author: pr.Author,
225+
AuthorType: pr.AuthorType,
226+
CreatedAt: pr.CreatedAt,
227+
UpdatedAt: pr.UpdatedAt,
228+
ClosedAt: pr.ClosedAt,
229+
Merged: pr.Merged,
230+
State: pr.State,
221231
}
222232
}
223233

internal/server/integration_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func TestOrgSampleStreamIntegration(t *testing.T) {
3333
// Create request
3434
reqBody := OrgSampleRequest{
3535
Org: "codeGROOVE-dev",
36-
SampleSize: 100,
36+
SampleSize: 250,
3737
Days: 60,
3838
}
3939
body, err := json.Marshal(reqBody)
@@ -195,7 +195,7 @@ func TestOrgSampleStreamNoTimeout(t *testing.T) {
195195
// Create request with larger sample size to ensure longer operation
196196
reqBody := OrgSampleRequest{
197197
Org: "codeGROOVE-dev",
198-
SampleSize: 100,
198+
SampleSize: 250,
199199
Days: 60,
200200
}
201201
body, err := json.Marshal(reqBody)

internal/server/server.go

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ type CalculateResponse struct {
144144
type RepoSampleRequest struct {
145145
Owner string `json:"owner"`
146146
Repo string `json:"repo"`
147-
SampleSize int `json:"sample_size,omitempty"` // Default: 100
147+
SampleSize int `json:"sample_size,omitempty"` // Default: 250
148148
Days int `json:"days,omitempty"` // Default: 60
149149
Config *cost.Config `json:"config,omitempty"`
150150
}
@@ -154,7 +154,7 @@ type RepoSampleRequest struct {
154154
//nolint:govet // fieldalignment: API struct field order optimized for readability
155155
type OrgSampleRequest struct {
156156
Org string `json:"org"`
157-
SampleSize int `json:"sample_size,omitempty"` // Default: 100
157+
SampleSize int `json:"sample_size,omitempty"` // Default: 250
158158
Days int `json:"days,omitempty"` // Default: 60
159159
Config *cost.Config `json:"config,omitempty"`
160160
}
@@ -1478,18 +1478,18 @@ func (s *Server) parseRepoSampleRequest(ctx context.Context, r *http.Request) (*
14781478

14791479
// Set defaults
14801480
if req.SampleSize == 0 {
1481-
req.SampleSize = 100
1481+
req.SampleSize = 250
14821482
}
14831483
if req.Days == 0 {
14841484
req.Days = 60
14851485
}
14861486

1487-
// Validate reasonable limits (silently cap at 100)
1487+
// Validate reasonable limits (silently cap at 250)
14881488
if req.SampleSize < 1 {
14891489
return nil, errors.New("sample_size must be at least 1")
14901490
}
1491-
if req.SampleSize > 100 {
1492-
req.SampleSize = 100
1491+
if req.SampleSize > 250 {
1492+
req.SampleSize = 250
14931493
}
14941494
if req.Days < 1 || req.Days > 365 {
14951495
return nil, errors.New("days must be between 1 and 365")
@@ -1536,18 +1536,18 @@ func (s *Server) parseOrgSampleRequest(ctx context.Context, r *http.Request) (*O
15361536

15371537
// Set defaults
15381538
if req.SampleSize == 0 {
1539-
req.SampleSize = 100
1539+
req.SampleSize = 250
15401540
}
15411541
if req.Days == 0 {
15421542
req.Days = 60
15431543
}
15441544

1545-
// Validate reasonable limits (silently cap at 100)
1545+
// Validate reasonable limits (silently cap at 250)
15461546
if req.SampleSize < 1 {
15471547
return nil, errors.New("sample_size must be at least 1")
15481548
}
1549-
if req.SampleSize > 100 {
1550-
req.SampleSize = 100
1549+
if req.SampleSize > 250 {
1550+
req.SampleSize = 250
15511551
}
15521552
if req.Days < 1 || req.Days > 365 {
15531553
return nil, errors.New("days must be between 1 and 365")
@@ -1663,10 +1663,15 @@ func (s *Server) processRepoSample(ctx context.Context, req *RepoSampleRequest,
16631663
prSummaryInfos := make([]cost.PRSummaryInfo, len(prs))
16641664
for i, pr := range prs {
16651665
prSummaryInfos[i] = cost.PRSummaryInfo{
1666-
Owner: pr.Owner,
1667-
Repo: pr.Repo,
1668-
Merged: pr.Merged,
1669-
State: pr.State,
1666+
Owner: pr.Owner,
1667+
Repo: pr.Repo,
1668+
Author: pr.Author,
1669+
AuthorType: pr.AuthorType,
1670+
CreatedAt: pr.CreatedAt,
1671+
UpdatedAt: pr.UpdatedAt,
1672+
ClosedAt: pr.ClosedAt,
1673+
Merged: pr.Merged,
1674+
State: pr.State,
16701675
}
16711676
}
16721677

@@ -1811,10 +1816,15 @@ func (s *Server) processOrgSample(ctx context.Context, req *OrgSampleRequest, to
18111816
prSummaryInfos := make([]cost.PRSummaryInfo, len(prs))
18121817
for i, pr := range prs {
18131818
prSummaryInfos[i] = cost.PRSummaryInfo{
1814-
Owner: pr.Owner,
1815-
Repo: pr.Repo,
1816-
Merged: pr.Merged,
1817-
State: pr.State,
1819+
Owner: pr.Owner,
1820+
Repo: pr.Repo,
1821+
Author: pr.Author,
1822+
AuthorType: pr.AuthorType,
1823+
CreatedAt: pr.CreatedAt,
1824+
UpdatedAt: pr.UpdatedAt,
1825+
ClosedAt: pr.ClosedAt,
1826+
Merged: pr.Merged,
1827+
State: pr.State,
18181828
}
18191829
}
18201830

@@ -2219,10 +2229,15 @@ func (s *Server) processRepoSampleWithProgress(ctx context.Context, req *RepoSam
22192229
prSummaryInfos := make([]cost.PRSummaryInfo, len(prs))
22202230
for i, pr := range prs {
22212231
prSummaryInfos[i] = cost.PRSummaryInfo{
2222-
Owner: pr.Owner,
2223-
Repo: pr.Repo,
2224-
Merged: pr.Merged,
2225-
State: pr.State,
2232+
Owner: pr.Owner,
2233+
Repo: pr.Repo,
2234+
Author: pr.Author,
2235+
AuthorType: pr.AuthorType,
2236+
CreatedAt: pr.CreatedAt,
2237+
UpdatedAt: pr.UpdatedAt,
2238+
ClosedAt: pr.ClosedAt,
2239+
Merged: pr.Merged,
2240+
State: pr.State,
22262241
}
22272242
}
22282243

@@ -2380,10 +2395,15 @@ func (s *Server) processOrgSampleWithProgress(ctx context.Context, req *OrgSampl
23802395
prSummaryInfos := make([]cost.PRSummaryInfo, len(prs))
23812396
for i, pr := range prs {
23822397
prSummaryInfos[i] = cost.PRSummaryInfo{
2383-
Owner: pr.Owner,
2384-
Repo: pr.Repo,
2385-
Merged: pr.Merged,
2386-
State: pr.State,
2398+
Owner: pr.Owner,
2399+
Repo: pr.Repo,
2400+
Author: pr.Author,
2401+
AuthorType: pr.AuthorType,
2402+
CreatedAt: pr.CreatedAt,
2403+
UpdatedAt: pr.UpdatedAt,
2404+
ClosedAt: pr.ClosedAt,
2405+
Merged: pr.Merged,
2406+
State: pr.State,
23872407
}
23882408
}
23892409

internal/server/server_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,7 +1484,7 @@ func TestParseRepoSampleRequest(t *testing.T) {
14841484
wantOwner: "testowner",
14851485
wantRepo: "testrepo",
14861486
wantDays: 60,
1487-
wantSampleSize: 100,
1487+
wantSampleSize: 250,
14881488
},
14891489
{
14901490
name: "missing owner",
@@ -1571,7 +1571,7 @@ func TestParseOrgSampleRequest(t *testing.T) {
15711571
wantErr: false,
15721572
wantOrg: "testorg",
15731573
wantDays: 60,
1574-
wantSampleSize: 100,
1574+
wantSampleSize: 250,
15751575
},
15761576
{
15771577
name: "missing org",

internal/server/static/index.html

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,7 @@ <h1><a href="/">PR Cost Calculator</a></h1>
10631063
id="repoSampleSize"
10641064
value="50"
10651065
min="1"
1066-
max="100"
1066+
max="250"
10671067
>
10681068
<div class="help-text">50 (recommended, ±14% accuracy) or 30 (faster, ±18% accuracy)</div>
10691069
</div>
@@ -1103,7 +1103,7 @@ <h1><a href="/">PR Cost Calculator</a></h1>
11031103
id="orgSampleSize"
11041104
value="50"
11051105
min="1"
1106-
max="100"
1106+
max="250"
11071107
>
11081108
<div class="help-text">50 (recommended, ±14% accuracy) or 30 (faster, ±18% accuracy)</div>
11091109
</div>
@@ -1426,7 +1426,7 @@ <h3>Why calculate PR costs?</h3>
14261426
}
14271427
}
14281428

1429-
function formatEfficiencyHTML(efficiencyPct, grade, message, preventableCost, preventableHours, totalCost, totalHours, avgOpenHours, isAnnual = false, annualWasteCost = 0, annualWasteHours = 0, wasteHoursPerWeek = 0, wasteCostPerWeek = 0, wasteHoursPerAuthorPerWeek = 0, wasteCostPerAuthorPerWeek = 0, totalAuthors = 0, salary = 250000, benefitsMultiplier = 1.2, analysisType = 'project', sourceName = '', mergeRate = 0, mergedPRs = 0, unmergedPRs = 0, velocityGrade = '', velocityMessage = '', mergeRateGrade = '', mergeRateMessage = '') {
1429+
function formatEfficiencyHTML(efficiencyPct, grade, message, preventableCost, preventableHours, totalCost, totalHours, avgOpenHours, isAnnual = false, annualWasteCost = 0, annualWasteHours = 0, wasteHoursPerWeek = 0, wasteCostPerWeek = 0, wasteHoursPerAuthorPerWeek = 0, wasteCostPerAuthorPerWeek = 0, totalAuthors = 0, salary = 250000, benefitsMultiplier = 1.2, analysisType = 'project', sourceName = '', mergeRate = 0, mergedPRs = 0, unmergedPRs = 0, velocityGrade = '', velocityMessage = '', mergeRateGrade = '', mergeRateMessage = '', days = 60) {
14301430
let html = '<div class="efficiency-section">';
14311431

14321432
// Development Efficiency box
@@ -1450,6 +1450,8 @@ <h3>Why calculate PR costs?</h3>
14501450
html += `<span style="font-size: 22px; font-weight: 700; color: #1d1d1f;">${formatTimeUnit(avgOpenHours)}</span>`;
14511451
html += '</div>';
14521452
html += `<div class="efficiency-message" style="font-size: 11px;">${velocityGradeObj.message}</div>`;
1453+
const cutoffDays = parseInt(days) * 2;
1454+
html += `<div style="font-size: 11px; color: #86868b; margin-top: 4px;">Excludes open PRs created >${cutoffDays}d ago</div>`;
14531455
html += '</div>'; // Close efficiency-box
14541456

14551457
// Merge Success Rate box (if data available) - use backend-computed grades if provided
@@ -1470,14 +1472,16 @@ <h3>Why calculate PR costs?</h3>
14701472

14711473
// Annual Impact box (only if annual)
14721474
if (isAnnual && annualWasteCost > 0) {
1473-
html += '<div class="efficiency-box" style="background: linear-gradient(135deg, #fff9e6 0%, #ffffff 100%); border-left: 3px solid #ffcc00;">';
1474-
html += '<h3 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 600; color: #1d1d1f;">Projected Annual Waste</h3>';
1475+
html += '<div class="efficiency-box">';
1476+
html += '<h3 style="margin: 0 0 6px 0; font-size: 12px; font-weight: 600; color: #1d1d1f;">Projected Annual Waste</h3>';
1477+
html += '<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 2px;">';
14751478
const annualWasteRounded = Math.round(annualWasteCost);
14761479
const annualWasteFormatted = '$' + annualWasteRounded.toLocaleString('en-US');
1477-
html += `<div style="font-size: 28px; font-weight: 700; color: #1d1d1f; margin-bottom: 4px;">${annualWasteFormatted}</div>`;
1480+
html += `<span style="font-size: 22px; font-weight: 700; color: #1d1d1f;">${annualWasteFormatted}</span>`;
1481+
html += '</div>';
14781482
const annualCostPerHead = salary * benefitsMultiplier;
14791483
const headcount = annualWasteCost / annualCostPerHead;
1480-
html += `<div class="efficiency-message">Equal to ${headcount.toFixed(1)} engineers</div>`;
1484+
html += `<div class="efficiency-message" style="font-size: 11px;">Equal to ${headcount.toFixed(1)} engineers</div>`;
14811485
html += '</div>'; // Close efficiency-box
14821486
}
14831487

@@ -2417,7 +2421,7 @@ <h3>Why calculate PR costs?</h3>
24172421
const velocityMessage = e.merge_velocity_message || '';
24182422
const mergeRateGrade = e.merge_rate_grade || '';
24192423
const mergeRateMessage = e.merge_rate_grade_message || '';
2420-
html += formatEfficiencyHTML(extEfficiencyPct, extEfficiency.grade, extEfficiency.message, extPreventableCost, extPreventableHours, e.total_cost, e.total_hours, avgPRDurationHours, true, annualWasteCost, annualWasteHours, wasteHoursPerWeek, wasteCostPerWeek, wasteHoursPerAuthorPerWeek, wasteCostPerAuthorPerWeek, totalAuthors, salary, benefitsMultiplier, analysisType, sourceName, mergeRate, mergedPRs, unmergedPRs, velocityGrade, velocityMessage, mergeRateGrade, mergeRateMessage);
2424+
html += formatEfficiencyHTML(extEfficiencyPct, extEfficiency.grade, extEfficiency.message, extPreventableCost, extPreventableHours, e.total_cost, e.total_hours, avgPRDurationHours, true, annualWasteCost, annualWasteHours, wasteHoursPerWeek, wasteCostPerWeek, wasteHoursPerAuthorPerWeek, wasteCostPerAuthorPerWeek, totalAuthors, salary, benefitsMultiplier, analysisType, sourceName, mergeRate, mergedPRs, unmergedPRs, velocityGrade, velocityMessage, mergeRateGrade, mergeRateMessage, days);
24212425

24222426
// Add R2R callout if enabled, otherwise generic merge time callout
24232427
// Calculate modeled efficiency (with 1.5h merge time)

pkg/cost/analyze.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@ type AnalysisRequest struct {
3434

3535
// PRSummaryInfo contains basic PR information needed for fetching and analysis.
3636
type PRSummaryInfo struct {
37-
UpdatedAt time.Time
38-
Owner string
39-
Repo string
40-
State string // "OPEN", "CLOSED", "MERGED"
41-
Number int
42-
Merged bool // Whether the PR was merged
37+
UpdatedAt time.Time
38+
CreatedAt time.Time
39+
ClosedAt *time.Time // Nil if still open
40+
Owner string
41+
Repo string
42+
Author string
43+
AuthorType string // "Bot", "User", or empty if unknown
44+
State string // "OPEN", "CLOSED", "MERGED"
45+
Number int
46+
Merged bool // Whether the PR was merged
4347
}
4448

4549
// AnalysisResult contains the breakdowns from analyzed PRs.

pkg/cost/cost_test.go

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,10 +1370,24 @@ func TestExtrapolateFromSamplesMultiple(t *testing.T) {
13701370
// Create merge status for 20 PRs: 17 merged, 3 open
13711371
prStatuses := make([]PRSummaryInfo, 20)
13721372
for i := range 17 {
1373-
prStatuses[i] = PRSummaryInfo{Owner: "test", Repo: "test", Merged: true, State: "MERGED"}
1373+
closedAt := now.Add(-time.Duration(i) * time.Hour)
1374+
prStatuses[i] = PRSummaryInfo{
1375+
Owner: "test",
1376+
Repo: "test",
1377+
Merged: true,
1378+
State: "MERGED",
1379+
CreatedAt: closedAt.Add(-24 * time.Hour),
1380+
ClosedAt: &closedAt,
1381+
}
13741382
}
13751383
for i := 17; i < 20; i++ {
1376-
prStatuses[i] = PRSummaryInfo{Owner: "test", Repo: "test", Merged: false, State: "OPEN"}
1384+
prStatuses[i] = PRSummaryInfo{
1385+
Owner: "test",
1386+
Repo: "test",
1387+
Merged: false,
1388+
State: "OPEN",
1389+
CreatedAt: now.Add(-time.Duration(i-17+1) * 24 * time.Hour), // Open PRs created 1-3 days ago
1390+
}
13771391
}
13781392
result := ExtrapolateFromSamples(breakdowns, 20, 5, 3, 14, cfg, prStatuses, nil)
13791393

@@ -1445,10 +1459,36 @@ func TestExtrapolateFromSamplesBotVsHuman(t *testing.T) {
14451459
},
14461460
}
14471461

1448-
// Create merge status for 10 PRs: all merged
1462+
// Create merge status for 10 PRs: 5 human, 5 bot (all merged)
14491463
prStatuses := make([]PRSummaryInfo, 10)
1464+
now := time.Now()
14501465
for i := range 10 {
1451-
prStatuses[i] = PRSummaryInfo{Owner: "test", Repo: "test", Merged: true, State: "MERGED"}
1466+
if i < 5 {
1467+
// Human PRs
1468+
prStatuses[i] = PRSummaryInfo{
1469+
Owner: "test",
1470+
Repo: "test",
1471+
Author: "human-author",
1472+
AuthorType: "User",
1473+
CreatedAt: now.Add(-24 * time.Hour),
1474+
ClosedAt: &now,
1475+
Merged: true,
1476+
State: "MERGED",
1477+
}
1478+
} else {
1479+
// Bot PRs
1480+
closedTime := now.Add(-2 * time.Hour)
1481+
prStatuses[i] = PRSummaryInfo{
1482+
Owner: "test",
1483+
Repo: "test",
1484+
Author: "dependabot[bot]",
1485+
AuthorType: "Bot",
1486+
CreatedAt: now.Add(-4 * time.Hour),
1487+
ClosedAt: &closedTime,
1488+
Merged: true,
1489+
State: "MERGED",
1490+
}
1491+
}
14521492
}
14531493
result := ExtrapolateFromSamples(breakdowns, 10, 5, 0, 7, cfg, prStatuses, nil)
14541494

0 commit comments

Comments
 (0)