Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
948af4c
plan: step-1-of-10-implement-the-git-impact-cli-project-scaffold-read…
siisee11 Mar 22, 2026
2ae3841
feat: scaffold git-impact CLI and core config/context primitives
siisee11 Mar 22, 2026
1a6e5fc
plan: move step-1 git-impact scaffold plan to completed
siisee11 Mar 22, 2026
a75d8f4
plan: step-2-of-10-implement-the-velen-cli-wrapper-for-the-git-impact…
siisee11 Mar 22, 2026
6c98446
step2: add velen cli wrapper for gitimpact
siisee11 Mar 22, 2026
6fda407
plan: step-3-of-10-implement-the-wtl-engine-and-phaseddeliverypolicy-…
siisee11 Mar 22, 2026
29af5fd
feat(gitimpact): implement phased engine and policy
siisee11 Mar 22, 2026
cfce335
plan: step-4-of-10-implement-the-wtl-observer-bubble-tea-msg-bridge-f…
siisee11 Mar 22, 2026
c8682a4
chore(gitimpact): add bubble tea dependencies (step4 m1)
siisee11 Mar 22, 2026
72edb46
feat(gitimpact): add observer to bubble tea bridge (step4 m2)
siisee11 Mar 22, 2026
5d366ed
feat(gitimpact): add minimal bubble tea analysis model (step4 m3)
siisee11 Mar 22, 2026
6a3fcf1
test(gitimpact): verify observer bubble tea message bridge (step4 m4)
siisee11 Mar 22, 2026
fd74239
chore(plan): mark step4 milestone m5 complete
siisee11 Mar 22, 2026
156fe76
feat(gitimpact): add observer to bubble tea bridge (step4 m2)
siisee11 Mar 22, 2026
9ed19aa
feat(gitimpact): add minimal bubble tea analysis model (step4 m3)
siisee11 Mar 22, 2026
baf8926
test(gitimpact): verify observer bubble tea message bridge (step4 m4)
siisee11 Mar 22, 2026
bc029f5
fix: resolve type conflicts from independent step branches in integra…
siisee11 Mar 22, 2026
d5439b3
plan: step-5-of-10-implement-cli-args-structured-context-agent-initia…
siisee11 Mar 22, 2026
11b0f86
Implement git-impact context/prompt and check-sources CLI
siisee11 Mar 22, 2026
bebf64b
plan: move step-5-of-10 to completed
siisee11 Mar 22, 2026
98bd618
plan: step-6-of-10-implement-the-source-check-collect-phase-handlers-…
siisee11 Mar 22, 2026
e0f2c86
plan(step6): complete milestone 1 contract review
siisee11 Mar 22, 2026
12a7029
feat(gitimpact): add source check phase handler
siisee11 Mar 22, 2026
e7e949f
feat(gitimpact): add collect phase handler stub
siisee11 Mar 22, 2026
04d2fd5
test(gitimpact): cover source-check and collect handlers
siisee11 Mar 22, 2026
d6fd619
plan: step-7-of-10-retry-implement-the-linker-phase-handler-turn-2-fo…
siisee11 Mar 22, 2026
356ad37
feat: implement linker phase handler
siisee11 Mar 22, 2026
7efd1e2
plan: step-8-of-10-implement-the-impact-scorer-phase-handler-turn-3-f…
siisee11 Mar 22, 2026
25aad80
feat: implement impact scorer phase handler
siisee11 Mar 22, 2026
a5f395d
plan: step-9-of-10-implement-the-tui-analysis-progress-view-for-git-i…
siisee11 Mar 22, 2026
234a737
Implement git-impact analysis progress TUI and default engine wiring
siisee11 Mar 22, 2026
6016891
plan: move step 9 exec plan to completed
siisee11 Mar 22, 2026
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
328 changes: 328 additions & 0 deletions cmd/git-impact/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
package main

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

"impactable/internal/gitimpact"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)

func main() {
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}

type cliState struct {
configPath string
output string
}

func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
root, state := newRootCommand(stdin, stdout, stderr)
root.SetArgs(args)
if err := root.Execute(); err != nil {
return emitCommandError(state.output, stdout, stderr, err)
}
return 0
}

func newRootCommand(stdin io.Reader, stdout io.Writer, stderr io.Writer) (*cobra.Command, *cliState) {
state := &cliState{
configPath: gitimpact.DefaultConfigFile,
output: defaultOutput(stdout),
}

root := &cobra.Command{
Use: "git-impact",
Short: "Analyze git change impact against product metrics",
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
state.output = strings.ToLower(strings.TrimSpace(state.output))
switch state.output {
case "text", "json":
return nil
default:
return fmt.Errorf("invalid --output value %q (expected text or json)", state.output)
}
},
}
root.SetIn(stdin)
root.SetOut(stdout)
root.SetErr(stderr)
root.PersistentFlags().StringVar(&state.configPath, "config", gitimpact.DefaultConfigFile, "Path to impact analyzer config file")
root.PersistentFlags().StringVar(&state.output, "output", state.output, "Output format (text or json)")

