Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cmd/git-impact/main.go
Original file line number Diff line number Diff line change
@@ -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))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 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
- [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
- 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 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`
- `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`
4 changes: 4 additions & 0 deletions git-impact
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail

exec go run ./cmd/git-impact "$@"
21 changes: 21 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
34 changes: 34 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
82 changes: 82 additions & 0 deletions internal/gitimpact/cli.go
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 61 additions & 0 deletions internal/gitimpact/cli_test.go
Original file line number Diff line number Diff line change
@@ -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
}
42 changes: 42 additions & 0 deletions internal/gitimpact/config.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading