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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ go install github.com/codeGROOVE-dev/prcost/cmd/prcost@latest
prcost https://github.com/owner/repo/pull/123
prcost --salary 300000 https://github.com/owner/repo/pull/123

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

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

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

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

## Cost Model: Scientific Foundations

Expand Down
14 changes: 7 additions & 7 deletions cmd/prcost/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func main() {
// Org/Repo sampling flags
org := flag.String("org", "", "GitHub organization to analyze (optionally with --repo for single repo)")
repo := flag.String("repo", "", "GitHub repository to analyze (requires --org)")
samples := flag.Int("samples", 25, "Number of PRs to sample for extrapolation (25=fast/±20%, 50=slower/±14%)")
samples := flag.Int("samples", 30, "Number of PRs to sample for extrapolation (30=fast/±18%, 50=slower/±14%)")
days := flag.Int("days", 60, "Number of days to look back for PR modifications")

flag.Usage = func() {
Expand Down Expand Up @@ -452,22 +452,22 @@ func formatLOC(kloc float64) string {

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

// For values < 100k, use existing precision logic
if kloc < 0.1 && kloc > 0 {
return fmt.Sprintf("%.2fk", kloc)
return fmt.Sprintf("%.2fk LOC", kloc)
}
if kloc < 1.0 {
return fmt.Sprintf("%.1fk", kloc)
return fmt.Sprintf("%.1fk LOC", kloc)
}
if kloc < 10.0 {
return fmt.Sprintf("%.1fk", kloc)
return fmt.Sprintf("%.1fk LOC", kloc)
}
return fmt.Sprintf("%.0fk", kloc)
return fmt.Sprintf("%.0fk LOC", kloc)
}

// efficiencyGrade returns a letter grade and message based on efficiency percentage (MIT scale).
Expand Down
195 changes: 92 additions & 103 deletions cmd/prcost/repository.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
// Shorten source file paths to show only filename:line
if a.Key == slog.SourceKey {
if src, ok := a.Value.Any().(*slog.Source); ok {
Expand Down
4 changes: 2 additions & 2 deletions internal/server/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestOrgSampleStreamIntegration(t *testing.T) {
// Create request
reqBody := OrgSampleRequest{
Org: "codeGROOVE-dev",
SampleSize: 25,
SampleSize: 30,
Days: 90,
}
body, err := json.Marshal(reqBody)
Expand Down Expand Up @@ -195,7 +195,7 @@ func TestOrgSampleStreamNoTimeout(t *testing.T) {
// Create request with larger sample size to ensure longer operation
reqBody := OrgSampleRequest{
Org: "codeGROOVE-dev",
SampleSize: 25,
SampleSize: 30,
Days: 90,
}
body, err := json.Marshal(reqBody)
Expand Down
64 changes: 41 additions & 23 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ type CalculateResponse struct {
type RepoSampleRequest struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
SampleSize int `json:"sample_size,omitempty"` // Default: 25
SampleSize int `json:"sample_size,omitempty"` // Default: 30
Days int `json:"days,omitempty"` // Default: 90
Config *cost.Config `json:"config,omitempty"`
}
Expand All @@ -122,7 +122,7 @@ type RepoSampleRequest struct {
//nolint:govet // fieldalignment: API struct field order optimized for readability
type OrgSampleRequest struct {
Org string `json:"org"`
SampleSize int `json:"sample_size,omitempty"` // Default: 25
SampleSize int `json:"sample_size,omitempty"` // Default: 30
Days int `json:"days,omitempty"` // Default: 90
Config *cost.Config `json:"config,omitempty"`
}
Expand Down Expand Up @@ -1150,18 +1150,18 @@ func (s *Server) parseRepoSampleRequest(ctx context.Context, r *http.Request) (*

// Set defaults
if req.SampleSize == 0 {
req.SampleSize = 25
req.SampleSize = 30
}
if req.Days == 0 {
req.Days = 90
}

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

// Set defaults
if req.SampleSize == 0 {
req.SampleSize = 25
req.SampleSize = 30
}
if req.Days == 0 {
req.Days = 90
}

// Validate reasonable limits (silently cap at 25)
// Validate reasonable limits (silently cap at 50)
if req.SampleSize < 1 {
return nil, errors.New("sample_size must be at least 1")
}
if req.SampleSize > 25 {
req.SampleSize = 25
if req.SampleSize > 50 {
req.SampleSize = 50
}
if req.Days < 1 || req.Days > 365 {
return nil, errors.New("days must be between 1 and 365")
Expand Down Expand Up @@ -1249,7 +1249,7 @@ func (s *Server) processRepoSample(ctx context.Context, req *RepoSampleRequest,
} else {
// Fetch all PRs modified since the date
var err error
prs, err = github.FetchPRsFromRepo(ctx, req.Owner, req.Repo, since, token)
prs, err = github.FetchPRsFromRepo(ctx, req.Owner, req.Repo, since, token, nil)
if err != nil {
return nil, fmt.Errorf("failed to fetch PRs: %w", err)
}
Expand Down Expand Up @@ -1350,7 +1350,7 @@ func (s *Server) processOrgSample(ctx context.Context, req *OrgSampleRequest, to
} else {
// Fetch all PRs across the org modified since the date
var err error
prs, err = github.FetchPRsFromOrg(ctx, req.Org, since, token)
prs, err = github.FetchPRsFromOrg(ctx, req.Org, since, token, nil)
if err != nil {
return nil, fmt.Errorf("failed to fetch PRs: %w", err)
}
Expand Down Expand Up @@ -1648,30 +1648,30 @@ func sendSSE(w http.ResponseWriter, update ProgressUpdate) error {
// startKeepAlive starts a goroutine that sends SSE keep-alive comments every 2 seconds.
// This prevents client-side timeouts during long operations.
// Returns a stop channel (to stop keep-alive) and an error channel (signals connection failure).
func startKeepAlive(w http.ResponseWriter) (chan struct{}, <-chan error) {
stop := make(chan struct{})
connErr := make(chan error, 1)
func startKeepAlive(w http.ResponseWriter) (stop chan struct{}, connErr <-chan error) {
stopChan := make(chan struct{})
errChan := make(chan error, 1)
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
defer close(connErr)
defer close(errChan)
for {
select {
case <-ticker.C:
// Send SSE comment (keeps connection alive, ignored by client)
if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil {
connErr <- fmt.Errorf("keepalive write failed: %w", err)
errChan <- fmt.Errorf("keepalive write failed: %w", err)
return
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
case <-stop:
case <-stopChan:
return
}
}
}()
return stop, connErr
return stopChan, errChan
}

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

// Fetch all PRs modified since the date
// Fetch all PRs modified since the date with progress updates
var err error
progressCallback := func(queryName string, page int, prCount int) {
logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{
Type: "fetching",
PR: 0,
Owner: req.Owner,
Repo: req.Repo,
Progress: fmt.Sprintf("Fetching %s PRs (page %d, %d PRs found)...", queryName, page, prCount),
}))
}
//nolint:contextcheck // Using background context intentionally to prevent client timeout from canceling work
prs, err = github.FetchPRsFromRepo(workCtx, req.Owner, req.Repo, since, token)
prs, err = github.FetchPRsFromRepo(workCtx, req.Owner, req.Repo, since, token, progressCallback)
if err != nil {
logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{
Type: "error",
Expand Down Expand Up @@ -1862,10 +1871,19 @@ func (s *Server) processOrgSampleWithProgress(ctx context.Context, req *OrgSampl
}
}()

// Fetch all PRs across the org modified since the date
// Fetch all PRs across the org modified since the date with progress updates
var err error
progressCallback := func(queryName string, page int, prCount int) {
logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{
Type: "fetching",
PR: 0,
Owner: req.Org,
Repo: "",
Progress: fmt.Sprintf("Fetching %s PRs (page %d, %d PRs found)...", queryName, page, prCount),
}))
}
//nolint:contextcheck // Using background context intentionally to prevent client timeout from canceling work
prs, err = github.FetchPRsFromOrg(workCtx, req.Org, since, token)
prs, err = github.FetchPRsFromOrg(workCtx, req.Org, since, token, progressCallback)
if err != nil {
logSSEError(ctx, s.logger, sendSSE(writer, ProgressUpdate{
Type: "error",
Expand Down
6 changes: 6 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,12 @@ func TestParseRequest(t *testing.T) {
}

func TestHandleCalculateNoToken(t *testing.T) {
// Clear environment variables that could provide a fallback token
// t.Setenv automatically restores the original value after the test
t.Setenv("GITHUB_TOKEN", "")
// Clear PATH to prevent gh CLI lookup
t.Setenv("PATH", "")

s := New()

reqBody := CalculateRequest{
Expand Down
Loading