Skip to content
Open
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
429 changes: 429 additions & 0 deletions .claude/skills/sonar-context-augmentation/SKILL.md

Large diffs are not rendered by default.

25 changes: 22 additions & 3 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func init() {
analyzeBgtasksCmd.Flags().String("from", "", "include tasks submitted on or after this date (YYYY-MM-DD, UTC)")
analyzeBgtasksCmd.Flags().String("to", "", "include tasks submitted on or before this date (YYYY-MM-DD, UTC)")
analyzeBgtasksCmd.Flags().String("report-name", "report-bgtasks", "output report filename (without .html extension)")
analyzeBgtasksCmd.Flags().IntSlice("estimate-workers", nil, "estimate analyses per hour for these worker counts (comma-separated, e.g. 4,8,16)")

analyzeCmd.AddCommand(analyzeBgtasksCmd)
rootCmd.AddCommand(analyzeCmd)
Expand All @@ -48,10 +49,14 @@ func runAnalyzeBgtasksCmd(cmd *cobra.Command, _ []string) error {
from, _ := cmd.Flags().GetString("from")
to, _ := cmd.Flags().GetString("to")
reportName, _ := cmd.Flags().GetString("report-name")
estimateWorkers, _ := cmd.Flags().GetIntSlice("estimate-workers")
if err := validateReportName(reportName); err != nil {
return err
}
return runAnalyze([]string{"bgtasks"}, dir, reportDir, from, to, withReportName(reportName))
if err := validateEstimateWorkers(estimateWorkers); err != nil {
return err
}
return runAnalyze([]string{"bgtasks"}, dir, reportDir, from, to, withReportName(reportName), withEstimateWorkers(estimateWorkers))
}

func validateReportName(name string) error {
Expand All @@ -67,8 +72,18 @@ func validateReportName(name string) error {
return nil
}

func validateEstimateWorkers(workers []int) error {
for _, w := range workers {
if w < 1 {
return fmt.Errorf("--estimate-workers: worker counts must be >= 1, got %d", w)
}
}
return nil
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

type analyzeOptions struct {
reportName string
reportName string
estimateWorkers []int
}

type analyzeOption func(*analyzeOptions)
Expand All @@ -77,6 +92,10 @@ func withReportName(name string) analyzeOption {
return func(o *analyzeOptions) { o.reportName = name }
}

func withEstimateWorkers(workers []int) analyzeOption {
return func(o *analyzeOptions) { o.estimateWorkers = workers }
}

func runAnalyze(targets []string, dir, reportDir, from, to string, opts ...analyzeOption) error {
o := analyzeOptions{reportName: "report-bgtasks"}
for _, opt := range opts {
Expand All @@ -95,7 +114,7 @@ func runAnalyze(targets []string, dir, reportDir, from, to string, opts ...analy
for _, target := range targets {
switch target {
case "bgtasks":
if err := analyzer.AnalyzeBgTasks(dir, reportDir, o.reportName, fromTime, toTime, logger); err != nil {
if err := analyzer.AnalyzeBgTasks(dir, reportDir, o.reportName, fromTime, toTime, logger, o.estimateWorkers); err != nil {
return fmt.Errorf("analyze bgtasks: %w", err)
}
default:
Expand Down
32 changes: 32 additions & 0 deletions cmd/analyze_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,38 @@ func TestAnalyzeHelp_ContainsDataDir(t *testing.T) {
}
}

func TestValidateEstimateWorkers(t *testing.T) {
cases := []struct {
input []int
wantErr bool
errContains string
}{
{input: []int{}, wantErr: false},
{input: []int{1}, wantErr: false},
{input: []int{1, 2, 4}, wantErr: false},
{input: []int{0}, wantErr: true, errContains: ">= 1"},
{input: []int{-1}, wantErr: true, errContains: ">= 1"},
{input: []int{2, 0, 4}, wantErr: true, errContains: ">= 1"},
}

for _, tc := range cases {
err := validateEstimateWorkers(tc.input)
if tc.wantErr {
if err == nil {
t.Errorf("validateEstimateWorkers(%v): expected error, got nil", tc.input)
continue
}
if !strings.Contains(err.Error(), tc.errContains) {
t.Errorf("validateEstimateWorkers(%v): error %q does not contain %q", tc.input, err.Error(), tc.errContains)
}
} else {
if err != nil {
t.Errorf("validateEstimateWorkers(%v): unexpected error: %v", tc.input, err)
}
}
}
}

func TestValidateReportName(t *testing.T) {
cases := []struct {
input string
Expand Down
7 changes: 6 additions & 1 deletion cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func init() {
runBgtasksCmd.Flags().String("from", "", "include tasks submitted on or after this date (YYYY-MM-DD, UTC)")
runBgtasksCmd.Flags().String("to", "", "include tasks submitted on or before this date (YYYY-MM-DD, UTC)")
runBgtasksCmd.Flags().String("report-name", "report-bgtasks", "output report filename (without .html extension)")
runBgtasksCmd.Flags().IntSlice("estimate-workers", nil, "estimate analyses per hour for these worker counts (comma-separated, e.g. 4,8,16)")

runCmd.AddCommand(runBgtasksCmd)
rootCmd.AddCommand(runCmd)
Expand Down Expand Up @@ -54,8 +55,12 @@ func runRunBgtasksCmd(cmd *cobra.Command, _ []string) error {
from, _ := cmd.Flags().GetString("from")
to, _ := cmd.Flags().GetString("to")
reportName, _ := cmd.Flags().GetString("report-name")
estimateWorkers, _ := cmd.Flags().GetIntSlice("estimate-workers")
if err := validateEstimateWorkers(estimateWorkers); err != nil {
return err
}
if err := runCollect([]string{"bgtasks"}, url, token, outDir, parallel); err != nil {
return err
}
return runAnalyze([]string{"bgtasks"}, outDir, reportDir, from, to, withReportName(reportName))
return runAnalyze([]string{"bgtasks"}, outDir, reportDir, from, to, withReportName(reportName), withEstimateWorkers(estimateWorkers))
}
80 changes: 79 additions & 1 deletion internal/analyzer/bgtasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package analyzer
import (
"fmt"
"log/slog"
"math"
"os"
"path/filepath"
"time"
Expand All @@ -17,7 +18,8 @@ import (

// AnalyzeBgTasks loads background task data from dir/bgtasks/, runs analysis, and writes
// the report to reportDir/reportName.html. from and to optionally bound the date filter.
func AnalyzeBgTasks(dir, reportDir, reportName string, from, to *time.Time, logger *slog.Logger) error {
// When estimateWorkers is non-empty, a capacity estimate is logged after the report is written.
func AnalyzeBgTasks(dir, reportDir, reportName string, from, to *time.Time, logger *slog.Logger, estimateWorkers []int) error {
logger.Info("analyzing background tasks")

dataDir := filepath.Join(dir, "bgtasks")
Expand Down Expand Up @@ -78,9 +80,85 @@ func AnalyzeBgTasks(dir, reportDir, reportName string, from, to *time.Time, logg
return fmt.Errorf("render report: %w", err)
}
logger.Info(fmt.Sprintf("report written to %s", reportPath))

if len(estimateWorkers) > 0 {
logCapacityEstimate(tasks, estimateWorkers, logger)
}
return nil
}

// logCapacityEstimate runs the capacity estimator and logs all results.
// It never returns an error — failures are logged and ignored so report generation is unaffected.
func logCapacityEstimate(tasks []bgtasks.BgTask, workerCounts []int, logger *slog.Logger) {
est, skipped, reason := bgtasks.EstimateCapacity(tasks, workerCounts)
if skipped {
logger.Info(fmt.Sprintf("capacity estimate: %s", reason))
return
}

// Step 0 — ISSUE_SYNC exclusion
logger.Debug(fmt.Sprintf("capacity estimate step 0: excluded %d ISSUE_SYNC tasks, %d non-ISSUE_SYNC tasks remain",
est.ExcludedCount, est.NonSyncCount))

// Step 1 — REPORT isolation
logger.Debug(fmt.Sprintf("capacity estimate step 1: %d REPORT tasks isolated", est.ReportCount))

// Step 2 — report share (sums at DEBUG, headline at INFO)
logger.Debug(fmt.Sprintf("capacity estimate step 2: Σ REPORT ms=%d, Σ non-ISSUE_SYNC ms=%d",
est.TotalReportMs, est.TotalNonSyncMs))
logger.Info(fmt.Sprintf("capacity estimate: REPORT (project analysis) accounts for %.1f%% of non-ISSUE_SYNC compute time",
est.ReportShare*100))

// Step 3 — category shares
for _, c := range est.WorkerEstimates[0].Categories {
logger.Debug(fmt.Sprintf("capacity estimate step 3: %-18s tasks=%d sum_ms=%d share=%.2f%%",
c.Label, c.BucketCount, c.BucketSumMs, c.Share*100))
}
logger.Debug(fmt.Sprintf("capacity estimate step 3: XXXL max observed = %dms", est.MaxObservedMs))

// Step 4 — representative costs (from the first worker estimate, which shares cats with all)
for _, c := range est.WorkerEstimates[0].Categories {
floor := ""
if c.FloorApplied {
floor = " (floor applied)"
}
logger.Debug(fmt.Sprintf("capacity estimate step 4: %-18s representative=%.1fs%s",
c.Label, c.RepresentativeSec, floor))
}

// Step 5 — per-worker intermediate values and results
for _, we := range est.WorkerEstimates {
capacitySec := float64(we.Workers) * secondsPerHour
reportCapacitySec := capacitySec * est.ReportShare
logger.Debug(fmt.Sprintf("capacity estimate step 5: workers=%d capacityPerHour=%.0fs reportCapacity=%.0fs",
we.Workers, capacitySec, reportCapacitySec))
for _, c := range we.Categories {
logger.Debug(fmt.Sprintf("capacity estimate step 5: workers=%d %-18s catCapacity=%.1fs",
we.Workers, c.Label, c.CatCapacitySec))
}

logger.Info(fmt.Sprintf("capacity estimate (per hour, ±20%%) for %d workers: total ≈ %d analyses (%d–%d)",
we.Workers, iround(we.TotalJobs), iround(we.TotalLow), iround(we.TotalHigh)))

line := " "
for _, c := range we.Categories {
if c.BucketCount == 0 {
continue
}
line += fmt.Sprintf("%s: ≈ %d (%d–%d) ", c.Label, iround(c.Jobs), iround(c.JobsLow), iround(c.JobsHigh))
}
if line != " " {
logger.Info(line)
}
}
}

const secondsPerHour = 3600

func iround(f float64) int {
return int(math.Round(f))
}

func filterByDate(tasks []bgtasks.BgTask, from, to *time.Time) []bgtasks.BgTask {
out := tasks[:0]
for _, t := range tasks {
Expand Down
Loading
Loading