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
209 changes: 209 additions & 0 deletions cmd/git-impact/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/spf13/cobra"

"impactable/internal/gitimpact"
)

type appOptions struct {
configPath string
output string
}

func main() {
root := newRootCmd(os.Stdout, os.Stderr)
if err := root.Execute(); err != nil {
os.Exit(1)
}
}

func newRootCmd(stdout io.Writer, stderr io.Writer) *cobra.Command {
opts := &appOptions{
configPath: gitimpact.DefaultConfigPath,
output: defaultOutput(stdout),
}

root := &cobra.Command{
Use: "git-impact",
Short: "Analyze product impact from Git and analytics sources",
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
opts.output = strings.ToLower(strings.TrimSpace(opts.output))
if opts.output == "" {
opts.output = defaultOutput(stdout)
}
if opts.output != "text" && opts.output != "json" {
return fmt.Errorf("invalid --output value %q (expected text or json)", opts.output)
}
return nil
},
}

root.PersistentFlags().StringVar(&opts.configPath, "config", gitimpact.DefaultConfigPath, "Path to config file")
root.PersistentFlags().StringVar(&opts.output, "output", opts.output, "Output format: text|json")

root.AddCommand(newAnalyzeCmd(opts, stdout))
root.AddCommand(newCheckSourcesCmd(opts, stdout, stderr))

return root
}

func newAnalyzeCmd(opts *appOptions, stdout io.Writer) *cobra.Command {
var since string
var pr int
var feature string

cmd := &cobra.Command{
Use: "analyze",
Short: "Run a git-impact analysis",
RunE: func(_ *cobra.Command, _ []string) error {
analysisCtx, err := gitimpact.NewAnalysisContext(since, pr, feature, opts.configPath)
if err != nil {
emitCommandError(stdout, opts.output, "analyze", err)
return err
}
prompt := gitimpact.BuildInitialPrompt(analysisCtx)

if opts.output == "json" {
payload := map[string]any{
"command": "analyze",
"status": "ok",
"analysis_context": analysisCtx,
"initial_prompt": prompt,
}
return encodeJSON(stdout, payload)
}

_, _ = fmt.Fprintf(stdout, "Analysis context loaded.\n")
_, _ = fmt.Fprintf(stdout, "Since: %s\n", displayValue(analysisCtx.Since))
if analysisCtx.PRNumber != nil {
_, _ = fmt.Fprintf(stdout, "PR: #%d\n", *analysisCtx.PRNumber)
} else {
_, _ = fmt.Fprintf(stdout, "PR: not provided\n")
}
_, _ = fmt.Fprintf(stdout, "Feature: %s\n", displayValue(analysisCtx.FeatureName))
_, _ = fmt.Fprintf(stdout, "Config: %s\n\n", analysisCtx.ConfigPath)
_, _ = fmt.Fprintln(stdout, "Initial prompt:")
_, _ = fmt.Fprintln(stdout, prompt)
return nil
},
}

cmd.Flags().StringVar(&since, "since", "", "Analyze changes since date (YYYY-MM-DD)")
cmd.Flags().IntVar(&pr, "pr", 0, "Analyze a single PR number")
cmd.Flags().StringVar(&feature, "feature", "", "Analyze a feature by name")
return cmd
}

func newCheckSourcesCmd(opts *appOptions, stdout io.Writer, stderr io.Writer) *cobra.Command {
cmd := &cobra.Command{
Use: "check-sources",
Short: "Check required Velen sources",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, _, err := gitimpact.LoadConfig(opts.configPath)
if err != nil {
emitCommandError(stdout, opts.output, "check-sources", err)
return err
}

result, checkErr := gitimpact.CheckSources(context.Background(), cfg)
if result == nil {
result = &gitimpact.SourceCheckResult{}
}

if opts.output == "json" {
if err := encodeJSON(stdout, result); err != nil {
return err
}
} else {
printCheckSourcesText(stdout, result)
}

if checkErr != nil {
if opts.output == "text" {
_, _ = fmt.Fprintf(stderr, "Source check failed: %s\n", checkErr.Error())
}
return checkErr
}
return nil
},
}
return cmd
}

func printCheckSourcesText(writer io.Writer, result *gitimpact.SourceCheckResult) {
_, _ = fmt.Fprintln(writer, "Source check")
_, _ = fmt.Fprintf(writer, "Org: %s\n", displayValue(result.OrgName))
if result.GitHubSource != nil {
_, _ = fmt.Fprintf(writer, "GitHub source: %s (%s)\n", displayValue(result.GitHubSource.Key), displayValue(result.GitHubSource.ProviderType))
} else {
_, _ = fmt.Fprintln(writer, "GitHub source: not found")
}
_, _ = fmt.Fprintf(writer, "GitHub QUERY support: %s\n", statusLabel(result.GitHubOK))

if result.AnalyticsSource != nil {
_, _ = fmt.Fprintf(writer, "Analytics source: %s (%s)\n", displayValue(result.AnalyticsSource.Key), displayValue(result.AnalyticsSource.ProviderType))
} else {
_, _ = fmt.Fprintln(writer, "Analytics source: not found")
}
_, _ = fmt.Fprintf(writer, "Analytics QUERY support: %s\n", statusLabel(result.AnalyticsOK))

if strings.TrimSpace(result.Error) != "" {
_, _ = fmt.Fprintf(writer, "Error: %s\n", result.Error)
}
}

