Skip to content

Commit 1766dc1

Browse files
authored
Merge pull request #7 from tstromberg/main
more samples, more progress, more simplification
2 parents 3db9dd1 + 4cb622d commit 1766dc1

File tree

12 files changed

+307
-243
lines changed

12 files changed

+307
-243
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ go install github.com/codeGROOVE-dev/prcost/cmd/prcost@latest
6767
prcost https://github.com/owner/repo/pull/123
6868
prcost --salary 300000 https://github.com/owner/repo/pull/123
6969

70-
# Repository analysis (samples 25 PRs from last 90 days)
70+
# Repository analysis (samples 30 PRs from last 90 days)
7171
prcost --org kubernetes --repo kubernetes
7272
prcost --org myorg --repo myrepo --samples 50 --days 30
7373

@@ -85,8 +85,8 @@ go run ./cmd/server
8585

8686
Repository and organization modes use time-bucket sampling to ensure even distribution across the time period, avoiding temporal clustering that would bias estimates.
8787

88-
- **25 samples** (default): Fast analysis with ±20% confidence interval
89-
- **50 samples**: More accurate with ±14% confidence interval (1.4× better precision)
88+
- **30 samples** (default): Fast analysis with ±18% confidence interval
89+
- **50 samples**: More accurate with ±14% confidence interval (1.3× better precision)
9090

9191
## Cost Model: Scientific Foundations
9292

