diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3664f3e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,21 @@ +version: "2" + +linters: + # standard = errcheck, govet, ineffassign, staticcheck, unused + default: standard + enable: + - revive + - gocritic + - misspell + - unconvert + - whitespace + - unparam + exclusions: + rules: + # Both functions are deliberate stubs whose signatures (including error + # returns) will be exercised by planned implementations; unparam cannot + # see that future use, so suppress only these two findings. + - linters: [unparam] + text: 'parseRunReportMarkdown - result 1' + - linters: [unparam] + text: '\(\*Orchestrator\)\.commit - result 0' diff --git a/Makefile b/Makefile index 088be01..9d0f10b 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,12 @@ help: @echo " check - Run tests and lint" @echo " install - Build and install to Go bin directory" @echo " calibrate-providers - Compare local Claude/Codex session usage for calibration" - @echo " install-hooks - Install git pre-commit hook" + @echo " install-hooks - Install git pre-commit and commit-msg hooks" @echo " help - Show this help" -# Install git pre-commit hook +# Install git pre-commit and commit-msg hooks install-hooks: @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg + @echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)" diff --git a/cmd/nightshift/commands/budget.go b/cmd/nightshift/commands/budget.go index 424ee78..65eeb58 100644 --- a/cmd/nightshift/commands/budget.go +++ b/cmd/nightshift/commands/budget.go @@ -21,7 +21,7 @@ var budgetCmd = &cobra.Command{ Long: `Display current budget status and usage. Shows spending across all providers or a specific provider.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { provider, _ := cmd.Flags().GetString("provider") return runBudget(provider) }, @@ -114,14 +114,14 @@ func runBudget(filterProvider string) error { return nil } -func printProviderBudget(mgr *budget.Manager, cfg *config.Config, provName string, source budget.BudgetSource, snapCollector *snapshots.Collector, codex *providers.Codex) error { +func printProviderBudget(mgr *budget.Manager, cfg *config.Config, provName string, source budget.Source, snapCollector *snapshots.Collector, codex *providers.Codex) error { result, err := mgr.CalculateAllowance(provName) if err != nil { return err } claudeApprox := provName == "claude" && result.UsedPercentSource == "jsonl-fallback" - estimate := budget.BudgetEstimate{ + estimate := budget.Estimate{ WeeklyTokens: int64(cfg.GetProviderBudget(provName)), Source: "config", } @@ -297,7 +297,7 @@ func printProviderBudget(mgr *budget.Manager, cfg *config.Config, provName strin } // printTokenAccountingNote adds a brief note about how tokens are counted. -func printTokenAccountingNote(provider string, estimate budget.BudgetEstimate) { +func printTokenAccountingNote(provider string, estimate budget.Estimate) { if estimate.Source != "calibrated" && estimate.Source != "scraped" { return } @@ -319,7 +319,7 @@ func formatTokens64(tokens int64) string { return fmt.Sprintf("%d", tokens) } -func formatBudgetMeta(estimate budget.BudgetEstimate) string { +func formatBudgetMeta(estimate budget.Estimate) string { if estimate.Source == "" { return "" } diff --git a/cmd/nightshift/commands/commit.go b/cmd/nightshift/commands/commit.go new file mode 100644 index 0000000..c1a6089 --- /dev/null +++ b/cmd/nightshift/commands/commit.go @@ -0,0 +1,101 @@ +package commands + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/marcus/nightshift/internal/commits" + "github.com/spf13/cobra" +) + +var commitCmd = &cobra.Command{ + Use: "commit", + Short: "Conventional Commits helpers", + Long: `Tools for working with Conventional Commits messages. + +Use "commit normalize" to validate and reformat a commit message so it +follows the project's rules (type prefix, lowercase type, subject length, +and wrapped body).`, +} + +var commitNormalizeCmd = &cobra.Command{ + Use: "normalize [MESSAGE]", + Short: "Normalize a commit message to Conventional Commits format", + Long: `Validate and rewrite a commit message into canonical Conventional +Commits form. + +The message is read from a positional argument, from a file passed via +--file (typically .git/COMMIT_EDITMSG by a commit-msg hook), or from stdin +when no argument and no --file are given. + + nightshift commit normalize "feat: add login" + nightshift commit normalize --file .git/COMMIT_EDITMSG + git log -1 --pretty=%B | nightshift commit normalize + +Use --check to only validate without rewriting: nothing is printed and the +exit code is non-zero when the message is invalid or not already in canonical +form.`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + check, _ := cmd.Flags().GetBool("check") + file, _ := cmd.Flags().GetString("file") + + raw, err := readCommitMessage(args, file) + if err != nil { + return err + } + + normalized, err := commits.Normalize(raw) + if err != nil { + return err + } + + if check { + if !isCanonical(raw, normalized) { + return errors.New("message is not in canonical Conventional Commits form (run without --check to print the normalized message)") + } + return nil + } + if _, err := fmt.Fprintln(os.Stdout, normalized); err != nil { + return err + } + return nil + }, +} + +func init() { + commitNormalizeCmd.Flags().BoolP("check", "c", false, "Only validate; do not rewrite") + commitNormalizeCmd.Flags().StringP("file", "f", "", "Read the message from this file (use by the commit-msg hook)") + commitCmd.AddCommand(commitNormalizeCmd) + rootCmd.AddCommand(commitCmd) +} + +// readCommitMessage resolves the message source in order: positional arg, +// --file, then stdin. +func readCommitMessage(args []string, file string) (string, error) { + if len(args) == 1 { + return args[0], nil + } + if file != "" { + b, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("read %s: %w", file, err) + } + return string(b), nil + } + b, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("read stdin: %w", err) + } + return string(b), nil +} + +// isCanonical reports whether raw is already in normalized form, ignoring a +// single trailing newline. +func isCanonical(raw, normalized string) bool { + return strings.TrimSuffix(raw, "\n") == normalized +} diff --git a/cmd/nightshift/commands/commit_test.go b/cmd/nightshift/commands/commit_test.go new file mode 100644 index 0000000..28cfaf0 --- /dev/null +++ b/cmd/nightshift/commands/commit_test.go @@ -0,0 +1,61 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadCommitMessage(t *testing.T) { + t.Run("positional argument wins", func(t *testing.T) { + got, err := readCommitMessage([]string{"feat: add thing"}, "ignored-file") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "feat: add thing" { + t.Errorf("got %q, want %q", got, "feat: add thing") + } + }) + + t.Run("reads from file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "msg") + if err := os.WriteFile(path, []byte("fix: correct thing\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := readCommitMessage(nil, path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "fix: correct thing\n" { + t.Errorf("got %q, want %q", got, "fix: correct thing\n") + } + }) + + t.Run("missing file errors", func(t *testing.T) { + if _, err := readCommitMessage(nil, filepath.Join(t.TempDir(), "nope")); err == nil { + t.Error("expected an error for a missing file") + } + }) +} + +func TestIsCanonical(t *testing.T) { + tests := []struct { + name string + raw string + normalized string + want bool + }{ + {"exact match", "feat: add login", "feat: add login", true}, + {"trailing newline ignored", "feat: add login\n", "feat: add login", true}, + {"needs rewriting", "FEAT: add login", "feat: add login", false}, + {"trailing blank lines", "feat: add login\n\n", "feat: add login", false}, + {"extra whitespace", " feat: add login ", "feat: add login", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isCanonical(tc.raw, tc.normalized); got != tc.want { + t.Errorf("isCanonical(%q, %q) = %v, want %v", tc.raw, tc.normalized, got, tc.want) + } + }) + } +} diff --git a/cmd/nightshift/commands/config.go b/cmd/nightshift/commands/config.go index 4300470..db4c6bc 100644 --- a/cmd/nightshift/commands/config.go +++ b/cmd/nightshift/commands/config.go @@ -21,7 +21,7 @@ var configCmd = &cobra.Command{ Shows current configuration merged from global and project configs. Use subcommands to get/set specific values or validate the config.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { return runConfigShow() }, } @@ -36,7 +36,7 @@ Examples: nightshift config get providers.claude.enabled nightshift config get logging.level`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { return runConfigGet(args[0]) }, } @@ -66,7 +66,7 @@ var configValidateCmd = &cobra.Command{ Long: `Validate the current configuration. Checks both global and project configs for errors.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { return runConfigValidate() }, } @@ -370,7 +370,7 @@ func printStruct(v reflect.Value, indent int) { case reflect.Struct: fmt.Printf("%s%s:\n", prefix, tag) printStruct(value, indent+1) - case reflect.Ptr: + case reflect.Pointer: if !value.IsNil() { if value.Elem().Kind() == reflect.Struct { fmt.Printf("%s%s:\n", prefix, tag) @@ -408,7 +408,7 @@ func printStruct(v reflect.Value, indent int) { func isZero(v reflect.Value) bool { switch v.Kind() { - case reflect.Ptr, reflect.Interface: + case reflect.Pointer, reflect.Interface: return v.IsNil() case reflect.Slice, reflect.Map: return v.Len() == 0 diff --git a/cmd/nightshift/commands/daemon.go b/cmd/nightshift/commands/daemon.go index d082b0e..d74ba73 100644 --- a/cmd/nightshift/commands/daemon.go +++ b/cmd/nightshift/commands/daemon.go @@ -129,7 +129,7 @@ func isDaemonRunning() (bool, int) { return isProcessRunning(pid), pid } -func runDaemonStart(cmd *cobra.Command, args []string) error { +func runDaemonStart(_ *cobra.Command, _ []string) error { // Check if already running if running, pid := isDaemonRunning(); running { return fmt.Errorf("daemon already running (pid %d)", pid) @@ -564,28 +564,30 @@ func takeSnapshot(ctx context.Context, cfg *config.Config, database *db.DB, log if cfg.Providers.Claude.Enabled { snapshot, err := collector.TakeSnapshot(ctx, "claude") - if err != nil { + switch { + case err != nil: log.Warnf("snapshot claude: %v", err) - } else if snapshot.ScrapedPct != nil { + case snapshot.ScrapedPct != nil: log.Infof("snapshot claude: %.1f%%", *snapshot.ScrapedPct) - } else { + default: log.Info("snapshot claude: local-only") } } if cfg.Providers.Codex.Enabled { snapshot, err := collector.TakeSnapshot(ctx, "codex") - if err != nil { + switch { + case err != nil: log.Warnf("snapshot codex: %v", err) - } else if snapshot.ScrapedPct != nil { + case snapshot.ScrapedPct != nil: log.Infof("snapshot codex: %.1f%%", *snapshot.ScrapedPct) - } else { + default: log.Info("snapshot codex: local-only") } } } -func pruneSnapshots(ctx context.Context, cfg *config.Config, database *db.DB, log *logging.Logger) { +func pruneSnapshots(_ context.Context, cfg *config.Config, database *db.DB, log *logging.Logger) { collector := snapshots.NewCollector(database, nil, nil, nil, nil, weekStartDayFromConfig(cfg)) deleted, err := collector.Prune(cfg.Budget.SnapshotRetentionDays) if err != nil { @@ -609,7 +611,7 @@ func weekStartDayFromConfig(cfg *config.Config) time.Weekday { } } -func runDaemonStop(cmd *cobra.Command, args []string) error { +func runDaemonStop(_ *cobra.Command, _ []string) error { running, pid := isDaemonRunning() if !running { // Check if PID file exists but process is dead @@ -657,7 +659,7 @@ func runDaemonStop(cmd *cobra.Command, args []string) error { } } -func runDaemonStatus(cmd *cobra.Command, args []string) error { +func runDaemonStatus(_ *cobra.Command, _ []string) error { running, pid := isDaemonRunning() if !running { diff --git a/cmd/nightshift/commands/doctor.go b/cmd/nightshift/commands/doctor.go index 1a0f0ee..f5a5630 100644 --- a/cmd/nightshift/commands/doctor.go +++ b/cmd/nightshift/commands/doctor.go @@ -50,7 +50,7 @@ func init() { rootCmd.AddCommand(doctorCmd) } -func runDoctor(cmd *cobra.Command, args []string) error { +func runDoctor(_ *cobra.Command, _ []string) error { // Augment PATH the same way 'run' does so CLI checks are accurate. ensurePATH() diff --git a/cmd/nightshift/commands/init.go b/cmd/nightshift/commands/init.go index 2ade6bf..517d4a3 100644 --- a/cmd/nightshift/commands/init.go +++ b/cmd/nightshift/commands/init.go @@ -36,7 +36,7 @@ func init() { rootCmd.AddCommand(initCmd) } -func runInit(cmd *cobra.Command, args []string) error { +func runInit(cmd *cobra.Command, _ []string) error { global, _ := cmd.Flags().GetBool("global") force, _ := cmd.Flags().GetBool("force") diff --git a/cmd/nightshift/commands/install.go b/cmd/nightshift/commands/install.go index c25ad96..e553858 100644 --- a/cmd/nightshift/commands/install.go +++ b/cmd/nightshift/commands/install.go @@ -55,7 +55,7 @@ func init() { } // runInstall implements the install command -func runInstall(cmd *cobra.Command, args []string) error { +func runInstall(_ *cobra.Command, args []string) error { // Determine service type serviceType := "" if len(args) > 0 { @@ -378,7 +378,7 @@ func installCron(binaryPath string, cfg *config.Config) error { } // runUninstall implements the uninstall command -func runUninstall(cmd *cobra.Command, args []string) error { +func runUninstall(_ *cobra.Command, _ []string) error { var errors []string removed := false diff --git a/cmd/nightshift/commands/logs.go b/cmd/nightshift/commands/logs.go index c33e7f9..ff60f6f 100644 --- a/cmd/nightshift/commands/logs.go +++ b/cmd/nightshift/commands/logs.go @@ -24,7 +24,7 @@ var logsCmd = &cobra.Command{ Long: `View nightshift logs. Displays recent log entries. Use --follow to stream logs in real-time.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { tail, _ := cmd.Flags().GetInt("tail") follow, _ := cmd.Flags().GetBool("follow") export, _ := cmd.Flags().GetString("export") diff --git a/cmd/nightshift/commands/preview.go b/cmd/nightshift/commands/preview.go index 8691939..7ef7964 100644 --- a/cmd/nightshift/commands/preview.go +++ b/cmd/nightshift/commands/preview.go @@ -44,7 +44,7 @@ func init() { rootCmd.AddCommand(previewCmd) } -func runPreview(cmd *cobra.Command, args []string) error { +func runPreview(cmd *cobra.Command, _ []string) error { runs, _ := cmd.Flags().GetInt("runs") projectPath, _ := cmd.Flags().GetString("project") taskFilter, _ := cmd.Flags().GetString("task") diff --git a/cmd/nightshift/commands/preview_output.go b/cmd/nightshift/commands/preview_output.go index b3a664c..8ab46e7 100644 --- a/cmd/nightshift/commands/preview_output.go +++ b/cmd/nightshift/commands/preview_output.go @@ -83,11 +83,12 @@ func renderPreviewText(result *previewResult, opts previewTextOptions) string { fmt.Fprintln(b, line) } } - if result.TaskFilter != "" { + switch { + case result.TaskFilter != "": fmt.Fprintf(b, " Task filter: %s\n", result.TaskFilter) - } else if len(result.EnabledTasks) == 0 { + case len(result.EnabledTasks) == 0: b.WriteString(" Task filter: all enabled tasks (none explicitly enabled)\n") - } else { + default: fmt.Fprintf(b, " Task filter: enabled list (%d) [%s]\n", len(result.EnabledTasks), strings.Join(result.EnabledTasks, ", ")) } if opts.Explain && result.ProjectCount > 1 { @@ -347,7 +348,7 @@ func renderDiagnosticsText(b *strings.Builder, styles previewStyles, diagnostics renderCooldownsText(b, styles, diagnostics.Cooldowns, indent) } -func renderCooldownsText(b *strings.Builder, styles previewStyles, cooldowns []previewCooldownEntry, indent string) { +func renderCooldownsText(b *strings.Builder, _ previewStyles, cooldowns []previewCooldownEntry, indent string) { if len(cooldowns) == 0 { return } diff --git a/cmd/nightshift/commands/report.go b/cmd/nightshift/commands/report.go index 37cc9cd..1f6329c 100644 --- a/cmd/nightshift/commands/report.go +++ b/cmd/nightshift/commands/report.go @@ -47,7 +47,7 @@ var reportCmd = &cobra.Command{ Long: `View structured reports from recent nightshift runs. By default, shows a polished overview of what happened during the last night.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { opts := reportOptions{} opts.reportType, _ = cmd.Flags().GetString("report") opts.period, _ = cmd.Flags().GetString("period") @@ -895,16 +895,17 @@ func renderReportBudget(styles reportStyles, runs []reportRun) string { b.WriteString(styles.Accent.Render(header)) b.WriteString("\n") - if summary.BudgetStart > 0 { + switch { + case summary.BudgetStart > 0: fmt.Fprintf(&b, " %s %s used / %s start (%s remaining)\n", styles.Label.Render("Budget:"), formatTokensCompact(summary.TokensUsed), formatTokensCompact(summary.BudgetStart), formatTokensCompact(summary.BudgetRemaining), ) - } else if summary.TokensUsed > 0 { + case summary.TokensUsed > 0: fmt.Fprintf(&b, " %s %s\n", styles.Label.Render("Tokens:"), formatTokensCompact(summary.TokensUsed)) - } else { + default: b.WriteString(" No budget data recorded\n") } diff --git a/cmd/nightshift/commands/run.go b/cmd/nightshift/commands/run.go index 9681002..1c81b8e 100644 --- a/cmd/nightshift/commands/run.go +++ b/cmd/nightshift/commands/run.go @@ -117,7 +117,7 @@ func init() { rootCmd.AddCommand(runCmd) } -func runRun(cmd *cobra.Command, args []string) error { +func runRun(cmd *cobra.Command, _ []string) error { dryRun, _ := cmd.Flags().GetBool("dry-run") projectPath, _ := cmd.Flags().GetString("project") taskFilter, _ := cmd.Flags().GetString("task") @@ -465,7 +465,8 @@ func buildPreflight(p executeRunParams) (*preflightPlan, error) { // Select tasks var selectedTasks []tasks.ScoredTask - if p.taskFilter != "" { + switch { + case p.taskFilter != "": def, err := tasks.GetDefinition(tasks.TaskType(p.taskFilter)) if err != nil { return nil, fmt.Errorf("unknown task type: %s", p.taskFilter) @@ -475,7 +476,7 @@ func buildPreflight(p executeRunParams) (*preflightPlan, error) { Score: p.selector.ScoreTask(def.Type, projectPath), Project: projectPath, }} - } else if p.randomTask { + case p.randomTask: taskBudget := choice.allowance.Allowance if p.ignoreBudget { taskBudget = math.MaxInt64 @@ -483,7 +484,7 @@ func buildPreflight(p executeRunParams) (*preflightPlan, error) { if picked := p.selector.SelectRandom(taskBudget, projectPath); picked != nil { selectedTasks = []tasks.ScoredTask{*picked} } - } else { + default: n := p.maxTasks if n <= 0 { n = 1 diff --git a/cmd/nightshift/commands/run_test.go b/cmd/nightshift/commands/run_test.go index 256f698..54a0659 100644 --- a/cmd/nightshift/commands/run_test.go +++ b/cmd/nightshift/commands/run_test.go @@ -25,7 +25,7 @@ type mockUsage struct { func (m *mockUsage) Name() string { return m.name } -func (m *mockUsage) GetUsedPercent(mode string, weeklyBudget int64) (float64, error) { +func (m *mockUsage) GetUsedPercent(_ string, _ int64) (float64, error) { return m.pct, nil } @@ -33,7 +33,7 @@ type mockCodexUsage struct { mockUsage } -func (m *mockCodexUsage) GetResetTime(mode string) (time.Time, error) { +func (m *mockCodexUsage) GetResetTime(_ string) (time.Time, error) { return time.Time{}, nil } @@ -41,7 +41,7 @@ type mockCopilotUsage struct { mockUsage } -func (m *mockCopilotUsage) GetResetTime(mode string) (time.Time, error) { +func (m *mockCopilotUsage) GetResetTime(_ string) (time.Time, error) { return time.Time{}, nil } diff --git a/cmd/nightshift/commands/setup.go b/cmd/nightshift/commands/setup.go index c098142..896d891 100644 --- a/cmd/nightshift/commands/setup.go +++ b/cmd/nightshift/commands/setup.go @@ -36,7 +36,7 @@ var setupCmd = &cobra.Command{ Creates/updates the global config, validates providers, runs a snapshot, previews the next run, and optionally installs/enables the daemon.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { model, err := newSetupModel() if err != nil { return err @@ -1507,11 +1507,12 @@ func renderEnvChecks(cfg *config.Config) string { // Check for Copilot CLI (gh or copilot binary) _, ghErr := execLookPath("gh") _, copilotErr := execLookPath("copilot") - if ghErr != nil && copilotErr != nil { + switch { + case ghErr != nil && copilotErr != nil: fmt.Fprintf(&b, " %s %s\n", styleWarn.Render("Note:"), "Copilot CLI not found (install via 'gh' or native 'copilot')") - } else if ghErr == nil { + case ghErr == nil: fmt.Fprintf(&b, " %s %s\n", styleOk.Render("OK:"), "gh CLI available (use 'gh copilot')") - } else { + default: fmt.Fprintf(&b, " %s %s\n", styleOk.Render("OK:"), "copilot CLI available") } if cfg.Providers.Claude.Enabled { @@ -1910,11 +1911,11 @@ func uninstallService(service string) error { func mustExecutablePath() string { path, _ := os.Executable() - real, err := filepath.EvalSymlinks(path) + resolved, err := filepath.EvalSymlinks(path) if err != nil { return path } - return real + return resolved } func writeGlobalConfig(cfg *config.Config) error { diff --git a/cmd/nightshift/commands/snapshot.go b/cmd/nightshift/commands/snapshot.go index ed19318..2d5ce30 100644 --- a/cmd/nightshift/commands/snapshot.go +++ b/cmd/nightshift/commands/snapshot.go @@ -32,7 +32,7 @@ display via tmux to get the provider's own usage percentage. Inference If both local tokens and scraped % are available, nightshift infers the weekly budget: budget = local_tokens / (scraped% / 100).`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { provider, _ := cmd.Flags().GetString("provider") localOnly, _ := cmd.Flags().GetBool("local-only") return runBudgetSnapshot(cmd, provider, localOnly) @@ -43,7 +43,7 @@ var budgetHistoryCmd = &cobra.Command{ Use: "history", Short: "Show recent budget snapshots", Long: `Show recent usage snapshots for budget calibration.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { provider, _ := cmd.Flags().GetString("provider") n, _ := cmd.Flags().GetInt("n") return runBudgetHistory(provider, n) @@ -54,7 +54,7 @@ var budgetCalibrateCmd = &cobra.Command{ Use: "calibrate", Short: "Show calibration status", Long: `Show inferred budget calibration status for providers.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { provider, _ := cmd.Flags().GetString("provider") return runBudgetCalibrate(provider) }, @@ -99,13 +99,14 @@ func runBudgetSnapshot(cmd *cobra.Command, filterProvider string, localOnly bool // Determine scraper availability and reason if disabled scraper := snapshots.UsageScraper(nil) scrapeDisabledReason := "" - if localOnly { + switch { + case localOnly: scrapeDisabledReason = "--local-only flag set" - } else if !cfg.Budget.CalibrateEnabled { + case !cfg.Budget.CalibrateEnabled: scrapeDisabledReason = "calibrate_enabled is false in config" - } else if strings.EqualFold(cfg.Budget.BillingMode, "api") { + case strings.EqualFold(cfg.Budget.BillingMode, "api"): scrapeDisabledReason = "billing_mode is 'api' (scraping only works with subscription)" - } else { + default: scraper = tmuxScraper{} } @@ -144,17 +145,18 @@ func runBudgetSnapshot(cmd *cobra.Command, filterProvider string, localOnly bool fmt.Printf(" Data source: %s\n", dataSource) // Scraping status - if scraper == nil { + switch { + case scraper == nil: fmt.Printf(" Scraping: disabled -- %s\n", scrapeDisabledReason) - } else if snapshot.ScrapeErr != nil { + case snapshot.ScrapeErr != nil: fmt.Printf(" Scraping: FAILED -- %v\n", snapshot.ScrapeErr) - } else if snapshot.ScrapedPct != nil { + case snapshot.ScrapedPct != nil: cmd := "/usage" if provName == "codex" { cmd = "/status" } fmt.Printf(" Scraped: %.1f%% used (via tmux %s)\n", *snapshot.ScrapedPct, cmd) - } else { + default: fmt.Printf(" Scraping: no data returned\n") } diff --git a/cmd/nightshift/commands/stats.go b/cmd/nightshift/commands/stats.go index c5b9c80..74e81bc 100644 --- a/cmd/nightshift/commands/stats.go +++ b/cmd/nightshift/commands/stats.go @@ -24,7 +24,7 @@ var statsCmd = &cobra.Command{ Shows run counts, task outcomes, token usage, budget projections, and per-project breakdowns. Use --json for machine-readable output.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { jsonOutput, _ := cmd.Flags().GetBool("json") period, _ := cmd.Flags().GetString("period") return runStats(jsonOutput, period) @@ -70,7 +70,7 @@ func runStats(jsonOutput bool, period string) error { // filterStatsByPeriod recomputes stats from reports filtered by the given period. // For period filtering we reload reports, filter by date, and recompute. -func filterStatsByPeriod(original *stats.StatsResult, s *stats.Stats, reportsDir string, period string) *stats.StatsResult { +func filterStatsByPeriod(original *stats.Result, s *stats.Stats, reportsDir string, period string) *stats.Result { _ = s // stats.Stats doesn't expose period filtering; we do it here runs, err := loadRunReports(reportsDir) @@ -107,16 +107,16 @@ func filterStatsByPeriod(original *stats.StatsResult, s *stats.Stats, reportsDir } if len(filtered) == 0 { - return &stats.StatsResult{TaskTypeBreakdown: make(map[string]int)} + return &stats.Result{TaskTypeBreakdown: make(map[string]int)} } // Recompute stats from filtered runs return computeStatsFromRuns(filtered) } -// computeStatsFromRuns builds a StatsResult from a set of report runs. -func computeStatsFromRuns(runs []reportRun) *stats.StatsResult { - result := &stats.StatsResult{ +// computeStatsFromRuns builds a Result from a set of report runs. +func computeStatsFromRuns(runs []reportRun) *stats.Result { + result := &stats.Result{ TotalRuns: len(runs), TaskTypeBreakdown: make(map[string]int), } @@ -245,13 +245,13 @@ func computeStatsFromRuns(runs []reportRun) *stats.StatsResult { return result } -func renderStatsJSON(result *stats.StatsResult) error { +func renderStatsJSON(result *stats.Result) error { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(result) } -func renderStatsHuman(result *stats.StatsResult) error { +func renderStatsHuman(result *stats.Result) error { fmt.Println("Nightshift Stats") fmt.Println("================================") fmt.Println() @@ -313,7 +313,8 @@ func renderStatsHuman(result *stats.StatsResult) error { fmt.Printf(" Reset: %s\n", bp.ResetHint) } - if bp.EstExhaustAt != nil { + switch { + case bp.EstExhaustAt != nil: if time.Until(*bp.EstExhaustAt) <= 0 { fmt.Printf(" Projected: budget may already be exhausted\n") } else { @@ -326,11 +327,11 @@ func renderStatsHuman(result *stats.StatsResult) error { } } } - } else if bp.RemainingTokens <= 0 { + case bp.RemainingTokens <= 0: fmt.Printf(" At current rate: budget may be exhausted\n") - } else if bp.EstDaysRemaining > 0 { + case bp.EstDaysRemaining > 0: fmt.Printf(" At current rate: ~%d days until budget exhausted\n", bp.EstDaysRemaining) - } else { + default: fmt.Printf(" At current rate: budget may be exhausted\n") } diff --git a/cmd/nightshift/commands/status.go b/cmd/nightshift/commands/status.go index 1a6883c..ba1541d 100644 --- a/cmd/nightshift/commands/status.go +++ b/cmd/nightshift/commands/status.go @@ -19,7 +19,7 @@ var statusCmd = &cobra.Command{ Long: `Display nightshift run history and activity. Shows the last N runs (default: 5) or today's activity summary.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { last, _ := cmd.Flags().GetInt("last") today, _ := cmd.Flags().GetBool("today") diff --git a/cmd/nightshift/commands/task.go b/cmd/nightshift/commands/task.go index 6b1820e..22a20fd 100644 --- a/cmd/nightshift/commands/task.go +++ b/cmd/nightshift/commands/task.go @@ -79,7 +79,7 @@ func init() { rootCmd.AddCommand(taskCmd) } -func runTaskList(cmd *cobra.Command, args []string) error { +func runTaskList(cmd *cobra.Command, _ []string) error { categoryFilter, _ := cmd.Flags().GetString("category") costFilter, _ := cmd.Flags().GetString("cost") asJSON, _ := cmd.Flags().GetBool("json") @@ -113,7 +113,7 @@ func runTaskList(cmd *cobra.Command, args []string) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintln(w, "TYPE\tNAME\tCATEGORY\tCOST\tTOKENS\tRISK") for _, d := range defs { - min, max := d.EstimatedTokens() + minTokens, maxTokens := d.EstimatedTokens() typeStr := string(d.Type) if tasks.IsCustom(d.Type) { typeStr += " [custom]" @@ -123,8 +123,8 @@ func runTaskList(cmd *cobra.Command, args []string) error { d.Name, categoryShort(d.Category), costShort(d.CostTier), - formatK(min), - formatK(max), + formatK(minTokens), + formatK(maxTokens), d.RiskLevel, ) } @@ -158,12 +158,12 @@ func runTaskShow(cmd *cobra.Command, args []string) error { return printTaskShowJSON(def, prompt) } - min, max := def.EstimatedTokens() + minTokens, maxTokens := def.EstimatedTokens() fmt.Printf("Task: %s\n", def.Name) fmt.Printf("Type: %s\n", def.Type) fmt.Printf("Category: %s\n", def.Category) fmt.Printf("Cost: %s\n", def.CostTier) - fmt.Printf("Tokens: %s - %s\n", formatK(min), formatK(max)) + fmt.Printf("Tokens: %s - %s\n", formatK(minTokens), formatK(maxTokens)) fmt.Printf("Risk: %s\n", def.RiskLevel) if tasks.IsCustom(def.Type) { fmt.Printf("Custom: yes\n") @@ -251,8 +251,8 @@ func runTaskRun(cmd *cobra.Command, args []string) error { } fmt.Printf("Timeout: %s\n", timeout) - min, max := def.EstimatedTokens() - fmt.Printf("Est: %s-%s tokens\n", formatK(min), formatK(max)) + minTokens, maxTokens := def.EstimatedTokens() + fmt.Printf("Est: %s-%s tokens\n", formatK(minTokens), formatK(maxTokens)) if dryRun { fmt.Println("\n[dry-run] Would send this prompt:") @@ -434,15 +434,15 @@ type taskListEntry struct { func printTaskListJSON(defs []tasks.TaskDefinition) error { entries := make([]taskListEntry, len(defs)) for i, d := range defs { - min, max := d.EstimatedTokens() + minTokens, maxTokens := d.EstimatedTokens() entries[i] = taskListEntry{ Type: string(d.Type), Name: d.Name, Category: categoryShort(d.Category), Description: d.Description, Cost: costShort(d.CostTier), - MinTokens: min, - MaxTokens: max, + MinTokens: minTokens, + MaxTokens: maxTokens, Risk: d.RiskLevel.String(), Custom: tasks.IsCustom(d.Type), } @@ -466,15 +466,15 @@ type taskShowEntry struct { } func printTaskShowJSON(def tasks.TaskDefinition, prompt string) error { - min, max := def.EstimatedTokens() + minTokens, maxTokens := def.EstimatedTokens() entry := taskShowEntry{ Type: string(def.Type), Name: def.Name, Category: categoryShort(def.Category), Description: def.Description, Cost: costShort(def.CostTier), - MinTokens: min, - MaxTokens: max, + MinTokens: minTokens, + MaxTokens: maxTokens, Risk: def.RiskLevel.String(), Custom: tasks.IsCustom(def.Type), Prompt: prompt, diff --git a/cmd/provider-calibration/main.go b/cmd/provider-calibration/main.go index ee573b2..4b6430a 100644 --- a/cmd/provider-calibration/main.go +++ b/cmd/provider-calibration/main.go @@ -1,3 +1,4 @@ +// Package main implements a diagnostic tool for calibrating provider budgets. package main import ( diff --git a/docs/commit-messages.md b/docs/commit-messages.md new file mode 100644 index 0000000..29ddc15 --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,51 @@ +# Commit Messages + +Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/) +for all commit messages. This keeps the history readable and lets tooling +derive changelogs automatically. + +## Format + +``` +(): + + +``` + +- **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `test`, + `chore`, `perf`, `build`, `ci`. +- **scope** — optional, e.g. `fix(api): ...`. +- **breaking change** — append `!` after the type or scope, e.g. `feat(api)!:` — + a breaking change must also be called out in the body. +- **subject** — lowercase, imperative mood, no trailing period, max 72 chars. +- **body** — optional, wrapped at 72 columns, separated from the subject by a + blank line. Paragraph breaks are preserved and bullet-list items (`- ` / + `* `) stay on their own lines. + +## The `commit normalize` command + +Validate and reformat a message: + +```sh +nightshift commit normalize "feat: add login screen" +nightshift commit normalize --file .git/COMMIT_EDITMSG +git log -1 --pretty=%B | nightshift commit normalize +``` + +Add `--check` to validate only: nothing is printed, and the command exits +non-zero when a message cannot be normalized (missing/unknown type, capitalized +or overlong subject) or is not already in canonical form. + +## commit-msg hook + +To enforce the rules locally, install the hook: + +```sh +make install-hooks +# or manually: +ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg +``` + +The hook normalizes your message file in place before the commit is created and +rejects messages that cannot be fixed automatically. Bypass it with +`git commit --no-verify`. diff --git a/internal/agents/agent.go b/internal/agents/agent.go index 97894a9..1bf351d 100644 --- a/internal/agents/agent.go +++ b/internal/agents/agent.go @@ -41,8 +41,9 @@ func (r *ExecuteResult) IsSuccess() bool { return r.ExitCode == 0 && r.Error == "" } -// truncate returns s trimmed to maxLen characters, appending "..." if truncated. -func truncate(s string, maxLen int) string { +// truncate returns s trimmed to 2000 characters, appending "..." if truncated. +func truncate(s string) string { + const maxLen = 2000 if len(s) <= maxLen { return s } diff --git a/internal/agents/claude.go b/internal/agents/claude.go index c34d513..d0e4741 100644 --- a/internal/agents/claude.go +++ b/internal/agents/claude.go @@ -1,4 +1,4 @@ -// claude.go implements the Agent interface for Claude Code CLI. +// Package agents implements the Agent interface for Claude Code CLI. package agents import ( @@ -164,7 +164,7 @@ func (a *ClaudeAgent) Execute(ctx context.Context, opts ExecuteOptions) (*Execut if ctx.Err() == context.DeadlineExceeded { result.Error = fmt.Sprintf("timeout after %v", timeout) if stderr != "" { - result.Error = fmt.Sprintf("timeout after %v; stderr: %s", timeout, truncate(stderr, 2000)) + result.Error = fmt.Sprintf("timeout after %v; stderr: %s", timeout, truncate(stderr)) } if stdout != "" { result.Output = stdout @@ -181,7 +181,7 @@ func (a *ClaudeAgent) Execute(ctx context.Context, opts ExecuteOptions) (*Execut } else { result.Error = err.Error() if stderr != "" { - result.Error = fmt.Sprintf("%s; stderr: %s", err.Error(), truncate(stderr, 2000)) + result.Error = fmt.Sprintf("%s; stderr: %s", err.Error(), truncate(stderr)) } } return result, err diff --git a/internal/agents/codex.go b/internal/agents/codex.go index c366400..13a5493 100644 --- a/internal/agents/codex.go +++ b/internal/agents/codex.go @@ -1,4 +1,4 @@ -// codex.go implements the Agent interface for OpenAI Codex CLI. +// Package agents implements the Agent interface for OpenAI Codex CLI. package agents import ( @@ -122,7 +122,7 @@ func (a *CodexAgent) Execute(ctx context.Context, opts ExecuteOptions) (*Execute if ctx.Err() == context.DeadlineExceeded { result.Error = fmt.Sprintf("timeout after %v", timeout) if stderr != "" { - result.Error = fmt.Sprintf("timeout after %v; stderr: %s", timeout, truncate(stderr, 2000)) + result.Error = fmt.Sprintf("timeout after %v; stderr: %s", timeout, truncate(stderr)) } if stdout != "" { result.Output = stdout @@ -139,7 +139,7 @@ func (a *CodexAgent) Execute(ctx context.Context, opts ExecuteOptions) (*Execute } else { result.Error = err.Error() if stderr != "" { - result.Error = fmt.Sprintf("%s; stderr: %s", err.Error(), truncate(stderr, 2000)) + result.Error = fmt.Sprintf("%s; stderr: %s", err.Error(), truncate(stderr)) } } return result, err diff --git a/internal/agents/codex_test.go b/internal/agents/codex_test.go index 0a1f5f9..ffc15f2 100644 --- a/internal/agents/codex_test.go +++ b/internal/agents/codex_test.go @@ -396,7 +396,7 @@ func TestCodexAgent_ContextCancellation(t *testing.T) { } } -func TestCodexAgent_ImplementsAgentInterface(t *testing.T) { +func TestCodexAgent_ImplementsAgentInterface(_ *testing.T) { // Verify CodexAgent implements the Agent interface var _ Agent = (*CodexAgent)(nil) } diff --git a/internal/agents/copilot.go b/internal/agents/copilot.go index fd94226..b3e15d3 100644 --- a/internal/agents/copilot.go +++ b/internal/agents/copilot.go @@ -1,4 +1,4 @@ -// copilot.go implements the Agent interface for GitHub Copilot CLI. +// Package agents implements the Agent interface for GitHub Copilot CLI. package agents import ( diff --git a/internal/analysis/busfactor_test.go b/internal/analysis/busfactor_test.go index 1d19c08..0899b69 100644 --- a/internal/analysis/busfactor_test.go +++ b/internal/analysis/busfactor_test.go @@ -136,9 +136,7 @@ func TestProgressBar(t *testing.T) { if tt.hasBlock && tt.filled > 0 && !strings.Contains(bar, "█") { t.Errorf("expected progress bar to contain filled block for %d filled", tt.filled) } - if strings.Contains(bar, "[") && strings.Contains(bar, "]") { - // Valid format - } else { + if !strings.Contains(bar, "[") || !strings.Contains(bar, "]") { t.Errorf("progress bar should be wrapped in brackets") } } diff --git a/internal/budget/budget.go b/internal/budget/budget.go index 31a718c..2850194 100644 --- a/internal/budget/budget.go +++ b/internal/budget/budget.go @@ -38,8 +38,8 @@ type CopilotUsageProvider interface { GetResetTime(mode string) (time.Time, error) } -// BudgetEstimate provides a resolved weekly budget with metadata. -type BudgetEstimate struct { +// Estimate provides a resolved weekly budget with metadata. +type Estimate struct { WeeklyTokens int64 Source string Confidence string @@ -47,9 +47,9 @@ type BudgetEstimate struct { Variance float64 } -// BudgetSource provides calibrated or external budget estimates. -type BudgetSource interface { - GetBudget(provider string) (BudgetEstimate, error) +// Source provides calibrated or external budget estimates. +type Source interface { + GetBudget(provider string) (Estimate, error) } // UsedPercentSourceProvider reports where the last used-percent value came from. @@ -72,7 +72,7 @@ type Manager struct { claude ClaudeUsageProvider codex CodexUsageProvider copilot CopilotUsageProvider - budgetSource BudgetSource + budgetSource Source trend TrendAnalyzer nowFunc func() time.Time // for testing } @@ -92,8 +92,8 @@ func NewManager(cfg *config.Config, claude ClaudeUsageProvider, codex CodexUsage return mgr } -// WithBudgetSource injects a BudgetSource for calibrated budgets. -func WithBudgetSource(source BudgetSource) Option { +// WithBudgetSource injects a Source for calibrated budgets. +func WithBudgetSource(source Source) Option { return func(m *Manager) { m.budgetSource = source } @@ -251,8 +251,8 @@ func (m *Manager) applyReserve(result *AllowanceResult, reservePercent int) *All return result } -func (m *Manager) resolveBudget(provider string) (BudgetEstimate, error) { - estimate := BudgetEstimate{ +func (m *Manager) resolveBudget(provider string) (Estimate, error) { + estimate := Estimate{ WeeklyTokens: int64(m.cfg.GetProviderBudget(provider)), Source: "config", } @@ -438,6 +438,7 @@ func (m *Manager) CanRun(provider string, estimatedTokens int64) (bool, error) { } // Tracker provides backward compatibility for tracking actual spend. +// // Deprecated: Use Manager for budget calculations. type Tracker struct { spent map[string]int64 @@ -445,6 +446,7 @@ type Tracker struct { } // NewTracker creates a budget tracker with the given limit. +// // Deprecated: Use NewManager instead. func NewTracker(limitCents int64) *Tracker { return &Tracker{ @@ -454,7 +456,7 @@ func NewTracker(limitCents int64) *Tracker { } // Record logs spending for a provider. -func (t *Tracker) Record(provider string, tokens int, costCents int64) { +func (t *Tracker) Record(provider string, _ int, costCents int64) { t.spent[provider] += costCents } diff --git a/internal/budget/budget_test.go b/internal/budget/budget_test.go index a867017..9984961 100644 --- a/internal/budget/budget_test.go +++ b/internal/budget/budget_test.go @@ -15,7 +15,7 @@ type mockClaudeProvider struct { } func (m *mockClaudeProvider) Name() string { return "claude" } -func (m *mockClaudeProvider) GetUsedPercent(mode string, weeklyBudget int64) (float64, error) { +func (m *mockClaudeProvider) GetUsedPercent(_ string, _ int64) (float64, error) { return m.usedPercent, m.err } func (m *mockClaudeProvider) LastUsedPercentSource() string { @@ -30,10 +30,10 @@ type mockCodexProvider struct { } func (m *mockCodexProvider) Name() string { return "codex" } -func (m *mockCodexProvider) GetUsedPercent(mode string, weeklyBudget int64) (float64, error) { +func (m *mockCodexProvider) GetUsedPercent(_ string, _ int64) (float64, error) { return m.usedPercent, m.err } -func (m *mockCodexProvider) GetResetTime(mode string) (time.Time, error) { +func (m *mockCodexProvider) GetResetTime(_ string) (time.Time, error) { return m.resetTime, m.err } @@ -45,19 +45,19 @@ type mockCopilotProvider struct { } func (m *mockCopilotProvider) Name() string { return "copilot" } -func (m *mockCopilotProvider) GetUsedPercent(mode string, monthlyLimit int64) (float64, error) { +func (m *mockCopilotProvider) GetUsedPercent(_ string, _ int64) (float64, error) { return m.usedPercent, m.err } -func (m *mockCopilotProvider) GetResetTime(mode string) (time.Time, error) { +func (m *mockCopilotProvider) GetResetTime(_ string) (time.Time, error) { return m.resetTime, m.err } type mockBudgetSource struct { - estimate BudgetEstimate + estimate Estimate err error } -func (m *mockBudgetSource) GetBudget(provider string) (BudgetEstimate, error) { +func (m *mockBudgetSource) GetBudget(_ string) (Estimate, error) { return m.estimate, m.err } @@ -66,7 +66,7 @@ type mockTrendAnalyzer struct { err error } -func (m *mockTrendAnalyzer) PredictDaytimeUsage(provider string, now time.Time, weeklyBudget int64) (int64, error) { +func (m *mockTrendAnalyzer) PredictDaytimeUsage(_ string, _ time.Time, _ int64) (int64, error) { return m.predicted, m.err } @@ -475,7 +475,7 @@ func TestBudgetSourceOverridesConfig(t *testing.T) { } claude := &mockClaudeProvider{usedPercent: 0} - source := &mockBudgetSource{estimate: BudgetEstimate{ + source := &mockBudgetSource{estimate: Estimate{ WeeklyTokens: 700000, Source: "calibrated", Confidence: "high", @@ -509,7 +509,7 @@ func TestBudgetSourceFallbacksToConfig(t *testing.T) { } claude := &mockClaudeProvider{usedPercent: 0} - source := &mockBudgetSource{estimate: BudgetEstimate{ + source := &mockBudgetSource{estimate: Estimate{ WeeklyTokens: 0, }} diff --git a/internal/calibrator/calibrator.go b/internal/calibrator/calibrator.go index d98be7d..944eaa6 100644 --- a/internal/calibrator/calibrator.go +++ b/internal/calibrator/calibrator.go @@ -130,11 +130,12 @@ func (c *Calibrator) Calibrate(provider string) (CalibrationResult, error) { confidence = "low" } default: - if cv <= 0.10 { + switch { + case cv <= 0.10: confidence = "high" - } else if cv <= 0.15 { + case cv <= 0.15: confidence = "medium" - } else { + default: confidence = "low" } } @@ -156,12 +157,12 @@ func (c *Calibrator) Calibrate(provider string) (CalibrationResult, error) { } // GetBudget returns a budget estimate for the budget manager. -func (c *Calibrator) GetBudget(provider string) (budget.BudgetEstimate, error) { +func (c *Calibrator) GetBudget(provider string) (budget.Estimate, error) { result, err := c.Calibrate(provider) if err != nil { - return budget.BudgetEstimate{}, err + return budget.Estimate{}, err } - return budget.BudgetEstimate{ + return budget.Estimate{ WeeklyTokens: result.InferredBudget, Source: result.Source, Confidence: result.Confidence, diff --git a/internal/commits/normalizer.go b/internal/commits/normalizer.go new file mode 100644 index 0000000..451afa7 --- /dev/null +++ b/internal/commits/normalizer.go @@ -0,0 +1,293 @@ +// Package commits implements Conventional Commits message normalization and +// validation. It exposes pure, well-tested functions used by the CLI and by the +// commit-msg git hook to keep the project's history consistent. +// +// The supported format follows the Conventional Commits 1.0.0 specification: +// +// ()!: +// +// +// +// where the scope and the "!" breaking-change marker are optional. The +// normalizer is intentionally strict but constructive: rather than silently +// accepting malformed input it fixes the trivially fixable (whitespace, type +// casing, trailing punctuation, body wrapping) and rejects anything that needs +// a human decision (missing type, unknown type, missing subject). +package commits + +import ( + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +// MaxSubjectLength is the maximum number of runes allowed in a commit subject. +const MaxSubjectLength = 72 + +// BodyWrapWidth is the column at which the commit body is wrapped. +const BodyWrapWidth = 72 + +// allowedTypes is the set of Conventional Commit types this project accepts. +var allowedTypes = map[string]struct{}{ + "feat": {}, + "fix": {}, + "docs": {}, + "style": {}, + "refactor": {}, + "test": {}, + "chore": {}, + "perf": {}, + "build": {}, + "ci": {}, +} + +// Errors returned by the normalizer. They are wrapped so callers can match on +// the underlying cause with errors.Is. +var ( + // ErrEmptyMessage is returned when the message contains no non-comment, + // non-whitespace content. + ErrEmptyMessage = errors.New("commit message is empty") + // ErrMissingType is returned when the subject line is not a Conventional + // Commit (no type prefix before the colon). + ErrMissingType = errors.New("commit message must start with a conventional commit type") + // ErrUnknownType is returned when the type prefix is not in the allowed set. + ErrUnknownType = errors.New("commit type is not in the allowed set") + // ErrMissingSubject is returned when the type prefix is present but no + // subject text follows the colon. + ErrMissingSubject = errors.New("commit subject is missing") + // ErrSubjectTooLong is returned when the subject exceeds MaxSubjectLength. + ErrSubjectTooLong = fmt.Errorf("commit subject exceeds %d characters", MaxSubjectLength) + // ErrSubjectLowercase is returned when the subject starts with an uppercase + // letter (the rule is "do not capitalize the subject"). + ErrSubjectLowercase = errors.New("commit subject must not be capitalized") +) + +// Normalize parses, validates, and rewrites a raw commit message so that it +// conforms to the project's Conventional Commits rules. It returns the +// canonical form and a non-nil error describing the first unrecoverable +// problem when the message cannot be normalized. +// +// Normalization is idempotent: Normalize(Normalize(m)) == Normalize(m). +func Normalize(msg string) (string, error) { + lines := stripComments(msg) + if len(lines) == 0 { + return "", ErrEmptyMessage + } + + header := lines[0] + body := lines[1:] + + typ, scope, subject, breaking, err := parseHeader(header) + if err != nil { + return "", err + } + + subject = cleanSubject(subject) + + var b strings.Builder + b.WriteString(formatHeader(typ, scope, subject, breaking)) + + wrapped := wrapBody(body, BodyWrapWidth) + if wrapped != "" { + b.WriteString("\n\n") + b.WriteString(wrapped) + } + + return b.String(), nil +} + +// stripComments removes git's commented-out lines (those beginning with "#"), +// trims trailing whitespace from every line, and drops leading/trailing blank +// lines. It returns the meaningful lines of the message. +func stripComments(msg string) []string { + rawLines := strings.Split(msg, "\n") + out := make([]string, 0, len(rawLines)) + for _, l := range rawLines { + l = strings.TrimRight(l, " \t\r") + if strings.HasPrefix(strings.TrimSpace(l), "#") { + continue + } + out = append(out, l) + } + // Drop leading and trailing blank lines. + for len(out) > 0 && strings.TrimSpace(out[0]) == "" { + out = out[1:] + } + for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { + out = out[:len(out)-1] + } + return out +} + +// parseHeader splits the first line into its Conventional Commit components and +// validates them. The returned type is lower-cased to match the allowed set. A +// trailing "!" on the type (after the optional scope) marks a breaking change +// per Conventional Commits 1.0.0 and is preserved. +func parseHeader(header string) (typ, scope, subject string, breaking bool, err error) { + header = strings.TrimSpace(header) + colon := strings.Index(header, ":") + if colon <= 0 { + return "", "", "", false, ErrMissingType + } + prefix := header[:colon] + subject = strings.TrimSpace(header[colon+1:]) + + // Split an optional "(scope)" from the type. + prefix = strings.TrimSpace(prefix) + if strings.HasPrefix(prefix, "(") { + // A leading "(" with no type is not a valid conventional header. + return "", "", "", false, ErrMissingType + } + breaking = strings.HasSuffix(prefix, "!") + if breaking { + prefix = strings.TrimSuffix(prefix, "!") + } + if open := strings.Index(prefix, "("); open > 0 && strings.HasSuffix(prefix, ")") { + typ = prefix[:open] + scope = prefix[open+1 : len(prefix)-1] + } else { + typ = prefix + } + typ = strings.ToLower(strings.TrimSpace(typ)) + scope = strings.ToLower(strings.TrimSpace(scope)) + + if typ == "" { + return "", "", "", false, ErrMissingType + } + if !isAllowedType(typ) { + return "", "", "", false, fmt.Errorf("%w: %q", ErrUnknownType, typ) + } + if strings.TrimSpace(subject) == "" { + return "", "", "", false, ErrMissingSubject + } + if utf8.RuneCountInString(subject) > MaxSubjectLength { + return "", "", "", false, ErrSubjectTooLong + } + if startsUpper(subject) { + return "", "", "", false, ErrSubjectLowercase + } + return typ, scope, subject, breaking, nil +} + +// cleanSubject normalizes the subject text: lowercases a leading uppercase +// letter is *not* done here (capitalization is a hard error, not a fix), but +// surrounding whitespace and a trailing period are removed. +func cleanSubject(subject string) string { + s := strings.TrimSpace(subject) + s = strings.TrimRight(s, ".") + return s +} + +// formatHeader reassembles a canonical header line from its components, +// keeping a "!" breaking-change marker after the type or scope. +func formatHeader(typ, scope, subject string, breaking bool) string { + bang := "" + if breaking { + bang = "!" + } + if scope != "" { + return typ + "(" + scope + ")" + bang + ": " + subject + } + return typ + bang + ": " + subject +} + +// wrapBody collapses runs of blank lines, preserves paragraph breaks, keeps +// bullet-list items on their own lines, and hard-wraps each paragraph to width. +// Paragraph and list blocks are separated by a single blank line; consecutive +// bullet items stay on adjacent lines so lists remain lists instead of being +// merged into prose. +func wrapBody(body []string, width int) string { + var blocks []string + var para []string + var list []string + + flushPara := func() { + if len(para) > 0 { + blocks = append(blocks, wrapParagraph(strings.Join(para, " "), width)) + para = nil + } + } + flushList := func() { + if len(list) > 0 { + blocks = append(blocks, strings.Join(list, "\n")) + list = nil + } + } + + for _, l := range body { + l = strings.TrimSpace(l) + if l == "" { + flushPara() + flushList() + continue + } + if isBulletItem(l) { + flushPara() + list = append(list, wrapParagraph(l, width)) + continue + } + if len(list) > 0 { + // Continuation of the preceding bullet item (e.g. the second + // line of a wrapped item) — keep it inside the list block so + // normalization stays idempotent. + list = append(list, wrapParagraph(l, width)) + continue + } + para = append(para, l) + } + flushPara() + flushList() + + return strings.Join(blocks, "\n\n") +} + +// isBulletItem reports whether a body line is a bullet-list item, i.e. it +// starts with "- " or "* ". +func isBulletItem(l string) bool { + return strings.HasPrefix(l, "- ") || strings.HasPrefix(l, "* ") +} + +// wrapParagraph hard-wraps a single-line paragraph at width, breaking on word +// boundaries. A word longer than width is left intact rather than split. +func wrapParagraph(text string, width int) string { + words := strings.Fields(text) + if len(words) == 0 { + return "" + } + var b strings.Builder + lineLen := 0 + for i, w := range words { + if i == 0 { + b.WriteString(w) + lineLen = len(w) + continue + } + if lineLen+1+len(w) <= width { + b.WriteByte(' ') + b.WriteString(w) + lineLen += 1 + len(w) + } else { + b.WriteByte('\n') + b.WriteString(w) + lineLen = len(w) + } + } + return b.String() +} + +// isAllowedType reports whether typ is one of the accepted Conventional Commit +// types. +func isAllowedType(typ string) bool { + _, ok := allowedTypes[typ] + return ok +} + +// startsUpper reports whether the first rune of s is an ASCII uppercase letter. +func startsUpper(s string) bool { + if s == "" { + return false + } + r, _ := utf8.DecodeRuneInString(s) + return r >= 'A' && r <= 'Z' +} diff --git a/internal/commits/normalizer_test.go b/internal/commits/normalizer_test.go new file mode 100644 index 0000000..7b022f4 --- /dev/null +++ b/internal/commits/normalizer_test.go @@ -0,0 +1,155 @@ +package commits + +import ( + "errors" + "strings" + "testing" +) + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr error + }{ + { + name: "valid simple feat", + in: "feat: add login screen", + want: "feat: add login screen", + }, + { + name: "valid with scope", + in: "fix(api): handle nil response", + want: "fix(api): handle nil response", + }, + { + name: "trims surrounding whitespace and trailing period", + in: " docs: update README. ", + want: "docs: update README", + }, + { + name: "lowercases an uppercased type", + in: "FEAT(ui): render button", + want: "feat(ui): render button", + }, + { + name: "preserves body and wraps long lines", + in: "feat: add thing\n\nthis is a body paragraph that is intentionally far longer than the configured wrap width so it must be hard wrapped onto multiple lines by the normalizer function", + want: "feat: add thing\n\n" + + "this is a body paragraph that is intentionally far longer than the\n" + + "configured wrap width so it must be hard wrapped onto multiple lines by\n" + + "the normalizer function", + }, + { + name: "strips git comment lines", + in: "chore: tidy\n# please enter the commit message\n\nbody here", + want: "chore: tidy\n\nbody here", + }, + { + name: "breaking-change marker without scope preserved", + in: "feat!: drop the legacy config loader", + want: "feat!: drop the legacy config loader", + }, + { + name: "breaking-change marker with scope preserved", + in: "FEAT(API)!: drop the legacy config loader.", + want: "feat(api)!: drop the legacy config loader", + }, + { + name: "bullet-list body items stay on their own lines", + in: "feat: add export\n\nadds csv export:\n\n- item one\n- item two with a description that is long enough to need wrapping onto a second line by the wrapper\n* item three\n\ntrailing paragraph", + want: "feat: add export\n\n" + + "adds csv export:\n\n" + + "- item one\n" + + "- item two with a description that is long enough to need wrapping onto\n" + + "a second line by the wrapper\n" + + "* item three\n\n" + + "trailing paragraph", + }, + { + name: "missing type rejected", + in: "just a plain message", + wantErr: ErrMissingType, + }, + { + name: "unknown type rejected", + in: "wip: halfway done", + wantErr: ErrUnknownType, + }, + { + name: "missing subject rejected", + in: "feat:", + wantErr: ErrMissingSubject, + }, + { + name: "capitalized subject rejected", + in: "feat: Add login screen", + wantErr: ErrSubjectLowercase, + }, + { + name: "overlong subject rejected", + in: "feat: " + strings.Repeat("a", MaxSubjectLength+1), + wantErr: ErrSubjectTooLong, + }, + { + name: "empty message rejected", + in: "\n\n# only comments\n \n", + wantErr: ErrEmptyMessage, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Normalize(tc.in) + if tc.wantErr != nil { + if err == nil { + t.Fatalf("Normalize(%q): expected error %v, got nil (result %q)", tc.in, tc.wantErr, got) + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("Normalize(%q): expected error to wrap %v, got %v", tc.in, tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("Normalize(%q): unexpected error: %v", tc.in, err) + } + if got != tc.want { + t.Errorf("Normalize(%q):\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeIdempotent(t *testing.T) { + cases := []string{ + "feat: add login screen", + "fix(api): handle nil response\n\nLong body that explains the fix in more detail than the subject alone can manage so that we exercise the wrapping path too and then some more words here.", + "docs: update README\n\nfirst paragraph\n\nsecond paragraph stays separate", + "feat(api)!: rework the public API\n\nrationale:\n\n- first reason\n- second reason that is long enough to require wrapping when the normalizer processes this list item", + } + for _, in := range cases { + once, err := Normalize(in) + if err != nil { + t.Fatalf("first Normalize(%q) errored: %v", in, err) + } + twice, err := Normalize(once) + if err != nil { + t.Fatalf("second Normalize(%q) errored: %v", once, err) + } + if once != twice { + t.Errorf("not idempotent for %q\n once: %q\n twice: %q", in, once, twice) + } + } +} + +func TestAllowedTypes(t *testing.T) { + for _, typ := range []string{"feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "build", "ci"} { + if !isAllowedType(typ) { + t.Errorf("expected %q to be an allowed type", typ) + } + } + if isAllowedType("wip") { + t.Error("did not expect wip to be allowed") + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 2f846a8..bdf08af 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + // Blank import registers the pure-Go SQLite driver used by database/sql. _ "modernc.org/sqlite" ) diff --git a/internal/db/import_test.go b/internal/db/import_test.go index ef3e75e..83d00c7 100644 --- a/internal/db/import_test.go +++ b/internal/db/import_test.go @@ -75,10 +75,10 @@ func TestImportLegacyStateSuccess(t *testing.T) { t.Fatalf("expected migrated file: %v", err) } - assertRowCount(t, database, "projects", 1) - assertRowCount(t, database, "task_history", 1) - assertRowCount(t, database, "assigned_tasks", 1) - assertRowCount(t, database, "run_history", 1) + assertOneRow(t, database, "projects") + assertOneRow(t, database, "task_history") + assertOneRow(t, database, "assigned_tasks") + assertOneRow(t, database, "run_history") } func TestImportLegacyStateSkipsWhenDataExists(t *testing.T) { @@ -115,7 +115,7 @@ func TestImportLegacyStateSkipsWhenDataExists(t *testing.T) { t.Fatalf("expected no migrated file, got err=%v", err) } - assertRowCount(t, database, "projects", 1) + assertOneRow(t, database, "projects") } func TestImportLegacyStateParseFailureKeepsFile(t *testing.T) { @@ -146,7 +146,7 @@ func TestImportLegacyStateParseFailureKeepsFile(t *testing.T) { } } -func assertRowCount(t *testing.T, database *DB, table string, expected int) { +func assertOneRow(t *testing.T, database *DB, table string) { t.Helper() row := database.SQL().QueryRow(`SELECT COUNT(*) FROM ` + table) @@ -154,7 +154,7 @@ func assertRowCount(t *testing.T, database *DB, table string, expected int) { if err := row.Scan(&count); err != nil { t.Fatalf("count rows in %s: %v", table, err) } - if count != expected { - t.Fatalf("expected %d rows in %s, got %d", expected, table, count) + if count != 1 { + t.Fatalf("expected 1 row in %s, got %d", table, count) } } diff --git a/internal/integrations/agentsmd.go b/internal/integrations/agentsmd.go index 5af031b..d00db39 100644 --- a/internal/integrations/agentsmd.go +++ b/internal/integrations/agentsmd.go @@ -34,7 +34,7 @@ func (r *AgentsMDReader) Enabled() bool { } // Read looks for agents.md or AGENTS.md and extracts behavior preferences. -func (r *AgentsMDReader) Read(ctx context.Context, projectPath string) (*Result, error) { +func (r *AgentsMDReader) Read(_ context.Context, projectPath string) (*Result, error) { // Try multiple possible locations candidates := []string{ filepath.Join(projectPath, "AGENTS.md"), diff --git a/internal/integrations/claudemd.go b/internal/integrations/claudemd.go index 44b5ec6..c85c7a6 100644 --- a/internal/integrations/claudemd.go +++ b/internal/integrations/claudemd.go @@ -34,7 +34,7 @@ func (r *ClaudeMDReader) Enabled() bool { } // Read looks for claude.md in project root and extracts context. -func (r *ClaudeMDReader) Read(ctx context.Context, projectPath string) (*Result, error) { +func (r *ClaudeMDReader) Read(_ context.Context, projectPath string) (*Result, error) { // Try multiple possible locations candidates := []string{ filepath.Join(projectPath, "claude.md"), diff --git a/internal/integrations/integrations.go b/internal/integrations/integrations.go index 5b7823b..c7bd4cd 100644 --- a/internal/integrations/integrations.go +++ b/internal/integrations/integrations.go @@ -57,6 +57,7 @@ type Hint struct { // HintType categorizes hints. type HintType int +// Hint types emitted by integration readers. const ( HintTaskSuggestion HintType = iota // Suggested task to run HintConvention // Coding convention diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 2f870ba..0f194fe 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -370,7 +370,7 @@ func Warn(msg string) { Get().Warn(msg) } -// Error(msg string) logs an error message to the global logger. +// Error logs an error message to the global logger. func Error(msg string) { Get().Error(msg) } diff --git a/internal/orchestrator/events.go b/internal/orchestrator/events.go index 7399672..dc28cb1 100644 --- a/internal/orchestrator/events.go +++ b/internal/orchestrator/events.go @@ -5,6 +5,7 @@ import "time" // EventType classifies orchestrator lifecycle events. type EventType int +// Task lifecycle events emitted by the orchestrator. const ( EventTaskStart EventType = iota // task execution begins EventPhaseStart // entering a phase (plan/implement/review) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 6141c95..2c2d905 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -29,6 +29,7 @@ const ( // TaskStatus represents the outcome of task execution. type TaskStatus string +// TaskStatus values track a task through the orchestration lifecycle. const ( StatusPending TaskStatus = "pending" StatusPlanning TaskStatus = "planning" @@ -113,7 +114,7 @@ func DefaultConfig() Config { // Orchestrator manages agent execution using plan-implement-review loop. type Orchestrator struct { agent agents.Agent - budget *budget.Tracker + budget *budget.Tracker //nolint:staticcheck // Tracker retained until migration to budget.Manager queue *tasks.Queue config Config logger *logging.Logger @@ -132,7 +133,9 @@ func WithAgent(a agents.Agent) Option { } // WithBudget sets the budget tracker. -func WithBudget(b *budget.Tracker) Option { +// +// Deprecated: Tracker is retained for compatibility until migration to budget.Manager. +func WithBudget(b *budget.Tracker) Option { //nolint:staticcheck // Tracker retained until migration to budget.Manager return func(o *Orchestrator) { o.budget = b } @@ -448,7 +451,7 @@ func (o *Orchestrator) plan(ctx context.Context, task *tasks.Task, workDir strin if execResult != nil && execResult.Output != "" { o.logger.WarnCtx("agent produced partial output before error", map[string]any{ "output_len": len(execResult.Output), - "output": truncateStr(execResult.Output, 1000), + "output": truncateStr(execResult.Output), "error": execResult.Error, }) } @@ -459,7 +462,7 @@ func (o *Orchestrator) plan(ctx context.Context, task *tasks.Task, workDir strin if execResult.Output != "" { o.logger.WarnCtx("agent produced output but reported error", map[string]any{ "output_len": len(execResult.Output), - "output": truncateStr(execResult.Output, 1000), + "output": truncateStr(execResult.Output), "error": execResult.Error, }) } @@ -510,7 +513,7 @@ func (o *Orchestrator) implement(ctx context.Context, task *tasks.Task, plan *Pl o.logger.WarnCtx("agent produced partial output before error", map[string]any{ "phase": "implement", "output_len": len(execResult.Output), - "output": truncateStr(execResult.Output, 1000), + "output": truncateStr(execResult.Output), "error": execResult.Error, }) } @@ -522,7 +525,7 @@ func (o *Orchestrator) implement(ctx context.Context, task *tasks.Task, plan *Pl o.logger.WarnCtx("agent produced output but reported error", map[string]any{ "phase": "implement", "output_len": len(execResult.Output), - "output": truncateStr(execResult.Output, 1000), + "output": truncateStr(execResult.Output), "error": execResult.Error, }) } @@ -624,7 +627,7 @@ func (o *Orchestrator) review(ctx context.Context, task *tasks.Task, impl *Imple o.logger.WarnCtx("agent produced partial output before error", map[string]any{ "phase": "review", "output_len": len(execResult.Output), - "output": truncateStr(execResult.Output, 1000), + "output": truncateStr(execResult.Output), "error": execResult.Error, }) } @@ -636,7 +639,7 @@ func (o *Orchestrator) review(ctx context.Context, task *tasks.Task, impl *Imple o.logger.WarnCtx("agent produced output but reported error", map[string]any{ "phase": "review", "output_len": len(execResult.Output), - "output": truncateStr(execResult.Output, 1000), + "output": truncateStr(execResult.Output), "error": execResult.Error, }) } @@ -936,8 +939,9 @@ func CurrentBranch(ctx context.Context, workDir string) (string, error) { return strings.TrimSpace(string(out)), nil } -// truncateStr returns s trimmed to maxLen characters, appending "..." if truncated. -func truncateStr(s string, maxLen int) string { +// truncateStr returns s trimmed to 1000 characters, appending "..." if truncated. +func truncateStr(s string) string { + const maxLen = 1000 if len(s) <= maxLen { return s } diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 45bff5c..5ec6c1d 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -33,7 +33,7 @@ func (m *mockAgent) Name() string { return m.name } -func (m *mockAgent) Execute(ctx context.Context, opts agents.ExecuteOptions) (*agents.ExecuteResult, error) { +func (m *mockAgent) Execute(_ context.Context, opts agents.ExecuteOptions) (*agents.ExecuteResult, error) { m.calls = append(m.calls, opts) if m.callIndex >= len(m.responses) { @@ -323,7 +323,7 @@ func TestContainsIgnoreCase(t *testing.T) { } } -func TestRunContextCancellation(t *testing.T) { +func TestRunContextCancellation(_ *testing.T) { // Create a slow mock that checks context agent := &slowMockAgent{delay: 100 * time.Millisecond} o := New(WithAgent(agent)) @@ -353,7 +353,7 @@ func (m *slowMockAgent) Name() string { return "slow-mock" } -func (m *slowMockAgent) Execute(ctx context.Context, opts agents.ExecuteOptions) (*agents.ExecuteResult, error) { +func (m *slowMockAgent) Execute(ctx context.Context, _ agents.ExecuteOptions) (*agents.ExecuteResult, error) { select { case <-ctx.Done(): return nil, ctx.Err() diff --git a/internal/providers/claude.go b/internal/providers/claude.go index 3f78e89..157aff5 100644 --- a/internal/providers/claude.go +++ b/internal/providers/claude.go @@ -1,4 +1,4 @@ -// claude.go implements the Provider interface for Claude Code CLI. +// Package providers implements the Provider interface for Claude Code CLI. package providers import ( @@ -102,7 +102,7 @@ func (c *Claude) Name() string { } // Execute runs a task via Claude Code CLI. -func (c *Claude) Execute(ctx context.Context, task Task) (Result, error) { +func (c *Claude) Execute(_ context.Context, _ Task) (Result, error) { // TODO: Implement - spawn claude CLI process return Result{}, nil } diff --git a/internal/providers/codex.go b/internal/providers/codex.go index eb948ed..ec89bbe 100644 --- a/internal/providers/codex.go +++ b/internal/providers/codex.go @@ -1,4 +1,4 @@ -// codex.go implements the Provider interface for OpenAI Codex CLI. +// Package providers implements the Provider interface for OpenAI Codex CLI. package providers import ( @@ -84,7 +84,7 @@ func (c *Codex) Name() string { } // Execute runs a task via Codex CLI. -func (c *Codex) Execute(ctx context.Context, task Task) (Result, error) { +func (c *Codex) Execute(_ context.Context, _ Task) (Result, error) { // TODO: Implement - spawn codex CLI process return Result{}, nil } diff --git a/internal/providers/copilot.go b/internal/providers/copilot.go index fe69acf..1c499c1 100644 --- a/internal/providers/copilot.go +++ b/internal/providers/copilot.go @@ -1,4 +1,4 @@ -// copilot.go implements the Provider interface for GitHub Copilot CLI. +// Package providers implements the Provider interface for GitHub Copilot CLI. package providers import ( @@ -56,7 +56,7 @@ func (c *Copilot) Name() string { // Execute runs a task via GitHub Copilot CLI. // Implementation note: GitHub Copilot CLI uses 'gh copilot' commands. -func (c *Copilot) Execute(ctx context.Context, task Task) (Result, error) { +func (c *Copilot) Execute(_ context.Context, _ Task) (Result, error) { // TODO: Implement - spawn gh copilot CLI process // According to GitHub docs, commands are: // - gh copilot explain diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 278ca71..3669982 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -200,7 +200,8 @@ func (s *Scheduler) Start(ctx context.Context) error { s.stopCh = make(chan struct{}) s.doneCh = make(chan struct{}) - if s.cronExpr != "" { + switch { + case s.cronExpr != "": // Cron-based scheduling s.cron = cron.New(cron.WithLocation(s.location)) entryID, err := s.cron.AddFunc(s.cronExpr, func() { @@ -222,13 +223,13 @@ func (s *Scheduler) Start(ctx context.Context) error { s.cron.Stop() close(s.doneCh) }() - } else if s.interval > 0 { + case s.interval > 0: // Interval-based scheduling s.updateNextRunLocked() s.mu.Unlock() go s.intervalLoop(ctx) - } else { + default: s.running = false s.mu.Unlock() return ErrNoSchedule @@ -449,12 +450,13 @@ func (s *Scheduler) IsRunning() bool { } // ScheduleCron adds a recurring job using cron expression. +// // Deprecated: Use SetCron and AddJob instead. func (s *Scheduler) ScheduleCron(expr string, job func()) error { if err := s.SetCron(expr); err != nil { return err } - s.AddJob(func(ctx context.Context) error { + s.AddJob(func(_ context.Context) error { job() return nil }) @@ -466,7 +468,7 @@ func (s *Scheduler) ScheduleInterval(d time.Duration, job func()) error { if err := s.SetInterval(d); err != nil { return err } - s.AddJob(func(ctx context.Context) error { + s.AddJob(func(_ context.Context) error { job() return nil }) @@ -475,7 +477,7 @@ func (s *Scheduler) ScheduleInterval(d time.Duration, job func()) error { // Schedule adds a one-time job to run at the specified time. func (s *Scheduler) Schedule(at time.Time, job func()) { - s.AddJob(func(ctx context.Context) error { + s.AddJob(func(_ context.Context) error { if time.Now().After(at) { job() } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index e28a714..9ebfc83 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -462,7 +462,7 @@ func TestScheduler_JobExecution_Interval(t *testing.T) { var count atomic.Int32 - s.AddJob(func(ctx context.Context) error { + s.AddJob(func(_ context.Context) error { count.Add(1) return nil }) @@ -502,7 +502,7 @@ func TestScheduler_JobExecution_WindowBlocks(t *testing.T) { var count atomic.Int32 - s.AddJob(func(ctx context.Context) error { + s.AddJob(func(_ context.Context) error { count.Add(1) return nil }) @@ -529,7 +529,7 @@ func TestScheduler_ContextCancellation(t *testing.T) { var count atomic.Int32 - s.AddJob(func(ctx context.Context) error { + s.AddJob(func(_ context.Context) error { count.Add(1) return nil }) diff --git a/internal/security/audit.go b/internal/security/audit.go index 69e93c6..2cae0a0 100644 --- a/internal/security/audit.go +++ b/internal/security/audit.go @@ -14,6 +14,7 @@ import ( // AuditEventType categorizes audit events. type AuditEventType string +// AuditEventType values identify audited actions. const ( AuditAgentStart AuditEventType = "agent_start" AuditAgentComplete AuditEventType = "agent_complete" diff --git a/internal/security/security.go b/internal/security/security.go index 1166e57..2f89611 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -237,9 +237,10 @@ func ValidateProjectPath(path string) error { return nil } -// Operation types for safety checks. +// OperationType categorizes operations checked for safety. type OperationType string +// OperationType values identify operation kinds. const ( OpAgentInvoke OperationType = "agent_invoke" OpFileRead OperationType = "file_read" diff --git a/internal/setup/presets.go b/internal/setup/presets.go index 4927c06..0bbe2c8 100644 --- a/internal/setup/presets.go +++ b/internal/setup/presets.go @@ -12,6 +12,7 @@ import ( // Preset identifies a task selection profile (safe, balanced, aggressive). type Preset string +// Task selection presets ordered by risk appetite. const ( PresetBalanced Preset = "balanced" PresetSafe Preset = "safe" diff --git a/internal/snapshots/collector_test.go b/internal/snapshots/collector_test.go index 2024aa1..27ea240 100644 --- a/internal/snapshots/collector_test.go +++ b/internal/snapshots/collector_test.go @@ -26,7 +26,7 @@ type fakeScraper struct { weeklyResetTime string } -func (f fakeScraper) ScrapeClaudeUsage(ctx context.Context) (tmux.UsageResult, error) { +func (f fakeScraper) ScrapeClaudeUsage(_ context.Context) (tmux.UsageResult, error) { return tmux.UsageResult{ Provider: "claude", WeeklyPct: f.claudePct, @@ -36,7 +36,7 @@ func (f fakeScraper) ScrapeClaudeUsage(ctx context.Context) (tmux.UsageResult, e }, nil } -func (f fakeScraper) ScrapeCodexUsage(ctx context.Context) (tmux.UsageResult, error) { +func (f fakeScraper) ScrapeCodexUsage(_ context.Context) (tmux.UsageResult, error) { return tmux.UsageResult{ Provider: "codex", WeeklyPct: f.codexPct, diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 0c93fe9..ecd4628 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -53,8 +53,8 @@ func (d Duration) String() string { return fmt.Sprintf("%dh %dm", int(dur.Hours()), int(dur.Minutes())%60) } -// StatsResult holds all computed statistics, JSON-serializable. -type StatsResult struct { +// Result holds all computed statistics, JSON-serializable. +type Result struct { // Run overview TotalRuns int `json:"total_runs"` FirstRunAt *time.Time `json:"first_run_at,omitempty"` @@ -118,7 +118,7 @@ type Stats struct { db *db.DB reportsDir string nowFunc func() time.Time - budgetSource budget.BudgetSource + budgetSource budget.Source } // New creates a Stats instance. @@ -131,7 +131,7 @@ func New(database *db.DB, reportsDir string) *Stats { } // NewWithBudgetSource creates a Stats instance with a calibrated budget source. -func NewWithBudgetSource(database *db.DB, reportsDir string, source budget.BudgetSource) *Stats { +func NewWithBudgetSource(database *db.DB, reportsDir string, source budget.Source) *Stats { return &Stats{ db: database, reportsDir: reportsDir, @@ -140,9 +140,9 @@ func NewWithBudgetSource(database *db.DB, reportsDir string, source budget.Budge } } -// Compute aggregates all available data into a StatsResult. -func (s *Stats) Compute() (*StatsResult, error) { - result := &StatsResult{ +// Compute aggregates all available data into a Result. +func (s *Stats) Compute() (*Result, error) { + result := &Result{ TaskTypeBreakdown: make(map[string]int), } @@ -215,7 +215,7 @@ func (s *Stats) loadReports() []*reporting.RunResults { } // computeFromReports extracts task-level stats from report JSON files. -func (s *Stats) computeFromReports(result *StatsResult, reports []*reporting.RunResults) { +func (s *Stats) computeFromReports(result *Result, reports []*reporting.RunResults) { if len(reports) == 0 { return } @@ -306,7 +306,7 @@ func (s *Stats) computeFromReports(result *StatsResult, reports []*reporting.Run } // computeFromRunHistory queries the run_history table for run-level stats. -func (s *Stats) computeFromRunHistory(result *StatsResult) { +func (s *Stats) computeFromRunHistory(result *Result) { sqlDB := s.db.SQL() if sqlDB == nil { return @@ -359,7 +359,7 @@ func (s *Stats) computeFromRunHistory(result *StatsResult) { } // computeFromProjects queries the projects table for project count and run counts. -func (s *Stats) computeFromProjects(result *StatsResult) { +func (s *Stats) computeFromProjects(result *Result) { sqlDB := s.db.SQL() if sqlDB == nil { return @@ -416,7 +416,7 @@ func (s *Stats) computeFromProjects(result *StatsResult) { } // computeBudgetProjections estimates projection windows for available providers. -func (s *Stats) computeBudgetProjections(result *StatsResult) { +func (s *Stats) computeBudgetProjections(result *Result) { sqlDB := s.db.SQL() if sqlDB == nil { return diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index bb0da18..6d05eef 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -887,7 +887,7 @@ func TestCompute_DBOnlyNoReports(t *testing.T) { func TestStatsResult_JSONRoundTrip(t *testing.T) { now := time.Date(2025, 1, 15, 2, 0, 0, 0, time.UTC) - original := &StatsResult{ + original := &Result{ TotalRuns: 5, FirstRunAt: &now, TotalDuration: Duration{2 * time.Hour}, @@ -933,7 +933,7 @@ func TestStatsResult_JSONRoundTrip(t *testing.T) { t.Fatalf("marshal: %v", err) } - var decoded StatsResult + var decoded Result if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } diff --git a/internal/tasks/selector.go b/internal/tasks/selector.go index 7a5c4d3..ae0ebc6 100644 --- a/internal/tasks/selector.go +++ b/internal/tasks/selector.go @@ -98,8 +98,8 @@ func (s *Selector) FilterEnabled(tasks []TaskDefinition) []TaskDefinition { func (s *Selector) FilterByBudget(tasks []TaskDefinition, budget int64) []TaskDefinition { filtered := make([]TaskDefinition, 0, len(tasks)) for _, t := range tasks { - _, max := t.EstimatedTokens() - if int64(max) <= budget { + _, maxTokens := t.EstimatedTokens() + if int64(maxTokens) <= budget { filtered = append(filtered, t) } } @@ -243,8 +243,8 @@ func (s *Selector) SelectNext(budget int64, project string) *ScoredTask { // Select top task that fits remaining budget for _, st := range scored { - _, max := st.Definition.EstimatedTokens() - if int64(max) <= budget { + _, maxTokens := st.Definition.EstimatedTokens() + if int64(maxTokens) <= budget { return &st } } diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 2c7dabb..1095b62 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -12,6 +12,7 @@ import ( // CostTier represents the estimated token cost for a task. type CostTier int +// CostTier values from lowest to highest token cost. const ( CostLow CostTier = iota // 10-50k tokens CostMedium // 50-150k tokens @@ -36,7 +37,7 @@ func (c CostTier) String() string { } // TokenRange returns the min and max estimated tokens for this tier. -func (c CostTier) TokenRange() (min, max int) { +func (c CostTier) TokenRange() (minTokens, maxTokens int) { switch c { case CostLow: return 10_000, 50_000 @@ -54,6 +55,7 @@ func (c CostTier) TokenRange() (min, max int) { // RiskLevel represents the risk associated with a task. type RiskLevel int +// RiskLevel values from lowest to highest risk. const ( RiskLow RiskLevel = iota RiskMedium @@ -242,7 +244,7 @@ func DefaultIntervalForCategory(cat TaskCategory) time.Duration { } // EstimatedTokens returns the token range for this task definition. -func (d TaskDefinition) EstimatedTokens() (min, max int) { +func (d TaskDefinition) EstimatedTokens() (minTokens, maxTokens int) { return d.CostTier.TokenRange() } @@ -856,13 +858,13 @@ func GetDefinition(taskType TaskType) (TaskDefinition, error) { } // GetCostEstimate returns the estimated token cost range for a task type. -func GetCostEstimate(taskType TaskType) (min, max int, err error) { +func GetCostEstimate(taskType TaskType) (minTokens, maxTokens int, err error) { def, err := GetDefinition(taskType) if err != nil { return 0, 0, err } - min, max = def.EstimatedTokens() - return min, max, nil + minTokens, maxTokens = def.EstimatedTokens() + return minTokens, maxTokens, nil } // GetTasksByCategory returns all task definitions in a category. @@ -996,7 +998,7 @@ func NewQueue() *Queue { } // Add queues a task. -func (q *Queue) Add(t Task) { +func (q *Queue) Add(_ Task) { // TODO: Implement } diff --git a/internal/tasks/tasks_test.go b/internal/tasks/tasks_test.go index 03cdf18..c7412a0 100644 --- a/internal/tasks/tasks_test.go +++ b/internal/tasks/tasks_test.go @@ -36,10 +36,10 @@ func TestCostTierTokenRange(t *testing.T) { {CostTier(99), 0, 0}, } for _, tt := range tests { - min, max := tt.tier.TokenRange() - if min != tt.wantMin || max != tt.wantMax { + minTokens, maxTokens := tt.tier.TokenRange() + if minTokens != tt.wantMin || maxTokens != tt.wantMax { t.Errorf("CostTier(%d).TokenRange() = (%d, %d), want (%d, %d)", - tt.tier, min, max, tt.wantMin, tt.wantMax) + tt.tier, minTokens, maxTokens, tt.wantMin, tt.wantMax) } } } @@ -106,21 +106,21 @@ func TestGetDefinition(t *testing.T) { func TestGetCostEstimate(t *testing.T) { // Low cost task - min, max, err := GetCostEstimate(TaskLintFix) + minTokens, maxTokens, err := GetCostEstimate(TaskLintFix) if err != nil { t.Fatalf("GetCostEstimate(TaskLintFix) error: %v", err) } - if min != 10_000 || max != 50_000 { - t.Errorf("GetCostEstimate(TaskLintFix) = (%d, %d), want (10000, 50000)", min, max) + if minTokens != 10_000 || maxTokens != 50_000 { + t.Errorf("GetCostEstimate(TaskLintFix) = (%d, %d), want (10000, 50000)", minTokens, maxTokens) } // Very high cost task - min, max, err = GetCostEstimate(TaskMigrationRehearsal) + minTokens, maxTokens, err = GetCostEstimate(TaskMigrationRehearsal) if err != nil { t.Fatalf("GetCostEstimate(TaskMigrationRehearsal) error: %v", err) } - if min != 500_000 || max != 1_000_000 { - t.Errorf("GetCostEstimate(TaskMigrationRehearsal) = (%d, %d), want (500000, 1000000)", min, max) + if minTokens != 500_000 || maxTokens != 1_000_000 { + t.Errorf("GetCostEstimate(TaskMigrationRehearsal) = (%d, %d), want (500000, 1000000)", minTokens, maxTokens) } // Unknown task @@ -237,9 +237,9 @@ func TestAllDefinitions(t *testing.T) { func TestTaskDefinitionEstimatedTokens(t *testing.T) { def, _ := GetDefinition(TaskLintFix) - min, max := def.EstimatedTokens() - if min != 10_000 || max != 50_000 { - t.Errorf("TaskDefinition.EstimatedTokens() = (%d, %d), want (10000, 50000)", min, max) + minTokens, maxTokens := def.EstimatedTokens() + if minTokens != 10_000 || maxTokens != 50_000 { + t.Errorf("TaskDefinition.EstimatedTokens() = (%d, %d), want (10000, 50000)", minTokens, maxTokens) } } diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 444e1d7..4143acb 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -13,7 +13,7 @@ type fakeRunner struct { calls int } -func (f *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { +func (f *fakeRunner) Run(_ context.Context, _ string, _ ...string) ([]byte, error) { if len(f.outputs) == 0 { return []byte(""), nil } diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..f61a5a2 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# +# Enforces Conventional Commits on every commit message and rewrites the +# message file into canonical form before the commit is created. Messages that +# cannot be normalized (missing/unknown type, capitalized or overlong subject) +# are rejected with a non-zero exit so the commit is aborted. +# +# Install: +# make install-hooks +# # or manually: +# ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg +# chmod +x scripts/commit-msg.sh +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: commit-msg " >&2 + exit 1 +fi + +MSG_FILE="$1" + +# Resolve the nightshift binary: prefer the one on $PATH, fall back to +# building the current source tree. Kept as an array so the multi-word +# "go run ..." fallback expands correctly. +NIGHTSHIFT="$(command -v nightshift || true)" +if [[ -n "$NIGHTSHIFT" ]]; then + NIGHTSHIFT_CMD=("$NIGHTSHIFT") +else + NIGHTSHIFT_CMD=(go run github.com/marcus/nightshift/cmd/nightshift) +fi + +ERR_FILE="$(mktemp "${TMPDIR:-/tmp}/nightshift-commit-msg.XXXXXX")" +trap 'rm -f "$ERR_FILE"' EXIT + +# Disable errexit around the assignment: its exit status is the command +# substitution's status, and under `set -e` a failing assignment would abort +# the script before the error guidance below could run. +set +e +NORMALIZED="$("${NIGHTSHIFT_CMD[@]}" commit normalize --file "$MSG_FILE" 2>"$ERR_FILE")" +STATUS=$? +set -e + +if [[ $STATUS -ne 0 ]]; then + echo "🪡 commit-msg: message does not follow Conventional Commits" >&2 + sed 's/^/ /' "$ERR_FILE" >&2 || true + echo "" >&2 + echo " Expected format: ()!: (scope and ! optional)" >&2 + echo " Types: feat fix docs style refactor test chore perf build ci" >&2 + echo " (rewrite your message, or bypass with: git commit --no-verify)" >&2 + exit 1 +fi + +# Rewrite the message file into canonical form. +printf '%s\n' "$NORMALIZED" > "$MSG_FILE" +echo "🪡 commit-msg: normalized" +exit 0