func statusLabel(ok bool) string {
if ok {
return "ok"
}
return "failed"
}

func displayValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "not provided"
}
return trimmed
}

func defaultOutput(stdout io.Writer) string {
file, ok := stdout.(*os.File)
if !ok {
return "json"
}
info, err := file.Stat()
if err != nil {
return "json"
}
if info.Mode()&os.ModeCharDevice != 0 {
return "text"
}
return "json"
}

func encodeJSON(writer io.Writer, payload any) error {
encoder := json.NewEncoder(writer)
return encoder.Encode(payload)
}

func emitCommandError(stdout io.Writer, output string, command string, err error) {
if output != "json" {
return
}
_ = encodeJSON(stdout, map[string]any{
"command": command,
"status": "failed",
"error": err.Error(),
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Step 5 of 10 - Implement CLI args, structured context, initial prompt, and check-sources

## Goal
Implement Step 5 for `git-impact`: wire CLI args into a structured analysis context, generate the agent initial prompt from context + config, add a `check-sources` command with structured results, and cover behavior with tests.

## Background
- Product requirements come from `SPEC.md` section 4.3 (source discovery strategy) and section 7 (CLI interface + structured context passing).
- Merge-blocking repository rules require tests for all new behavior and machine-readable output for automation-facing commands.
- Step scope includes three code surfaces: `internal/gitimpact/context.go`, `internal/gitimpact/check_sources.go`, and `cmd/git-impact/main.go`, plus unit tests.
- Current worktree does not yet contain `internal/gitimpact/` or `cmd/git-impact/`; Step 5 will proceed by implementing or extending those paths per prior step expectations.

## Milestones
| ID | Milestone | Status | Exit criteria |
| --- | --- | --- | --- |
| M1 | Baseline and scaffolding alignment | completed | Verified prior-step `git-impact` scaffolding was absent; created `internal/gitimpact` and `cmd/git-impact` with core shared types (`AnalysisContext`, `Config`, `Source`, `VelenClient`) and CLI/package layout. |
| M2 | Context + prompt implementation | completed | Implemented `NewAnalysisContext(...)` with Viper-backed config loading from `--config`, optional PR/feature population, and `BuildInitialPrompt(...)` containing `since`/`pr`/`feature` plus configured Velen source keys. |
| M3 | CLI command surface wiring | completed | Added Cobra root `git-impact` with persistent `--config` and `--output` (TTY-aware default), `analyze` flags (`--since`, `--pr`, `--feature`), and `check-sources` subcommand wiring. |
| M4 | Source check implementation + output modes | completed | Implemented `CheckSources(...)` and `SourceCheckResult`; command flow runs auth/org/source discovery via Velen client abstraction, matches GitHub/analytics providers by `provider_type`, validates QUERY support, and renders text or JSON output. |
| M5 | Tests and verification | completed | Added mock-client tests in `check_sources_test.go` (success/failure/source capability and call ordering), added context/prompt coverage, and validated `go build ./...` + `go test ./...` passing. |

## Current progress
- Overall status: completed.
- Implemented all Step 5 surfaces:
- `internal/gitimpact/types.go`, `context.go`, `velen.go`, `check_sources.go`, `context_test.go`, `check_sources_test.go`
- `cmd/git-impact/main.go`
- Added module dependencies for Cobra and Viper in `go.mod`/`go.sum`.
- Verification completed successfully:
- `go build ./...`
- `go test ./...`

## Key decisions
- Follow `SPEC.md` section 4.3 as the canonical behavior for source discovery and validation.
- Keep command outputs automation-safe: structured JSON for `--output json`, human summary for text mode.
- Model optional analysis selectors as explicit optional fields in context (`PR number`, `feature`) while preserving `since` and config provenance.
- Treat provider detection as case-insensitive substring matching on `provider_type` for GitHub and analytics family providers.
- Use a `VelenClient` interface with a concrete CLI-backed implementation (`os/exec`) to keep runtime behavior aligned with spec while keeping unit tests deterministic.
- Return a fully populated `SourceCheckResult` on failure paths (with `Error`) and propagate an error for non-zero command behavior.

## Remaining issues
- None in Step 5 scope.

## Links
- Spec (source discovery): `SPEC.md` section 4.3
- Spec (CLI + context): `SPEC.md` section 7
- Merge rules: `NON_NEGOTIABLE_RULES.md`
- Plan policy: `docs/PLANS.md`
- Target files:
- `internal/gitimpact/context.go`
- `internal/gitimpact/check_sources.go`
- `internal/gitimpact/check_sources_test.go`
- `cmd/git-impact/main.go`
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=
Loading