cmd/prcost/main.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func main() {
3131
// Org/Repo sampling flags
3232
org := flag.String("org", "", "GitHub organization to analyze (optionally with --repo for single repo)")
3333
repo := flag.String("repo", "", "GitHub repository to analyze (requires --org)")
34-
samples := flag.Int("samples", 25, "Number of PRs to sample for extrapolation (25=fast/±20%, 50=slower/±14%)")
34+
samples := flag.Int("samples", 30, "Number of PRs to sample for extrapolation (30=fast/±18%, 50=slower/±14%)")
3535
days := flag.Int("days", 60, "Number of days to look back for PR modifications")
3636

3737
flag.Usage = func() {
@@ -452,22 +452,22 @@ func formatLOC(kloc float64) string {
452452

453453
// Add fractional part if significant
454454
if kloc < 1000.0 && fracPart >= 0.05 {
455-
return fmt.Sprintf("%s.%dk", string(result), int(fracPart*10))
455+
return fmt.Sprintf("%s.%dk LOC", string(result), int(fracPart*10))
456456
}
457-
return string(result) + "k"
457+
return string(result) + "k LOC"
458458
}
459459

460460
// For values < 100k, use existing precision logic
461461
if kloc < 0.1 && kloc > 0 {
462-
return fmt.Sprintf("%.2fk", kloc)
462+
return fmt.Sprintf("%.2fk LOC", kloc)
463463
}
464464
if kloc < 1.0 {
465-
return fmt.Sprintf("%.1fk", kloc)
465+
return fmt.Sprintf("%.1fk LOC", kloc)
466466
}
467467
if kloc < 10.0 {
468-
return fmt.Sprintf("%.1fk", kloc)
468+
return fmt.Sprintf("%.1fk LOC", kloc)
469469
}
470-
return fmt.Sprintf("%.0fk", kloc)
470+
return fmt.Sprintf("%.0fk LOC", kloc)
471471
}
472472

473473
// efficiencyGrade returns a letter grade and message based on efficiency percentage (MIT scale).

cmd/prcost/repository.go

Lines changed: 92 additions & 103 deletions
Large diffs are not rendered by default.

cmd/server/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func main() {
4141
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
4242
AddSource: true,
4343
Level: slog.LevelInfo,
44-
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
44+
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
4545
// Shorten source file paths to show only filename:line
4646
if a.Key == slog.SourceKey {
4747
if src, ok := a.Value.Any().(*slog.Source); ok {

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: 25,
36+
SampleSize: 30,
3737
Days: 90,
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: 25,
198+
SampleSize: 30,
199199
Days: 90,
200200
}
201201
body, err := json.Marshal(reqBody)

internal/server/server.go

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ type CalculateResponse struct {
112112
type RepoSampleRequest struct {
113113
Owner string `json:"owner"`
114114
Repo string `json:"repo"`
115-
SampleSize int `json:"sample_size,omitempty"` // Default: 25
115+
SampleSize int `json:"sample_size,omitempty"` // Default: 30
116116
Days int `json:"days,omitempty"` // Default: 90
117117
Config *cost.Config `json:"config,omitempty"`
118118
}
@@ -122,7 +122,7 @@ type RepoSampleRequest struct {
122122
//nolint:govet // fieldalignment: API struct field order optimized for readability
123123
type OrgSampleRequest struct {
124124
Org string `json:"org"`
125-
SampleSize int `json:"sample_size,omitempty"` // Default: 25
125+
SampleSize int `json:"sample_size,omitempty"` // Default: 30
126126
Days int `json:"days,omitempty"` // Default: 90
127127
Config *cost.Config `json:"config,omitempty"`
128128
}
@@ -1150,18 +1150,18 @@ func (s *Server) parseRepoSampleRequest(ctx context.Context, r *http.Request) (*
11501150

11511151
// Set defaults
11521152
if req.SampleSize == 0 {
1153-
req.SampleSize = 25
1153+
req.SampleSize = 30
11541154
}
11551155
if req.Days == 0 {
11561156
req.Days = 90
11571157
}
11581158

1159-
// Validate reasonable limits (silently cap at 25)
1159+
// Validate reasonable limits (silently cap at 50)
11601160
if req.SampleSize < 1 {
11611161
return nil, errors.New("sample_size must be at least 1")
11621162
}
1163-
if req.SampleSize > 25 {
1164-
req.SampleSize = 25
1163+
if req.SampleSize > 50 {
1164+
req.SampleSize = 50
11651165
}
11661166
if req.Days < 1 || req.Days > 365 {
11671167
return nil, errors.New("days must be between 1 and 365")
@@ -1208,18 +1208,18 @@ func (s *Server) parseOrgSampleRequest(ctx context.Context, r *http.Request) (*O
12081208

12091209
// Set defaults
12101210
if req.SampleSize == 0 {
1211-
req.SampleSize = 25
1211+
req.SampleSize = 30
12121212
}
12131213
if req.Days == 0 {
12141214
req.Days = 90
12151215
}
12161216

1217-
// Validate reasonable limits (silently cap at 25)
1217+
// Validate reasonable limits (silently cap at 50)
12181218
if req.SampleSize < 1 {
12191219
return nil, errors.New("sample_size must be at least 1")
12201220
}
1221-
if req.SampleSize > 25 {
1222-
req.SampleSize = 25
1221+
if req.SampleSize > 50 {
1222+
req.SampleSize = 50
12231223
}
12241224
if req.Days < 1 || req.Days > 365 {
12251225
return nil, errors.New("days must be between 1 and 365")
@@ -1249,7 +1249,7 @@ func (s *Server) processRepoSample(ctx context.Context, req *RepoSampleRequest,
12491249
} else {
12501250
// Fetch all PRs modified since the date
12511251
var err error
1252-
prs, err = github.FetchPRsFromRepo(ctx, req.Owner, req.Repo, since, token)
1252+
prs, err = github.FetchPRsFromRepo(ctx, req.Owner, req.Repo, since, token, nil)
12531253
if err != nil {
12541254
return nil, fmt.Errorf("failed to fetch PRs: %w", err)
12551255
}
@@ -1350,7 +1350,7 @@ func (s *Server) processOrgSample(ctx context.Context, req *OrgSampleRequest, to
13501350
} else {
13511351
// Fetch all PRs across the org modified since the date
13521352
var err error
1353-
prs, err = github.FetchPRsFromOrg(ctx, req.Org, since, token)
1353+
prs, err = github.FetchPRsFromOrg(ctx, req.Org, since, token, nil)
13541354
if err != nil {
13551355
return nil, fmt.Errorf("failed to fetch PRs: %w", err)
13561356
}
@@ -1648,30 +1648,30 @@ func sendSSE(w http.ResponseWriter, update ProgressUpdate) error {
16481648
// startKeepAlive starts a goroutine that sends SSE keep-alive comments every 2 seconds.
16491649
// This prevents client-side timeouts during long operations.
16501650
// Returns a stop channel (to stop keep-alive) and an error channel (signals connection failure).
1651-
func startKeepAlive(w http.ResponseWriter) (chan struct{}, <-chan error) {
1652-
stop := make(chan struct{})
1653-
connErr := make(chan error, 1)
1651+
func startKeepAlive(w http.ResponseWriter) (stop chan struct{}, connErr <-chan error) {
1652+
stopChan := make(chan struct{})
1653+
errChan := make(chan error, 1)
16541654
go func() {
16551655
ticker := time.NewTicker(2 * time.Second)
16561656
defer ticker.Stop()
1657-
defer close(connErr)
1657+
defer close(errChan)
16581658
for {
16591659
select {
16601660
case <-ticker.C:
16611661
// Send SSE comment (keeps connection alive, ignored by client)
16621662
if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil {
1663-
connErr <- fmt.Errorf("keepalive write failed: %w", err)
1663+
errChan <- fmt.Errorf("keepalive write failed: %w", err)
16641664
return
16651665
}
16661666
if flusher, ok := w.(http.Flusher); ok {
16671667
flusher.Flush()
16681668
}
1669-
case <-stop:
1669+
case <-stopChan:
16701670
return
16711671
}
16721672
}
16731673
}()
1674-
return stop, connErr
1674+
return stopChan, errChan
16751675
}
16761676

16771677
// logSSEError logs an error from sendSSE if it occurs.
@@ -1737,10 +1737,19 @@ func (s *Server) processRepoSampleWithProgress(ctx context.Context, req *RepoSam
17371737
}
17381738
}()
17391739

1740-
// Fetch all PRs modified since the date
1740+
// Fetch all PRs modified since the date with progress updates
17411741
var err error
1742+
progressCallback := func(queryName string, page int, prCount int) {
1743+
logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{
1744+
Type: "fetching",
1745+
PR: 0,
1746+
Owner: req.Owner,
1747+
Repo: req.Repo,
1748+
Progress: fmt.Sprintf("Fetching %s PRs (page %d, %d PRs found)...", queryName, page, prCount),
1749+
}))
1750+
}
17421751
//nolint:contextcheck // Using background context intentionally to prevent client timeout from canceling work
1743-
prs, err = github.FetchPRsFromRepo(workCtx, req.Owner, req.Repo, since, token)
1752+
prs, err = github.FetchPRsFromRepo(workCtx, req.Owner, req.Repo, since, token, progressCallback)
17441753
if err != nil {
17451754
logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{
17461755
Type: "error",
@@ -1862,10 +1871,19 @@ func (s *Server) processOrgSampleWithProgress(ctx context.Context, req *OrgSampl
18621871
}
18631872
}()
18641873

1865-
// Fetch all PRs across the org modified since the date
1874+
// Fetch all PRs across the org modified since the date with progress updates
18661875
var err error
1876+
progressCallback := func(queryName string, page int, prCount int) {
1877+
logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{
1878+
Type: "fetching",
1879+
PR: 0,
1880+
Owner: req.Org,
1881+
Repo: "",
1882+
Progress: fmt.Sprintf("Fetching %s PRs (page %d, %d PRs found)...", queryName, page, prCount),
1883+
}))
1884+
}
18671885
//nolint:contextcheck // Using background context intentionally to prevent client timeout from canceling work
1868-
prs, err = github.FetchPRsFromOrg(workCtx, req.Org, since, token)
1886+
prs, err = github.FetchPRsFromOrg(workCtx, req.Org, since, token, progressCallback)
18691887
if err != nil {
18701888
logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{
18711889
Type: "error",

internal/server/server_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,12 @@ func TestParseRequest(t *testing.T) {
460460
}
461461

462462
func TestHandleCalculateNoToken(t *testing.T) {
463+
// Clear environment variables that could provide a fallback token
464+
// t.Setenv automatically restores the original value after the test
465+
t.Setenv("GITHUB_TOKEN", "")
466+
// Clear PATH to prevent gh CLI lookup
467+
t.Setenv("PATH", "")
468+
463469
s := New()
464470

465471
reqBody := CalculateRequest{

0 commit comments

Comments
 (0)