var since string
var prNum int
var feature string
analyzeCmd := &cobra.Command{
Use: "analyze",
Short: "Run impact analysis",
RunE: func(cmd *cobra.Command, _ []string) error {
analysisCtx, err := gitimpact.NewAnalysisContext(since, prNum, feature, state.configPath)
if err != nil {
return err
}
cfg, err := gitimpact.LoadConfig(analysisCtx.ConfigPath)
if err != nil {
return err
}

runCtx := &gitimpact.RunContext{
Config: &cfg,
AnalysisCtx: analysisCtx,
VelenClient: gitimpact.NewVelenClient(0),
}

interactiveTUI := state.output == "text" && isTerminalWriter(stdout)
if interactiveTUI {
result, err := runAnalyzeWithTUI(cmd.Context(), stdin, stdout, runCtx)
if err != nil {
return err
}
if result == nil {
return fmt.Errorf("analysis completed without a result")
}
return nil
}

waitHandler := newNonInteractiveWaitHandler()
if isTerminalReader(stdin) {
waitHandler = newPromptWaitHandler(stdin, stdout)
}
engine := gitimpact.NewDefaultEngine(runCtx.VelenClient, nil, waitHandler)
result, err := engine.Run(cmd.Context(), runCtx)
if err != nil {
return err
}
if result == nil {
return fmt.Errorf("analysis completed without a result")
}

payload := map[string]any{
"command": "analyze",
"status": "ok",
"result": result,
"context": analysisCtx,
"initial_prompt": gitimpact.BuildInitialPrompt(analysisCtx, &cfg),
}
return emitAnalyzeResult(state.output, stdout, payload, result)
},
}
analyzeCmd.Flags().StringVar(&since, "since", "", "Analyze changes since YYYY-MM-DD")
analyzeCmd.Flags().IntVar(&prNum, "pr", 0, "Analyze a specific PR number")
analyzeCmd.Flags().StringVar(&feature, "feature", "", "Analyze a specific feature group")

checkSourcesCmd := &cobra.Command{
Use: "check-sources",
Short: "Validate configured Velen sources",
RunE: func(cmd *cobra.Command, _ []string) error {
analysisCtx, err := gitimpact.NewAnalysisContext("", 0, "", state.configPath)
if err != nil {
return err
}

cfg, err := gitimpact.LoadConfig(analysisCtx.ConfigPath)
if err != nil {
return err
}

result, err := gitimpact.CheckSources(cmd.Context(), gitimpact.NewVelenClient(0), &cfg)
if err != nil {
return err
}
return emitSourceCheckResult(state.output, stdout, result)
},
}

root.AddCommand(analyzeCmd)
root.AddCommand(checkSourcesCmd)
return root, state
}

func runAnalyzeWithTUI(ctx context.Context, stdin io.Reader, stdout io.Writer, runCtx *gitimpact.RunContext) (*gitimpact.AnalysisResult, error) {
phases := gitimpact.DefaultAnalysisPhases()
model := gitimpact.NewAnalysisModel(phases)
program := tea.NewProgram(
&model,
tea.WithInput(nil),
tea.WithOutput(stdout),
tea.WithoutSignalHandler(),
)

runDone := make(chan error, 1)
go func() {
_, err := program.Run()
runDone <- err
}()

observer := gitimpact.NewTUIObserver(program)
waitHandler := newNonInteractiveWaitHandler()
if isTerminalReader(stdin) {
waitHandler = newPromptWaitHandler(stdin, stdout)
}
engine := gitimpact.NewDefaultEngine(runCtx.VelenClient, observer, waitHandler)

result, runErr := engine.Run(ctx, runCtx)
programErr := <-runDone
if runErr != nil {
return nil, runErr
}
if programErr != nil {
return nil, fmt.Errorf("run analysis progress TUI: %w", programErr)
}
return result, nil
}

func emitAnalyzeResult(output string, stdout io.Writer, payload map[string]any, result *gitimpact.AnalysisResult) error {
if output == "json" {
return emitJSON(stdout, payload)
}

body := map[string]any{
"status": "ok",
"result": result,
}
textBody, err := json.MarshalIndent(body, "", " ")
if err != nil {
return err
}
_, _ = fmt.Fprintln(stdout, string(textBody))
return nil
}

func emitSourceCheckResult(output string, stdout io.Writer, result *gitimpact.SourceCheckResult) error {
status := "ok"
if !result.GitHubOK || !result.AnalyticsOK || len(result.Errors) > 0 {
status = "issues"
}

if output == "json" {
return emitJSON(stdout, map[string]any{
"command": "check-sources",
"status": status,
"result": result,
})
}

_, _ = fmt.Fprintf(stdout, "organization: %s\n", fallbackText(result.OrgName, "unknown"))
_, _ = fmt.Fprintf(stdout, "github: %s (query=%t)\n", sourceLabel(result.GitHubSource), result.GitHubOK)
_, _ = fmt.Fprintf(stdout, "analytics: %s (query=%t)\n", sourceLabel(result.AnalyticsSource), result.AnalyticsOK)
if len(result.Errors) == 0 {
_, _ = fmt.Fprintln(stdout, "status: ok")
return nil
}
_, _ = fmt.Fprintln(stdout, "status: issues")
for _, issue := range result.Errors {
_, _ = fmt.Fprintf(stdout, "- %s\n", issue)
}
return nil
}

