From e78604de47e34bb2adc803f8b3d55e5da8492beb Mon Sep 17 00:00:00 2001 From: siisee11 Date: Sun, 22 Mar 2026 18:28:00 +0900 Subject: [PATCH 1/3] plan: step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch --- ...-project-scaffold-read-spec-md-and-arch.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md diff --git a/docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md b/docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md new file mode 100644 index 0000000..224ecad --- /dev/null +++ b/docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md @@ -0,0 +1,35 @@ +# Step 1 of 10 - Implement the git-impact CLI project scaffold (read SPEC.md and ARCHITECTURE.md) + +## Goal +Create the initial `git-impact` Go project scaffold described in `SPEC.md`, including CLI entrypoint stubs, core `internal/gitimpact` stubs, config/context plumbing, initial tests, and a successful `go build ./...` baseline. + +## Background +`SPEC.md` defines the Git Impact Analyzer as a new monorepo CLI with `analyze` and `check-sources` commands plus config-driven analysis behavior. `ARCHITECTURE.md` requires thin `cmd/*` entrypoints and behavior inside `internal/*` packages. This step establishes the first shippable foundation before implementing full analysis logic. + +## Milestones +- [ ] M1 (`not started`): Add `cmd/git-impact/main.go` with Cobra root command and stub `analyze` + `check-sources` subcommands, delegating implementation surface to `internal/gitimpact`. +- [ ] M2 (`not started`): Create `internal/gitimpact/types.go` with initial domain types (`Config`, `AnalysisContext`, `PR`, `Deployment`, `FeatureGroup`, `ContributorStats`, `PRImpact`, `AnalysisResult`) aligned to `SPEC.md` terminology. +- [ ] M3 (`not started`): Implement `internal/gitimpact/config.go` for YAML config loading via Viper using `impact-analyzer.yaml` schema from SPEC section 8, with defaults for analysis windows. +- [ ] M4 (`not started`): Implement `internal/gitimpact/context.go` for converting CLI arguments into `AnalysisContext`. +- [ ] M5 (`not started`): Add repo-root `./git-impact` executable shim matching existing wrapper style (`./ralph-loop`, `./wtl`). +- [ ] M6 (`not started`): Add/update dependencies in `go.mod` for Cobra and Viper and write tests for config loading + context construction. +- [ ] M7 (`not started`): Run validation (`go build ./...`, relevant tests), address failures, and confirm scaffold compiles cleanly. + +## Current progress +- Execution plan initialized. +- Implementation milestones not yet started. + +## Key decisions +- Keep `cmd/git-impact` thin per architecture boundary rules; place behavior in `internal/gitimpact`. +- Start with command stubs and testable config/context primitives to enable incremental follow-up steps. +- Encode defaults in config load path so CLI runs remain predictable without full user config. + +## Remaining issues +- Exact field-level shape for all domain structs may evolve in later steps as WTL phase integration is implemented. +- Source-check and analyze command runtime logic remain stubbed in this step by design. + +## Links +- `SPEC.md` +- `ARCHITECTURE.md` +- `docs/PLANS.md` +- `docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md` From 6ab1e3e8c42581cfef92701a54242e2a32437429 Mon Sep 17 00:00:00 2001 From: siisee11 Date: Sun, 22 Mar 2026 18:34:14 +0900 Subject: [PATCH 2/3] feat: scaffold git-impact CLI and core config/context primitives --- cmd/git-impact/main.go | 16 ++++ ...-project-scaffold-read-spec-md-and-arch.md | 26 ++--- git-impact | 4 + go.mod | 21 +++++ go.sum | 34 +++++++ internal/gitimpact/cli.go | 82 ++++++++++++++++ internal/gitimpact/cli_test.go | 61 ++++++++++++ internal/gitimpact/config.go | 42 +++++++++ internal/gitimpact/config_test.go | 94 +++++++++++++++++++ internal/gitimpact/context.go | 82 ++++++++++++++++ internal/gitimpact/context_test.go | 48 ++++++++++ internal/gitimpact/types.go | 85 +++++++++++++++++ 12 files changed, 584 insertions(+), 11 deletions(-) create mode 100644 cmd/git-impact/main.go create mode 100755 git-impact create mode 100644 go.sum create mode 100644 internal/gitimpact/cli.go create mode 100644 internal/gitimpact/cli_test.go create mode 100644 internal/gitimpact/config.go create mode 100644 internal/gitimpact/config_test.go create mode 100644 internal/gitimpact/context.go create mode 100644 internal/gitimpact/context_test.go create mode 100644 internal/gitimpact/types.go diff --git a/cmd/git-impact/main.go b/cmd/git-impact/main.go new file mode 100644 index 0000000..ad233cd --- /dev/null +++ b/cmd/git-impact/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "os" + + "impactable/internal/gitimpact" +) + +func main() { + cwd, err := os.Getwd() + if err != nil { + _, _ = os.Stderr.WriteString(err.Error() + "\n") + os.Exit(1) + } + os.Exit(gitimpact.Run(os.Args[1:], cwd, os.Stdin, os.Stdout, os.Stderr)) +} diff --git a/docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md b/docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md index 224ecad..94dece8 100644 --- a/docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md +++ b/docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md @@ -7,26 +7,30 @@ Create the initial `git-impact` Go project scaffold described in `SPEC.md`, incl `SPEC.md` defines the Git Impact Analyzer as a new monorepo CLI with `analyze` and `check-sources` commands plus config-driven analysis behavior. `ARCHITECTURE.md` requires thin `cmd/*` entrypoints and behavior inside `internal/*` packages. This step establishes the first shippable foundation before implementing full analysis logic. ## Milestones -- [ ] M1 (`not started`): Add `cmd/git-impact/main.go` with Cobra root command and stub `analyze` + `check-sources` subcommands, delegating implementation surface to `internal/gitimpact`. -- [ ] M2 (`not started`): Create `internal/gitimpact/types.go` with initial domain types (`Config`, `AnalysisContext`, `PR`, `Deployment`, `FeatureGroup`, `ContributorStats`, `PRImpact`, `AnalysisResult`) aligned to `SPEC.md` terminology. -- [ ] M3 (`not started`): Implement `internal/gitimpact/config.go` for YAML config loading via Viper using `impact-analyzer.yaml` schema from SPEC section 8, with defaults for analysis windows. -- [ ] M4 (`not started`): Implement `internal/gitimpact/context.go` for converting CLI arguments into `AnalysisContext`. -- [ ] M5 (`not started`): Add repo-root `./git-impact` executable shim matching existing wrapper style (`./ralph-loop`, `./wtl`). -- [ ] M6 (`not started`): Add/update dependencies in `go.mod` for Cobra and Viper and write tests for config loading + context construction. -- [ ] M7 (`not started`): Run validation (`go build ./...`, relevant tests), address failures, and confirm scaffold compiles cleanly. +- [x] M1 (`completed`): Added `cmd/git-impact/main.go` with thin entrypoint into `internal/gitimpact` and Cobra-backed `analyze` + `check-sources` stubs. +- [x] M2 (`completed`): Created `internal/gitimpact/types.go` with initial domain/config/result structs aligned to `SPEC.md`. +- [x] M3 (`completed`): Implemented `internal/gitimpact/config.go` using Viper for `impact-analyzer.yaml` load/decode with default analysis windows. +- [x] M4 (`completed`): Implemented `internal/gitimpact/context.go` to convert CLI args into validated `AnalysisContext`. +- [x] M5 (`completed`): Added repo-root `./git-impact` executable shim matching existing wrapper style. +- [x] M6 (`completed`): Updated `go.mod`/`go.sum` with Cobra + Viper dependencies and added tests for config loading/context construction plus command-stub behavior. +- [x] M7 (`completed`): Validation passed via `go test ./...` and `go build ./...` (with `GOCACHE=/tmp/go-build-cache` for sandbox compatibility). ## Current progress -- Execution plan initialized. -- Implementation milestones not yet started. +- Step 1 scaffold is implemented end-to-end and committed-ready. +- New package surface exists at `internal/gitimpact` (`cli.go`, `types.go`, `config.go`, `context.go`) with command stubs and shared config/context plumbing. +- Tests now cover config defaults/overrides, CLI-arg context conversion, and stub command execution paths. +- Validation baseline is green for the repository after dependency additions. ## Key decisions - Keep `cmd/git-impact` thin per architecture boundary rules; place behavior in `internal/gitimpact`. - Start with command stubs and testable config/context primitives to enable incremental follow-up steps. - Encode defaults in config load path so CLI runs remain predictable without full user config. +- Keep `analyze` and `check-sources` behavior explicitly stubbed by returning `not implemented` sentinel errors after config/context validation. +- Resolve relative config paths against the caller working directory during context construction. ## Remaining issues -- Exact field-level shape for all domain structs may evolve in later steps as WTL phase integration is implemented. -- Source-check and analyze command runtime logic remain stubbed in this step by design. +- Exact field-level shape for some domain structs may evolve in later steps as WTL phase integration is implemented. +- `analyze` and `check-sources` command runtime logic remains stubbed in this step by design. ## Links - `SPEC.md` diff --git a/git-impact b/git-impact new file mode 100755 index 0000000..341c4df --- /dev/null +++ b/git-impact @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec go run ./cmd/git-impact "$@" diff --git a/go.mod b/go.mod index 7a3e3d8..e5ffa24 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,24 @@ module impactable go 1.26 + +require ( + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5ad2cc6 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/gitimpact/cli.go b/internal/gitimpact/cli.go new file mode 100644 index 0000000..92402f9 --- /dev/null +++ b/internal/gitimpact/cli.go @@ -0,0 +1,82 @@ +package gitimpact + +import ( + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" +) + +var ( + ErrAnalyzeNotImplemented = errors.New("analyze command is not implemented yet") + ErrCheckSourcesNotImplemented = errors.New("check-sources command is not implemented yet") +) + +// Run executes git-impact CLI commands. +func Run(args []string, cwd string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { + root := NewRootCommand(cwd, stdin, stdout, stderr) + root.SetArgs(args) + if err := root.Execute(); err != nil { + if stderr != nil { + _, _ = fmt.Fprintln(stderr, err) + } + return 1 + } + return 0 +} + +// NewRootCommand builds the Cobra command tree for git-impact. +func NewRootCommand(cwd string, stdin io.Reader, stdout io.Writer, stderr io.Writer) *cobra.Command { + var analyzeArgs CLIArgs + var checkSourcesConfigPath string + + root := &cobra.Command{ + Use: "git-impact", + Short: "Analyze git change impact against product metrics", + SilenceUsage: true, + SilenceErrors: true, + } + root.SetIn(stdin) + root.SetOut(stdout) + root.SetErr(stderr) + + analyzeCmd := &cobra.Command{ + Use: "analyze", + Short: "Run impact analysis", + RunE: func(_ *cobra.Command, _ []string) error { + ctx, err := NewAnalysisContext(cwd, analyzeArgs) + if err != nil { + return err + } + if _, err := LoadConfig(ctx.ConfigPath); err != nil { + return err + } + return ErrAnalyzeNotImplemented + }, + } + analyzeCmd.Flags().StringVar(&analyzeArgs.ConfigPath, "config", DefaultConfigFile, "Path to impact analyzer config file") + analyzeCmd.Flags().StringVar(&analyzeArgs.Since, "since", "", "Analyze changes since YYYY-MM-DD") + analyzeCmd.Flags().IntVar(&analyzeArgs.PR, "pr", 0, "Analyze a specific PR number") + analyzeCmd.Flags().StringVar(&analyzeArgs.Feature, "feature", "", "Analyze a specific feature group") + + checkSourcesCmd := &cobra.Command{ + Use: "check-sources", + Short: "Validate configured Velen sources", + RunE: func(_ *cobra.Command, _ []string) error { + ctx, err := NewAnalysisContext(cwd, CLIArgs{ConfigPath: checkSourcesConfigPath}) + if err != nil { + return err + } + if _, err := LoadConfig(ctx.ConfigPath); err != nil { + return err + } + return ErrCheckSourcesNotImplemented + }, + } + checkSourcesCmd.Flags().StringVar(&checkSourcesConfigPath, "config", DefaultConfigFile, "Path to impact analyzer config file") + + root.AddCommand(analyzeCmd) + root.AddCommand(checkSourcesCmd) + return root +} diff --git a/internal/gitimpact/cli_test.go b/internal/gitimpact/cli_test.go new file mode 100644 index 0000000..06b98e3 --- /dev/null +++ b/internal/gitimpact/cli_test.go @@ -0,0 +1,61 @@ +package gitimpact + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRun_AnalyzeStub(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + configPath := writeTestConfig(t, cwd) + + var stderr bytes.Buffer + exitCode := Run([]string{"analyze", "--config", configPath}, cwd, strings.NewReader(""), io.Discard, &stderr) + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), ErrAnalyzeNotImplemented.Error()) { + t.Fatalf("expected analyze stub error, got %q", stderr.String()) + } +} + +func TestRun_CheckSourcesStub(t *testing.T) { + t.Parallel() + + cwd := t.TempDir() + configPath := writeTestConfig(t, cwd) + + var stderr bytes.Buffer + exitCode := Run([]string{"check-sources", "--config", configPath}, cwd, strings.NewReader(""), io.Discard, &stderr) + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), ErrCheckSourcesNotImplemented.Error()) { + t.Fatalf("expected check-sources stub error, got %q", stderr.String()) + } +} + +func writeTestConfig(t *testing.T, dir string) string { + t.Helper() + + path := filepath.Join(dir, DefaultConfigFile) + content := `velen: + org: my-company + sources: + github: github-main + analytics: amplitude-prod +feature_grouping: + strategies: + - label_prefix +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write test config: %v", err) + } + return path +} diff --git a/internal/gitimpact/config.go b/internal/gitimpact/config.go new file mode 100644 index 0000000..885ae80 --- /dev/null +++ b/internal/gitimpact/config.go @@ -0,0 +1,42 @@ +package gitimpact + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +const ( + DefaultConfigFile = "impact-analyzer.yaml" + DefaultBeforeWindowDays = 7 + DefaultAfterWindowDays = 7 + DefaultCooldownHours = 24 + DefaultFeatureMappingsFile = "feature-map.yaml" +) + +// LoadConfig reads and decodes impact-analyzer.yaml configuration. +func LoadConfig(configPath string) (Config, error) { + resolvedPath := strings.TrimSpace(configPath) + if resolvedPath == "" { + resolvedPath = DefaultConfigFile + } + + v := viper.New() + v.SetConfigFile(resolvedPath) + v.SetConfigType("yaml") + v.SetDefault("analysis.before_window_days", DefaultBeforeWindowDays) + v.SetDefault("analysis.after_window_days", DefaultAfterWindowDays) + v.SetDefault("analysis.cooldown_hours", DefaultCooldownHours) + v.SetDefault("feature_grouping.custom_mappings_file", DefaultFeatureMappingsFile) + + if err := v.ReadInConfig(); err != nil { + return Config{}, fmt.Errorf("read config %q: %w", resolvedPath, err) + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return Config{}, fmt.Errorf("decode config %q: %w", resolvedPath, err) + } + return cfg, nil +} diff --git a/internal/gitimpact/config_test.go b/internal/gitimpact/config_test.go new file mode 100644 index 0000000..7c30371 --- /dev/null +++ b/internal/gitimpact/config_test.go @@ -0,0 +1,94 @@ +package gitimpact + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig_AppliesDefaults(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "impact-analyzer.yaml") + content := `velen: + org: my-company + sources: + github: github-main + analytics: amplitude-prod +feature_grouping: + strategies: + - label_prefix + - branch_prefix + custom_mappings_file: feature-map.yaml +` + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig returned error: %v", err) + } + + if cfg.Velen.Org != "my-company" { + t.Fatalf("expected org my-company, got %q", cfg.Velen.Org) + } + if cfg.Velen.Sources.GitHub != "github-main" { + t.Fatalf("expected github source github-main, got %q", cfg.Velen.Sources.GitHub) + } + if cfg.Velen.Sources.Analytics != "amplitude-prod" { + t.Fatalf("expected analytics source amplitude-prod, got %q", cfg.Velen.Sources.Analytics) + } + if cfg.Analysis.BeforeWindowDays != DefaultBeforeWindowDays { + t.Fatalf("expected before window default %d, got %d", DefaultBeforeWindowDays, cfg.Analysis.BeforeWindowDays) + } + if cfg.Analysis.AfterWindowDays != DefaultAfterWindowDays { + t.Fatalf("expected after window default %d, got %d", DefaultAfterWindowDays, cfg.Analysis.AfterWindowDays) + } + if cfg.Analysis.CooldownHours != DefaultCooldownHours { + t.Fatalf("expected cooldown default %d, got %d", DefaultCooldownHours, cfg.Analysis.CooldownHours) + } +} + +func TestLoadConfig_UsesExplicitAnalysisValues(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "impact-analyzer.yaml") + content := `velen: + org: my-company + sources: + github: github-main + analytics: amplitude-prod +analysis: + before_window_days: 10 + after_window_days: 5 + cooldown_hours: 12 +feature_grouping: + strategies: + - label_prefix + custom_mappings_file: custom-feature-map.yaml +` + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig returned error: %v", err) + } + + if cfg.Analysis.BeforeWindowDays != 10 { + t.Fatalf("expected before window 10, got %d", cfg.Analysis.BeforeWindowDays) + } + if cfg.Analysis.AfterWindowDays != 5 { + t.Fatalf("expected after window 5, got %d", cfg.Analysis.AfterWindowDays) + } + if cfg.Analysis.CooldownHours != 12 { + t.Fatalf("expected cooldown 12, got %d", cfg.Analysis.CooldownHours) + } + if cfg.FeatureGrouping.CustomMappingsFile != "custom-feature-map.yaml" { + t.Fatalf("expected custom mapping file custom-feature-map.yaml, got %q", cfg.FeatureGrouping.CustomMappingsFile) + } +} diff --git a/internal/gitimpact/context.go b/internal/gitimpact/context.go new file mode 100644 index 0000000..833a7e7 --- /dev/null +++ b/internal/gitimpact/context.go @@ -0,0 +1,82 @@ +package gitimpact + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const sinceDateLayout = "2006-01-02" + +type CLIArgs struct { + ConfigPath string + Since string + PR int + Feature string +} + +// NewAnalysisContext converts parsed CLI arguments into a runtime context. +func NewAnalysisContext(cwd string, args CLIArgs) (AnalysisContext, error) { + workingDirectory := strings.TrimSpace(cwd) + if workingDirectory == "" { + wd, err := os.Getwd() + if err != nil { + return AnalysisContext{}, fmt.Errorf("resolve working directory: %w", err) + } + workingDirectory = wd + } + + configPath, err := resolveConfigPath(workingDirectory, args.ConfigPath) + if err != nil { + return AnalysisContext{}, err + } + + since, err := parseSince(args.Since) + if err != nil { + return AnalysisContext{}, err + } + + feature := strings.TrimSpace(args.Feature) + if args.PR < 0 { + return AnalysisContext{}, fmt.Errorf("--pr must be zero or a positive integer") + } + if args.PR > 0 && feature != "" { + return AnalysisContext{}, fmt.Errorf("--pr and --feature cannot be set together") + } + + return AnalysisContext{ + WorkingDirectory: workingDirectory, + ConfigPath: configPath, + Since: since, + PRNumber: args.PR, + Feature: feature, + }, nil +} + +func resolveConfigPath(cwd string, configPath string) (string, error) { + trimmed := strings.TrimSpace(configPath) + if trimmed == "" { + trimmed = DefaultConfigFile + } + if filepath.IsAbs(trimmed) { + return filepath.Clean(trimmed), nil + } + if strings.TrimSpace(cwd) == "" { + return "", fmt.Errorf("working directory is required to resolve relative --config") + } + return filepath.Clean(filepath.Join(cwd, trimmed)), nil +} + +func parseSince(value string) (*time.Time, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, nil + } + parsed, err := time.Parse(sinceDateLayout, trimmed) + if err != nil { + return nil, fmt.Errorf("invalid --since value %q (expected YYYY-MM-DD)", trimmed) + } + return &parsed, nil +} diff --git a/internal/gitimpact/context_test.go b/internal/gitimpact/context_test.go new file mode 100644 index 0000000..f8c2d4a --- /dev/null +++ b/internal/gitimpact/context_test.go @@ -0,0 +1,48 @@ +package gitimpact + +import "testing" + +func TestNewAnalysisContext_WithDefaults(t *testing.T) { + t.Parallel() + + ctx, err := NewAnalysisContext("/repo", CLIArgs{Since: "2026-01-01"}) + if err != nil { + t.Fatalf("NewAnalysisContext returned error: %v", err) + } + if ctx.WorkingDirectory != "/repo" { + t.Fatalf("expected working directory /repo, got %q", ctx.WorkingDirectory) + } + if ctx.ConfigPath != "/repo/impact-analyzer.yaml" { + t.Fatalf("expected default config path /repo/impact-analyzer.yaml, got %q", ctx.ConfigPath) + } + if ctx.Since == nil { + t.Fatalf("expected since date to be set") + } + if got := ctx.Since.Format(sinceDateLayout); got != "2026-01-01" { + t.Fatalf("expected since date 2026-01-01, got %s", got) + } + if ctx.PRNumber != 0 { + t.Fatalf("expected default PR number 0, got %d", ctx.PRNumber) + } + if ctx.Feature != "" { + t.Fatalf("expected empty feature, got %q", ctx.Feature) + } +} + +func TestNewAnalysisContext_RejectsInvalidSince(t *testing.T) { + t.Parallel() + + _, err := NewAnalysisContext("/repo", CLIArgs{Since: "01-01-2026"}) + if err == nil { + t.Fatalf("expected invalid --since error") + } +} + +func TestNewAnalysisContext_RejectsPRAndFeatureTogether(t *testing.T) { + t.Parallel() + + _, err := NewAnalysisContext("/repo", CLIArgs{PR: 42, Feature: "onboarding-v2"}) + if err == nil { + t.Fatalf("expected mutually exclusive --pr and --feature error") + } +} diff --git a/internal/gitimpact/types.go b/internal/gitimpact/types.go new file mode 100644 index 0000000..f64f9f3 --- /dev/null +++ b/internal/gitimpact/types.go @@ -0,0 +1,85 @@ +package gitimpact + +import "time" + +// Config captures the impact-analyzer.yaml schema. +type Config struct { + Velen VelenConfig `mapstructure:"velen"` + Analysis AnalysisConfig `mapstructure:"analysis"` + FeatureGrouping FeatureGroupingConfig `mapstructure:"feature_grouping"` +} + +type VelenConfig struct { + Org string `mapstructure:"org"` + Sources VelenSources `mapstructure:"sources"` +} + +type VelenSources struct { + GitHub string `mapstructure:"github"` + Analytics string `mapstructure:"analytics"` +} + +type AnalysisConfig struct { + BeforeWindowDays int `mapstructure:"before_window_days"` + AfterWindowDays int `mapstructure:"after_window_days"` + CooldownHours int `mapstructure:"cooldown_hours"` +} + +type FeatureGroupingConfig struct { + Strategies []string `mapstructure:"strategies"` + CustomMappingsFile string `mapstructure:"custom_mappings_file"` +} + +// AnalysisContext is the structured context passed into analysis runtime. +type AnalysisContext struct { + WorkingDirectory string + ConfigPath string + Since *time.Time + PRNumber int + Feature string +} + +type PR struct { + Number int + Title string + Author string + MergedAt time.Time + Branch string + Labels []string + ChangedFile []string +} + +type Deployment struct { + PRNumber int + Marker string + Source string + DeployedAt time.Time +} + +type FeatureGroup struct { + Name string + PRNumbers []int +} + +type ContributorStats struct { + Author string + PRCount int + AverageScore float64 + TopPRNumber int +} + +type PRImpact struct { + PRNumber int + Score float64 + Confidence string + Reasoning string +} + +type AnalysisResult struct { + GeneratedAt time.Time + PRs []PR + Deployments []Deployment + FeatureGroups []FeatureGroup + Contributors []ContributorStats + PRImpacts []PRImpact +} From 9a1338400eeb13cab186d94c804584067214f045 Mon Sep 17 00:00:00 2001 From: siisee11 Date: Sun, 22 Mar 2026 18:35:10 +0900 Subject: [PATCH 3/3] plan: move step-1 git-impact scaffold plan to completed --- ...t-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/exec-plans/{active => completed}/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md (100%) diff --git a/docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md b/docs/exec-plans/completed/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md similarity index 100% rename from docs/exec-plans/active/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md rename to docs/exec-plans/completed/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md