func emitJSON(stdout io.Writer, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
_, _ = fmt.Fprintf(stdout, "%s\n", body)
return nil
}

func emitCommandError(output string, stdout io.Writer, stderr io.Writer, err error) int {
if strings.EqualFold(strings.TrimSpace(output), "json") {
_ = emitJSON(stdout, map[string]any{
"status": "failed",
"error": map[string]string{
"code": "command_failed",
"message": err.Error(),
},
})
return 1
}
_, _ = fmt.Fprintln(stderr, err.Error())
return 1
}

func defaultOutput(stdout io.Writer) string {
if isTerminalWriter(stdout) {
return "text"
}
return "json"
}

func isTerminalWriter(writer io.Writer) bool {
file, ok := writer.(*os.File)
if !ok {
return false
}
info, err := file.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}

func isTerminalReader(reader io.Reader) bool {
file, ok := reader.(*os.File)
if !ok {
return false
}
info, err := file.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}

func newPromptWaitHandler(stdin io.Reader, stdout io.Writer) gitimpact.WaitHandler {
reader := bufio.NewReader(stdin)
return func(message string) (string, error) {
prompt := strings.TrimSpace(message)
if prompt != "" {
_, _ = fmt.Fprintln(stdout, prompt)
}
_, _ = fmt.Fprint(stdout, "> ")

response, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
return strings.TrimSpace(response), nil
}
}

func newNonInteractiveWaitHandler() gitimpact.WaitHandler {
return func(message string) (string, error) {
return "", fmt.Errorf("analysis requires user input: %s", strings.TrimSpace(message))
}
}

func sourceLabel(source *gitimpact.Source) string {
if source == nil {
return "missing"
}
if strings.TrimSpace(source.Key) != "" {
return source.Key
}
if strings.TrimSpace(source.Name) != "" {
return source.Name
}
return "unknown"
}

func fallbackText(value string, fallback string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return fallback
}
return trimmed
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Step 2 of 10: Implement the Velen CLI wrapper for the git-impact tool

## Goal
Implement a production-safe Velen CLI wrapper in `internal/gitimpact` that can authenticate context, discover sources, run read-only queries, and return structured results/errors for automation.

## Background
- `SPEC.md` section 4 requires all external data access through Velen CLI commands (`auth whoami`, `org current`, `source list/show`, `query`).
- `SPEC.md` section 11 requires read-only query behavior, org verification before analysis, and source availability checks.
- Non-negotiable repository rules require test coverage for all new behavior and machine-readable structured error handling.
- `internal/gitimpact` is not present in this worktree yet, so this step must create the package surface needed for Velen integration.

## Milestones
| ID | Milestone | Status | Exit criteria |
| --- | --- | --- | --- |
| M1 | Define Velen client and result/error types | completed | `internal/gitimpact` contains `VelenClient`, `WhoAmIResult`, `OrgResult`, `Source`, `QueryResult`, `VelenError`, and `Source.SupportsQuery()` with JSON tags aligned to CLI payloads. |
| M2 | Implement safe command execution wrapper | completed | Client runs `velen` via `os/exec` with timeout context, captures stdout+stderr, parses JSON, and maps non-zero exits to structured `VelenError`. |
| M3 | Implement command methods | completed | `WhoAmI`, `CurrentOrg`, `ListSources`, `ShowSource`, and `Query` call the wrapper with correct args and decode expected payloads. |
| M4 | Add unit tests with fake velen binary | completed | Table-driven tests cover success, JSON parse failures, timeout handling, command failure mapping, and SQL argument passing for `Query`. |
| M5 | Validate build and test suite | completed | `go build ./...` and `go test ./...` pass with the new package and tests. |

## Current progress
- Added `internal/gitimpact/types.go` with Velen result/error types and `Source.SupportsQuery()`.
- Added `internal/gitimpact/velen.go` with `VelenClient`, default timeout constructor, safe `os/exec` wrapping, JSON decode helpers, and structured error mapping.
- Added `internal/gitimpact/velen_test.go` using a fake helper-process binary pattern to validate success paths, argument safety, timeout behavior, and failure mapping.
- Validation completed:
- `go test ./...`
- `go build ./...`

## Key decisions
- Use direct `exec.CommandContext` argument lists only; never shell execution.
- Keep a default timeout of 30 seconds via constructor, while allowing caller override.
- Treat non-zero Velen exits as structured errors that include code and combined stderr/stdout diagnostics.
- Favor deterministic tests using a fake helper process rather than requiring a real Velen installation.

## Remaining issues
- Confirm final field-level JSON shapes against actual Velen output if differences appear during integration.
- Decide whether future steps need richer query metadata beyond `columns`, `rows`, and `row_count`.

## Links
- Spec: `SPEC.md` (sections 4 and 11)
- Plan policy: `docs/PLANS.md`
- Merge blockers: `NON_NEGOTIABLE_RULES.md`
- Architecture boundaries: `ARCHITECTURE.md`
Loading