diff --git a/cmd/git-impact/main.go b/cmd/git-impact/main.go new file mode 100644 index 0000000..0e1e9f0 --- /dev/null +++ b/cmd/git-impact/main.go @@ -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 +} diff --git a/docs/exec-plans/active/step-2-of-10-implement-the-velen-cli-wrapper-for-the-git-impact-tool-read-spec-m.md b/docs/exec-plans/active/step-2-of-10-implement-the-velen-cli-wrapper-for-the-git-impact-tool-read-spec-m.md new file mode 100644 index 0000000..3b99163 --- /dev/null +++ b/docs/exec-plans/active/step-2-of-10-implement-the-velen-cli-wrapper-for-the-git-impact-tool-read-spec-m.md @@ -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` diff --git a/docs/exec-plans/active/step-3-of-10-implement-the-wtl-engine-and-phaseddeliverypolicy-for-the-git-impac.md b/docs/exec-plans/active/step-3-of-10-implement-the-wtl-engine-and-phaseddeliverypolicy-for-the-git-impac.md new file mode 100644 index 0000000..622175a --- /dev/null +++ b/docs/exec-plans/active/step-3-of-10-implement-the-wtl-engine-and-phaseddeliverypolicy-for-the-git-impac.md @@ -0,0 +1,45 @@ +# Step 3 of 10 - Implement the WTL engine and PhasedDeliveryPolicy for the git-impact tool + +## Goal +Implement the git-impact WTL execution engine and phased-delivery control flow so analysis progresses across Source Check, Collect, Link, Score, and Report with deterministic directive handling, observer lifecycle hooks, wait/resume behavior, and test coverage. + +## Background +- `SPEC.md` section 3 defines the git-impact architecture as a single WTL run split into ordered phases. +- `SPEC.md` section 3.1 defines phased directives and explicit `wait` behavior where terminal user input resumes the run. +- `internal/wtl` already provides the repository's baseline engine/policy loop pattern and testing style for directives and loop exhaustion. +- This step introduces `internal/gitimpact` as the git-impact-specific phased engine surface for later CLI and TUI integration. + +## Milestones +| ID | Milestone | Status | Exit criteria | +| --- | --- | --- | --- | +| M1 | Define gitimpact engine contract | completed | `internal/gitimpact/engine.go` defines phases, directives, turn result, handler interface, run context, and analysis data structs with compile-safe types. | +| M2 | Implement phased-delivery loop mechanics | completed | `Engine.Run` executes ordered phase progression with retry limits, continue semantics, wait/resume callback flow, and completion/exhaustion paths. | +| M3 | Add observer integration surface | completed | Observer callbacks are wired for turn start, phase advance, wait entered/resolved, run complete, and run exhausted lifecycle points. | +| M4 | Add tests for core control flow | completed | `internal/gitimpact/engine_test.go` validates phase progression, retry logic, and wait handling behavior with deterministic assertions. | +| M5 | Verify repository health | completed | `go build ./...` and `go test ./...` both succeed with the new git-impact engine package included. | + +## Current progress +- Implemented `internal/gitimpact/engine.go` with phase/directive enums, run context/data structs, phased engine loop, retry cap (default 3), wait/resume callback flow, and analysis result completion. +- Added `internal/gitimpact/observer.go` with observer lifecycle hooks and `WaitHandler`. +- Added `internal/gitimpact/engine_test.go` coverage for ordered phase progression, retry exhaustion, and wait handling with observer assertions. +- Verification completed: + - `GOCACHE=/tmp/go-build-cache go build ./...` + - `GOCACHE=/tmp/go-build-cache go test ./...` + +## Key decisions +- Mirror the existing `internal/wtl` pattern for loop mechanics while specializing directives/phases for git-impact. +- Keep wait handling callback-driven (`WaitHandler`) so later TUI or terminal prompt adapters can plug in without changing engine logic. +- Use explicit ordered phase list in engine control flow instead of implicit policy state to keep progression auditable. +- Keep retry handling phase-local with a fixed maximum of 3 retries per phase directive path. +- Emit observer lifecycle callbacks directly from the engine (`OnTurnStarted`, `OnPhaseAdvanced`, `OnWaitEntered`, `OnWaitResolved`, `OnRunCompleted`, `OnRunExhausted`) to support Bubble Tea bridge wiring in later steps. + +## Remaining issues +- Exact runtime behavior for non-terminal `DirectiveContinue` in a phase without external state mutation may need refinement in later steps if handlers do not naturally converge. +- Domain types are introduced minimally in this step and may be expanded when collectors/linkers/scorers are implemented. + +## Links +- Product spec: `SPEC.md` +- WTL package reference: `internal/wtl/engine.go` +- WTL policy reference: `internal/wtl/policy.go` +- WTL tests reference: `internal/wtl/engine_test.go` +- Plans policy: `docs/PLANS.md` diff --git a/docs/exec-plans/active/step-4-of-10-implement-the-wtl-observer-bubble-tea-msg-bridge-for-git-impact-rea.md b/docs/exec-plans/active/step-4-of-10-implement-the-wtl-observer-bubble-tea-msg-bridge-for-git-impact-rea.md new file mode 100644 index 0000000..ff3de6d --- /dev/null +++ b/docs/exec-plans/active/step-4-of-10-implement-the-wtl-observer-bubble-tea-msg-bridge-for-git-impact-rea.md @@ -0,0 +1,46 @@ +# Step 4 of 10: Implement the WTL Observer -> Bubble Tea Msg bridge for git-impact + +## Goal +Implement the Observer-to-TUI bridge for git-impact so WTL lifecycle events are emitted as Bubble Tea messages, consumed by a minimal analysis progress model, and validated by tests. + +## Background +- `SPEC.md` section 3 and 3.1 define a phase-driven WTL run and an explicit Observer -> Bubble Tea `Msg` mapping. +- Required message mapping for this step: `TurnStarted`, `PhaseAdvanced`, `WaitEntered`, `WaitResolved`, `RunCompleted`, `RunExhausted`. +- The current worktree has `internal/wtl` engine code, while `internal/gitimpact` is not yet present and will need to be introduced as part of this step's implementation. +- Repository merge rules require tests for new behavior (`NON_NEGOTIABLE_RULES.md` Rule 1). + +## Milestones +| ID | Milestone | Status | Exit criteria | +| --- | --- | --- | --- | +| M1 | Add Bubble Tea dependencies to module | completed | `go.mod` includes `bubbletea`, `bubbles`, and `lipgloss`; dependency graph resolves via `go get`. | +| M2 | Define Observer->Msg bridge types and adapter | completed | `internal/gitimpact/tui_bridge.go` defines all required `tea.Msg` structs and a `TUIObserver` implementing the git-impact `Observer` interface with `program.Send(...)` dispatch. | +| M3 | Add minimal analysis progress model | completed | `internal/gitimpact/tui_model.go` defines `AnalysisModel`, `PhaseStatus`, `Init`, `Update`, and `View` handling all bridge message types with simple text rendering. | +| M4 | Add bridge message dispatch tests | completed | `internal/gitimpact/tui_bridge_test.go` verifies each Observer callback emits the expected Bubble Tea message payload. | +| M5 | Verify build and test health | not started | `go build ./...` and `go test ./...` pass after Step 4 changes. | + +## Current progress +- Overall status: in progress (`M1` + `M2` + `M3` + `M4` complete, `M5` next). +- Added Bubble Tea ecosystem dependencies via `go get`: `github.com/charmbracelet/bubbletea v1.3.10`, `github.com/charmbracelet/bubbles v1.0.0`, and `github.com/charmbracelet/lipgloss v1.1.0`. +- Added `internal/gitimpact/tui_bridge.go` with six Bubble Tea message structs and `TUIObserver` forwarding each Observer callback with `program.Send(...)`. +- Introduced minimal `internal/gitimpact/engine.go` and `internal/gitimpact/observer.go` scaffolding required for bridge compilation because prior-step files were not present in this worktree. +- Added `internal/gitimpact/tui_model.go` implementing `AnalysisModel` (`Init`, `Update`, `View`) and `PhaseStatus`, with event-driven state transitions for all Observer bridge message types. +- Added `internal/gitimpact/tui_bridge_test.go` with a Bubble Tea program-backed observer dispatch test validating type and payload for all six bridge messages. +- Confirmed tree health after `M4` changes by running `go build ./...` and `go test ./...` successfully. + +## Key decisions +- Keep this step focused on the Observer-to-TUI bridge and minimal model state transitions; defer richer TUI visuals/interactions to Step 9 as scoped. +- Use one Bubble Tea `Msg` type per Observer event exactly as specified to keep event handling explicit and testable. +- Treat the bridge as an adapter layer in `internal/gitimpact` to avoid coupling WTL core internals directly to Bubble Tea update logic. +- Follow loop rule "exactly one milestone per iteration" by advancing milestones incrementally across iterations. +- Define only minimal `Phase`, `AnalysisResult`, and `Observer` types now to unblock bridge wiring; richer engine/result modeling remains for later milestones. +- Keep model state simple and explicit for now: each phase is one of `waiting`, `running`, `done`; richer visuals/components are deferred to Step 9. +- Use an in-process Bubble Tea `Program` test harness (with renderer/input disabled) so tests verify real `program.Send(...)` integration instead of mocked dispatch. + +## Remaining issues +- Final step-level verification milestone (`M5`) remains pending. + +## Links +- Product spec: `SPEC.md` (sections 3, 3.1) +- Plan policy: `docs/PLANS.md` +- Merge gates: `NON_NEGOTIABLE_RULES.md` +- System boundaries: `ARCHITECTURE.md` diff --git a/docs/exec-plans/active/step-6-of-10-implement-the-source-check-collect-phase-handlers-for-git-impact-re.md b/docs/exec-plans/active/step-6-of-10-implement-the-source-check-collect-phase-handlers-for-git-impact-re.md new file mode 100644 index 0000000..d1dece9 --- /dev/null +++ b/docs/exec-plans/active/step-6-of-10-implement-the-source-check-collect-phase-handlers-for-git-impact-re.md @@ -0,0 +1,55 @@ +# Step 6 of 10 - Implement the Source Check + Collect phase handlers for git-impact + +## Goal +Implement the Source Check and Collect phase handlers in `internal/gitimpact/` so the engine can validate required Velen sources, collect GitHub PR/tag/release data, and advance phase state with test coverage. + +## Background +- `SPEC.md` section 3.2 defines Source Check (pre-Turn 1) and Collector (Turn 1) responsibilities. +- `SPEC.md` sections 4.1-4.3 define Velen access flow, required source types (GitHub + Analytics), and wait-on-missing-source behavior. +- Existing `internal/gitimpact/` runtime already includes base types, engine loop, observer, Velen client, and `CheckSources`; this step must add phase handlers and registration without redefining existing types. + +## Milestones +- [x] Milestone 1 (completed): Confirm handler contracts and run-context expectations from existing `engine.go`, `check_sources.go`, and tests. +- [x] Milestone 2 (completed): Add `phase_source_check.go` with `SourceCheckHandler` implementing `PhaseHandler`, including wait handling for missing/non-query-capable sources and wait-response resolution (`y` advance, `n` error). +- [x] Milestone 3 (completed): Add `phase_collect.go` with `CollectHandler` implementing `PhaseHandler`, using Velen queries for PRs, tags, and releases; parse into `CollectedData`; persist to `runCtx`; return `DirectiveAdvancePhase`. +- [x] Milestone 4 (completed): Add unit tests in `phase_source_check_test.go` and `phase_collect_test.go` with mockable Velen query/source behavior via interface or injectable functions. +- [ ] Milestone 5 (not started): Add `DefaultHandlers(client *VelenClient) map[Phase]PhaseHandler` registration for SourceCheck + Collect plus temporary Link/Score/Report advance stubs; run `go build ./...` and `go test ./...`. + +## Current progress +- Plan created and checked in. +- Milestone 1 completed by confirming the `PhaseHandler` contract (`Handle(context.Context, *RunContext)`) and engine wait-cycle behavior (`runCtx.AnalysisCtx.LastWaitResponse` populated after `DirectiveWait`). +- Confirmed `CheckSources(ctx, client, cfg)` already encapsulates auth/org/source discovery and returns the exact readiness bits needed by `SourceCheckHandler` (`GitHubOK`, `AnalyticsOK`, and `Errors`). +- Confirmed `RunContext.CollectedData` shape (`PRs []PR`, `Tags []string`, `Releases []Release`) matches Step 6 collector output targets without adding or redefining core types. +- Milestone 2 completed by adding `internal/gitimpact/phase_source_check.go` with `SourceCheckHandler` that: + - runs `CheckSources` through injectable function field (defaults to `CheckSources`); + - advances immediately when GitHub + Analytics are both QUERY-capable; + - issues `DirectiveWait` with deterministic message when not ready; + - resolves wait input from `LastWaitResponse` (`y` => advance, `n` => hard error, other => validation error). +- Milestone 3 completed by adding `internal/gitimpact/phase_collect.go` with `CollectHandler` that: + - issues the required PR/tag/release SQL queries via injectable query function (defaults to `VelenClient.Query`); + - derives `{since}` from `runCtx.AnalysisCtx.Since` (fallback `1970-01-01`); + - parses PR/tag/release rows into existing `PR`, `Release`, and `CollectedData` types; + - stores parsed output in `runCtx.CollectedData` and returns `DirectiveAdvancePhase`. +- Milestone 4 completed with handler-focused unit tests: + - `phase_source_check_test.go`: verifies ready->advance, not-ready->wait, wait-response handling (`y` advance, `n` error), and checker-error propagation. + - `phase_collect_test.go`: verifies required SQL execution/order, parsed data population in `runCtx.CollectedData`, missing-source-key failure, query-error wrapping, and invalid-row parse failure. + - `go test ./internal/gitimpact` passes with the new tests. + +## Key decisions +- Use additive files/functions only; do not alter or redefine existing foundational types. +- Prefer dependency injection (interface/function fields) in handlers to keep unit tests isolated from real `velen` command execution. +- Keep Link/Score/Report handlers as explicit stubs returning `DirectiveAdvancePhase` until their dedicated steps. +- Treat wait responses as phase-local state read from `runCtx.AnalysisCtx.LastWaitResponse` so source-check confirmation can resolve within the existing engine retry loop. +- Use config-driven GitHub source key (`cfg.Velen.Sources.GitHub`) for collection queries, with SQL assembled deterministically for test assertions. +- Use strict wait-response semantics for source check (`y`/`n` only) to avoid silent continuation on ambiguous input. +- Keep collector parsing defensive for mixed JSON row scalar types (`string`, `float64`, `[]interface{}`, `json.Number`) to avoid coupling to one Velen JSON decoder shape. + +## Remaining issues +- Register default phase handlers map (`DefaultHandlers`) and run full-repo build/test verification. + +## Links +- `SPEC.md` (sections 3.2, 4.1, 4.2, 4.3) +- `internal/gitimpact/engine.go` +- `internal/gitimpact/check_sources.go` +- `internal/gitimpact/velen.go` +- `docs/PLANS.md` diff --git a/docs/exec-plans/active/step-7-of-10-retry-implement-the-linker-phase-handler-turn-2-for-git-impact-read.md b/docs/exec-plans/active/step-7-of-10-retry-implement-the-linker-phase-handler-turn-2-for-git-impact-read.md new file mode 100644 index 0000000..ba28c11 --- /dev/null +++ b/docs/exec-plans/active/step-7-of-10-retry-implement-the-linker-phase-handler-turn-2-for-git-impact-read.md @@ -0,0 +1,41 @@ +# Step 7 of 10 (Retry): Implement the Linker phase handler (Turn 2) for git-impact + +## Goal +Implement Turn 2 Linker behavior in `internal/gitimpact` to infer deployment timestamps from collected GitHub data, propose feature groups, detect ambiguity windows, and return correct phased-delivery directives. + +## Background +- `SPEC.md` section 3.2 defines Linker responsibilities: infer deployment mapping from releases/tags/merge time and propose feature groupings. +- `SPEC.md` section 5.2 defines deployment inference priority: release publish time, then version tag markers, then PR merge time fallback. +- `internal/gitimpact` already has engine directives, `CollectedData`, `LinkedData`, `Deployment`, `FeatureGroup`, and `AmbiguousDeployment` types. +- This retry requires concrete Go implementation and tests, followed by repository-wide build and test verification. + +## Milestones +| ID | Milestone | Status | Exit criteria | +| --- | --- | --- | --- | +| M1 | Add Linker phase handler scaffold | not started | `internal/gitimpact/phase_link.go` exists with `LinkHandler` and `Handle(context.Context, *RunContext)` wired to `CollectedData`/`LinkedData`. | +| M2 | Implement deployment inference helpers | not started | `inferDeployment` + `isVersionTag` support priority ordering and 48h window checks with deterministic selection. | +| M3 | Implement feature grouping proposals | not started | `proposeFeatureGroups` groups PRs by `feature/` label prefix and `feature/` branch prefix. | +| M4 | Implement ambiguity detection and wait behavior | not started | `detectAmbiguousDeployments` identifies multi-release/multi-PR 24h windows and `Handle` returns `DirectiveWait` with descriptive message when ambiguous. | +| M5 | Add and pass Linker tests | not started | `internal/gitimpact/phase_link_test.go` has >=5 inference-focused tests and `go test ./...` passes. | +| M6 | Verify full build and finalize commit | not started | `go build ./...` passes and Linker changes are staged and committed cleanly. | + +## Current progress +- Completed repo orientation (`AGENTS.md`, `NON_NEGOTIABLE_RULES.md`, `docs/PLANS.md`). +- Read `SPEC.md` sections 3.2 and 5.2 and reviewed existing `internal/gitimpact` phase/type implementations. +- Linker implementation and tests not started yet. + +## Key decisions +- Preserve existing phased-delivery contract (`DirectiveAdvancePhase`/`DirectiveWait`) and keep Linker pure over in-memory `CollectedData`. +- Use deterministic helper behavior (sorted/nearest matching by timestamp) to avoid nondeterministic test failures. +- Keep ambiguity handling conservative: pause only when overlapping release windows and merged PR windows indicate unclear mapping. + +## Remaining issues +- `CollectedData.Tags` is currently `[]string`; tag timestamp parsing format must be defined in Linker logic to support the `Tag.CreatedAt` inference window. +- Decide exact wait-message detail level to balance clarity and concise terminal prompts. + +## Links +- `SPEC.md` (sections 3.2 and 5.2) +- `internal/gitimpact/engine.go` +- `internal/gitimpact/phase_collect.go` +- `docs/PLANS.md` +- `NON_NEGOTIABLE_RULES.md` diff --git a/docs/exec-plans/active/step-8-of-10-implement-the-impact-scorer-phase-handler-turn-3-for-git-impact-rea.md b/docs/exec-plans/active/step-8-of-10-implement-the-impact-scorer-phase-handler-turn-3-for-git-impact-rea.md new file mode 100644 index 0000000..5dd0db9 --- /dev/null +++ b/docs/exec-plans/active/step-8-of-10-implement-the-impact-scorer-phase-handler-turn-3-for-git-impact-rea.md @@ -0,0 +1,43 @@ +# Step 8 of 10: Implement the Impact Scorer phase handler (Turn 3) for git-impact + +## Goal +Implement Turn 3 scoring behavior in `internal/gitimpact` so deployments are evaluated against analytics metrics, PR impacts are scored with confidence reasoning, contributor rollups are computed, and phase control advances correctly. + +## Background +- `SPEC.md` section 3.2 defines Impact Scorer responsibilities: schema exploration, metric querying, confidence judgment, and natural-language reasoning. +- `SPEC.md` section 5.1 defines PR-level score semantics as agent judgment over meaningful metrics with confidence adjustment. +- `SPEC.md` sections 5.3 and 5.4 define default before/after windows and handling of confounding overlapping deployments. +- `internal/gitimpact` already contains `RunContext`, `LinkedData`, `ScoredData`, and phase directive types required for Turn 3 integration. + +## Milestones +| ID | Milestone | Status | Exit criteria | +| --- | --- | --- | --- | +| M1 | Add Impact Scorer handler scaffold | not started | `internal/gitimpact/phase_score.go` defines `ScoreHandler` with `Handle(context.Context, *RunContext)` and required helper functions. | +| M2 | Implement schema discovery + metric query flow | not started | Handler queries analytics `information_schema.columns`, selects first usable metric, and runs before/after metric queries per deployment. | +| M3 | Implement score/confidence/reasoning generation | not started | Each deployment yields `PRImpact` with score (0-10), confidence (`high`/`medium`/`low`), and reasoning including confounding context. | +| M4 | Implement contributor rollup | not started | PR impacts are grouped by author to compute average score and top PR in `ContributorStats`. | +| M5 | Add scorer tests | not started | `internal/gitimpact/phase_score_test.go` covers score normalization, confidence thresholds, contributor rollup, and empty-schema graceful behavior. | +| M6 | Verify and finalize | not started | `go build ./...` and `go test ./...` pass; scorer changes are staged and committed. | + +## Current progress +- Repository guardrails and architecture docs reviewed. +- Required spec sections identified and read (`3.2`, `5.1`, `5.3`, `5.4`). +- Impact Scorer implementation and tests are not started. + +## Key decisions +- Reuse existing `VelenClient.Query` integration pattern from prior phase handlers for deterministic testability. +- Keep default windows aligned with config defaults and spec defaults (7 days each when unset). +- Treat deployment overlap density as the primary confidence baseline and surface overlap details in reasoning text. + +## Remaining issues +- Analytics schemas may vary widely; first-metric discovery and date filtering logic must be robust to sparse metadata. +- Some deployments may not map to PR authors in collected data; rollup behavior must handle missing author values safely. + +## Links +- `SPEC.md` (sections 3.2, 5.1, 5.3, 5.4) +- `internal/gitimpact/engine.go` +- `internal/gitimpact/types.go` +- `internal/gitimpact/phase_collect.go` +- `internal/gitimpact/phase_link.go` +- `docs/PLANS.md` +- `NON_NEGOTIABLE_RULES.md` diff --git a/docs/exec-plans/completed/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 new file mode 100644 index 0000000..94dece8 --- /dev/null +++ b/docs/exec-plans/completed/step-1-of-10-implement-the-git-impact-cli-project-scaffold-read-spec-md-and-arch.md @@ -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` diff --git a/docs/exec-plans/completed/step-5-of-10-implement-cli-args-structured-context-agent-initial-prompt-and-the.md b/docs/exec-plans/completed/step-5-of-10-implement-cli-args-structured-context-agent-initial-prompt-and-the.md new file mode 100644 index 0000000..249cecd --- /dev/null +++ b/docs/exec-plans/completed/step-5-of-10-implement-cli-args-structured-context-agent-initial-prompt-and-the.md @@ -0,0 +1,56 @@ +# Step 5 of 10: Implement CLI args, structured context, initial prompt, and check-sources + +## Goal +Implement Step 5 for `git-impact` by wiring CLI arguments into a structured analysis context, building the agent initial prompt from that context and config, and adding a `check-sources` flow that validates required Velen source connectivity. + +## Background +`SPEC.md` section 7 requires `git-impact analyze` and `git-impact check-sources`, with analyze arguments passed into a structured context object and included in the agent's initial prompt. `SPEC.md` section 4.3 requires source discovery on every analyze run: list sources, identify GitHub and Analytics sources by provider type, confirm query capability, and surface gaps. + +Existing types and engine already exist in `internal/gitimpact/` and must be reused (no type redefinition). This step adds implementation glue in: +- `internal/gitimpact/context.go` +- `internal/gitimpact/check_sources.go` +- `cmd/git-impact/main.go` +- tests for source detection and fallback behavior + +## Milestones +1. **Context construction and prompt assembly** — `completed` + - Implement `NewAnalysisContext(...)` to load config via Viper and populate `AnalysisContext`. + - Implement `BuildInitialPrompt(...)` to inject since/pr/feature inputs and configured Velen source keys. +2. **CLI command surface** — `completed` + - Build root Cobra command (`git-impact`) with persistent `--config` and `--output` flags. + - Add `analyze` subcommand with `--since`, `--pr`, `--feature`; print placeholder plus parsed context JSON. +3. **Source check implementation** — `completed` + - Add `CheckSources(...)` and `SourceCheckResult` in `internal/gitimpact/check_sources.go`. + - Execute `WhoAmI`, `CurrentOrg`, and `ListSources`; detect GitHub and Analytics providers case-insensitively. + - Set support flags via `SupportsQuery()` and apply config-key fallback matching. +4. **Command wiring and output modes** — `completed` + - Wire `check-sources` subcommand to call `CheckSources`. + - Emit text summary for human output and JSON payload for machine-readable output. +5. **Validation and tests** — `completed` + - Add table-driven tests in `check_sources_test.go`. + - Run `go build ./...` and `go test ./...` and fix any breakages. + +## Current progress +- Implemented `NewAnalysisContext(since, prNum, feature, configPath)` and `BuildInitialPrompt` in `internal/gitimpact/context.go`. +- Added `internal/gitimpact/check_sources.go` with provider-type detection, query-capability checks, and config-key fallback. +- Reworked `cmd/git-impact/main.go` to a full Cobra command surface with root persistent flags and wired `analyze` / `check-sources`. +- Added `internal/gitimpact/check_sources_test.go` table-driven coverage plus helper-process command failure coverage. +- Updated context tests for the new `NewAnalysisContext` signature and added prompt-content assertions. +- Validation complete: `GOCACHE=/tmp/go-build-cache go build ./...` and `GOCACHE=/tmp/go-build-cache go test ./...` both pass. + +## Key decisions +- Reuse existing `Config`, `AnalysisContext`, `Source`, Velen client, and engine types as-is. +- Keep output behavior aligned with non-negotiable machine-readable automation rules by defaulting non-TTY output to JSON. +- Keep `analyze` behavior minimal in this step (context parsing + placeholder output), while also emitting the generated initial prompt. +- Keep `check-sources` output non-fatal for missing source capability by returning structured status (`ok`/`issues`) and per-source errors. + +## Remaining issues +- Confirm downstream automation envelope expectations (`status` semantics and field naming) before integrating into the next analysis phase. +- Verify provider-type fallback behavior against real-world `velen source list` payload variations beyond test fixtures. + +## Links +- `SPEC.md` (sections 4.3 and 7) +- `NON_NEGOTIABLE_RULES.md` +- `docs/PLANS.md` +- `internal/gitimpact/` +- `cmd/git-impact/main.go` diff --git a/docs/exec-plans/completed/step-9-of-10-implement-the-tui-analysis-progress-view-for-git-impact-read-spec-m.md b/docs/exec-plans/completed/step-9-of-10-implement-the-tui-analysis-progress-view-for-git-impact-read-spec-m.md new file mode 100644 index 0000000..593866d --- /dev/null +++ b/docs/exec-plans/completed/step-9-of-10-implement-the-tui-analysis-progress-view-for-git-impact-read-spec-m.md @@ -0,0 +1,44 @@ +# Step 9 of 10: Implement the TUI Analysis Progress View for git-impact + +## Goal +Implement the analysis-progress TUI for `git-impact analyze` (per `SPEC.md` section 7.1), wire it into the runtime engine loop, add default engine construction helpers, and validate with tests and full package build/test. + +## Background +- `SPEC.md` section 7.1 defines a progress UI with title, separator, progress bar, phase-status list, and wait prompt area. +- `internal/gitimpact/tui_model.go` currently has a minimal model and text view that does not match the required structure. +- `internal/gitimpact/tui_bridge.go` already defines Bubble Tea message types and `TUIObserver`. +- `cmd/git-impact/main.go` `analyze` command is currently a placeholder output and does not run the phased engine. +- `internal/gitimpact/engine.go` supports observer notifications and phased execution but lacks a convenience constructor that registers all default handlers. + +## Milestones +- [x] M1 (`completed`): Expanded `AnalysisModel` in `internal/gitimpact/tui_model.go` with required fields (`phases`, `currentPhase`, `iteration`, `totalPhases`, `isWaiting`, `waitMessage`, `spinner`, `done`, `result`, `err`) and phase-aware status transitions for all engine bridge messages. +- [x] M2 (`completed`): Implemented `View()` with a SPEC-aligned progress layout (title, separator, progress bar/turn + phase label, phase rows with done/running/waiting icons, and conditional wait message block). +- [x] M3 (`completed`): Added `internal/gitimpact/tui_model_test.go` covering `Update()` behavior for `TurnStartedMsg`, `PhaseAdvancedMsg`, `WaitEnteredMsg`, `WaitResolvedMsg`, `RunCompletedMsg`, `RunExhaustedMsg`, and spinner tick handling. +- [x] M4 (`completed`): Added `NewDefaultEngine(client *VelenClient, observer Observer, waitHandler WaitHandler) *Engine` plus `DefaultHandlers()` in `internal/gitimpact/` with all phase handlers registered. +- [x] M5 (`completed`): Updated `cmd/git-impact/main.go` `analyze` command to instantiate and run Bubble Tea + `TUIObserver`, construct `RunContext`, execute engine on main goroutine, and wait for TUI shutdown. + +## Current progress +- Repository guidance reviewed: `AGENTS.md`, `NON_NEGOTIABLE_RULES.md`, `docs/PLANS.md`. +- Implemented full progress TUI model and view in `internal/gitimpact/tui_model.go`. +- Added `internal/gitimpact/tui_model_test.go` and `internal/gitimpact/engine_defaults_test.go`. +- Added default engine wiring (`internal/gitimpact/engine_defaults.go`) with default phase handlers and report completion handler. +- Wired `cmd/git-impact/main.go` analyze flow to run engine + Bubble Tea concurrently in interactive text mode and non-interactive engine execution for JSON/piped modes. +- Verification complete: `go build ./...` and `go test ./...` both passed (using `GOCACHE=/tmp/go-build-cache` in sandbox). + +## Key decisions +- Treat this step as a direct implementation step, not a mock/prototype: all required Go code and tests will be committed. +- Keep phase progression source-of-truth in engine observer messages (TUI should only react to incoming messages, not infer hidden state transitions). +- Keep default engine registration centralized via `NewDefaultEngine` to avoid duplicating handler wiring in CLI entrypoints. +- Preserve machine-readable CLI behavior (`--output json`) by running the default engine without TUI and emitting structured payloads. +- Run interactive progress TUI only for terminal text output to avoid renderer noise in automation/piped output. + +## Remaining issues +- None for this milestone. + +## Links +- `SPEC.md` (section 7.1) +- `internal/gitimpact/tui_model.go` +- `internal/gitimpact/tui_bridge.go` +- `internal/gitimpact/engine.go` +- `cmd/git-impact/main.go` +- `docs/PLANS.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..621af46 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,42 @@ module impactable go 1.26 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + 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/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.7 // 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/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f354834 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +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/check_sources.go b/internal/gitimpact/check_sources.go new file mode 100644 index 0000000..76ab681 --- /dev/null +++ b/internal/gitimpact/check_sources.go @@ -0,0 +1,124 @@ +package gitimpact + +import ( + "context" + "fmt" + "strings" +) + +// SourceCheckResult summarizes source connectivity status for required providers. +type SourceCheckResult struct { + GitHubSource *Source `json:"github_source,omitempty"` + AnalyticsSource *Source `json:"analytics_source,omitempty"` + GitHubOK bool `json:"github_ok"` + AnalyticsOK bool `json:"analytics_ok"` + OrgName string `json:"org_name"` + Errors []string `json:"errors,omitempty"` +} + +// CheckSources validates auth/org state and discovers required sources. +func CheckSources(ctx context.Context, client *VelenClient, cfg *Config) (*SourceCheckResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return nil, err + } + if client == nil { + return nil, fmt.Errorf("velen client is nil") + } + + whoAmI, err := client.WhoAmI() + if err != nil { + return nil, fmt.Errorf("velen auth whoami: %w", err) + } + if err := ctx.Err(); err != nil { + return nil, err + } + + org, err := client.CurrentOrg() + if err != nil { + return nil, fmt.Errorf("velen org current: %w", err) + } + if err := ctx.Err(); err != nil { + return nil, err + } + + sources, err := client.ListSources() + if err != nil { + return nil, fmt.Errorf("velen source list: %w", err) + } + + result := &SourceCheckResult{ + OrgName: strings.TrimSpace(org.Name), + } + if result.OrgName == "" { + result.OrgName = strings.TrimSpace(org.Slug) + } + if result.OrgName == "" && whoAmI != nil { + result.OrgName = strings.TrimSpace(whoAmI.Org) + } + + for idx := range sources { + source := &sources[idx] + providerType := strings.ToLower(strings.TrimSpace(source.ProviderType)) + if result.GitHubSource == nil && strings.Contains(providerType, "github") { + result.GitHubSource = source + continue + } + if result.AnalyticsSource == nil && containsAny(providerType, "analytics", "amplitude", "mixpanel", "segment") { + result.AnalyticsSource = source + } + } + + // Fallback to configured source keys when provider type metadata is absent or non-standard. + if result.GitHubSource == nil && cfg != nil { + result.GitHubSource = sourceByKey(sources, cfg.Velen.Sources.GitHub) + } + if result.AnalyticsSource == nil && cfg != nil { + result.AnalyticsSource = sourceByKey(sources, cfg.Velen.Sources.Analytics) + } + + if result.GitHubSource == nil { + result.Errors = append(result.Errors, "github source not found") + } else { + result.GitHubOK = result.GitHubSource.SupportsQuery() + if !result.GitHubOK { + result.Errors = append(result.Errors, fmt.Sprintf("github source %q does not support QUERY", result.GitHubSource.Key)) + } + } + + if result.AnalyticsSource == nil { + result.Errors = append(result.Errors, "analytics source not found") + } else { + result.AnalyticsOK = result.AnalyticsSource.SupportsQuery() + if !result.AnalyticsOK { + result.Errors = append(result.Errors, fmt.Sprintf("analytics source %q does not support QUERY", result.AnalyticsSource.Key)) + } + } + + return result, nil +} + +func containsAny(value string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} + +func sourceByKey(sources []Source, key string) *Source { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return nil + } + for idx := range sources { + source := &sources[idx] + if strings.EqualFold(strings.TrimSpace(source.Key), trimmed) { + return source + } + } + return nil +} diff --git a/internal/gitimpact/check_sources_test.go b/internal/gitimpact/check_sources_test.go new file mode 100644 index 0000000..eb1f636 --- /dev/null +++ b/internal/gitimpact/check_sources_test.go @@ -0,0 +1,255 @@ +package gitimpact + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestCheckSources_TableDriven(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + sources []Source + wantGitHubKey string + wantAnalyticsKey string + wantGitHubOK bool + wantAnalyticsOK bool + wantOrgName string + wantErrorContains []string + wantNoErrorSubstrings []string + }{ + { + name: "provider type matching succeeds", + sources: []Source{ + {Key: "github-main", ProviderType: "GitHub", Capabilities: []string{"QUERY", "SYNC"}}, + {Key: "amplitude-prod", ProviderType: "Amplitude", Capabilities: []string{"QUERY"}}, + }, + wantGitHubKey: "github-main", + wantAnalyticsKey: "amplitude-prod", + wantGitHubOK: true, + wantAnalyticsOK: true, + wantOrgName: "Impactable", + wantNoErrorSubstrings: []string{"not found", "does not support QUERY"}, + }, + { + name: "analytics detected but query unsupported", + sources: []Source{ + {Key: "gh-enterprise", ProviderType: "github-enterprise", Capabilities: []string{"QUERY"}}, + {Key: "mixpanel-main", ProviderType: "MIXPANEL", Capabilities: []string{"SYNC"}}, + }, + wantGitHubKey: "gh-enterprise", + wantAnalyticsKey: "mixpanel-main", + wantGitHubOK: true, + wantAnalyticsOK: false, + wantOrgName: "Impactable", + wantErrorContains: []string{"analytics source \"mixpanel-main\" does not support QUERY"}, + }, + { + name: "fallback to configured keys when provider types do not match", + cfg: Config{ + Velen: VelenConfig{ + Sources: VelenSources{ + GitHub: "gh-fallback", + Analytics: "analytics-fallback", + }, + }, + }, + sources: []Source{ + {Key: "gh-fallback", ProviderType: "custom", Capabilities: []string{"QUERY"}}, + {Key: "analytics-fallback", ProviderType: "internal", Capabilities: []string{"QUERY"}}, + }, + wantGitHubKey: "gh-fallback", + wantAnalyticsKey: "analytics-fallback", + wantGitHubOK: true, + wantAnalyticsOK: true, + wantOrgName: "Impactable", + wantNoErrorSubstrings: []string{"not found", "does not support QUERY"}, + }, + { + name: "missing required sources surfaces errors", + cfg: Config{ + Velen: VelenConfig{ + Sources: VelenSources{ + GitHub: "missing-github", + Analytics: "missing-analytics", + }, + }, + }, + sources: []Source{ + {Key: "warehouse-main", ProviderType: "warehouse", Capabilities: []string{"QUERY"}}, + }, + wantGitHubOK: false, + wantAnalyticsOK: false, + wantOrgName: "Impactable", + wantErrorContains: []string{"github source not found", "analytics source not found"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := newCheckSourcesHelperClient(t, checkSourcesHelperPayload{ + WhoAmI: WhoAmIResult{ + Email: "agent@example.com", + Org: "impactable", + }, + Org: OrgResult{ + Slug: "impactable", + Name: "Impactable", + }, + Sources: tt.sources, + }) + + result, err := CheckSources(context.Background(), client, &tt.cfg) + if err != nil { + t.Fatalf("CheckSources returned error: %v", err) + } + + if got := sourceKey(result.GitHubSource); got != tt.wantGitHubKey { + t.Fatalf("unexpected github source key: got %q, want %q", got, tt.wantGitHubKey) + } + if got := sourceKey(result.AnalyticsSource); got != tt.wantAnalyticsKey { + t.Fatalf("unexpected analytics source key: got %q, want %q", got, tt.wantAnalyticsKey) + } + if result.GitHubOK != tt.wantGitHubOK { + t.Fatalf("unexpected github ok: got %t, want %t", result.GitHubOK, tt.wantGitHubOK) + } + if result.AnalyticsOK != tt.wantAnalyticsOK { + t.Fatalf("unexpected analytics ok: got %t, want %t", result.AnalyticsOK, tt.wantAnalyticsOK) + } + if result.OrgName != tt.wantOrgName { + t.Fatalf("unexpected org name: got %q, want %q", result.OrgName, tt.wantOrgName) + } + + for _, expected := range tt.wantErrorContains { + if !containsString(result.Errors, expected) { + t.Fatalf("expected errors to contain %q, got %#v", expected, result.Errors) + } + } + for _, unexpected := range tt.wantNoErrorSubstrings { + for _, msg := range result.Errors { + if strings.Contains(msg, unexpected) { + t.Fatalf("did not expect errors to contain substring %q, got %#v", unexpected, result.Errors) + } + } + } + }) + } +} + +func TestCheckSources_ReturnsCommandErrors(t *testing.T) { + t.Parallel() + + client := newCheckSourcesHelperClient(t, checkSourcesHelperPayload{ + FailCommand: "velen org current", + FailMessage: "boom", + }) + + _, err := CheckSources(context.Background(), client, &Config{}) + if err == nil { + t.Fatalf("expected command failure error") + } + if !strings.Contains(err.Error(), "velen org current") { + t.Fatalf("expected wrapped org current error, got %q", err.Error()) + } +} + +type checkSourcesHelperPayload struct { + WhoAmI WhoAmIResult + Org OrgResult + Sources []Source + FailCommand string + FailMessage string +} + +func newCheckSourcesHelperClient(t *testing.T, payload checkSourcesHelperPayload) *VelenClient { + t.Helper() + + payloadBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal helper payload: %v", err) + } + + client := NewVelenClient(time.Second) + client.cmdFactory = func(ctx context.Context, name string, args ...string) *exec.Cmd { + helperArgs := []string{"-test.run=TestCheckSourcesHelperProcess", "--", name} + helperArgs = append(helperArgs, args...) + cmd := exec.CommandContext(ctx, os.Args[0], helperArgs...) + cmd.Env = append(os.Environ(), + "GO_WANT_CHECK_SOURCES_HELPER_PROCESS=1", + "CHECK_SOURCES_HELPER_PAYLOAD="+string(payloadBytes), + ) + return cmd + } + return client +} + +func TestCheckSourcesHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_CHECK_SOURCES_HELPER_PROCESS") != "1" { + return + } + + separator := -1 + for idx, arg := range os.Args { + if arg == "--" { + separator = idx + break + } + } + if separator == -1 || separator+1 >= len(os.Args) { + _, _ = os.Stderr.WriteString("missing helper args") + os.Exit(2) + } + + var payload checkSourcesHelperPayload + if err := json.Unmarshal([]byte(os.Getenv("CHECK_SOURCES_HELPER_PAYLOAD")), &payload); err != nil { + _, _ = os.Stderr.WriteString("invalid helper payload") + os.Exit(2) + } + + cmdText := strings.Join(os.Args[separator+1:], " ") + if payload.FailCommand != "" && payload.FailCommand == cmdText { + _, _ = os.Stderr.WriteString(payload.FailMessage) + os.Exit(7) + } + + switch cmdText { + case "velen auth whoami": + _ = json.NewEncoder(os.Stdout).Encode(payload.WhoAmI) + os.Exit(0) + case "velen org current": + _ = json.NewEncoder(os.Stdout).Encode(payload.Org) + os.Exit(0) + case "velen source list": + _ = json.NewEncoder(os.Stdout).Encode(payload.Sources) + os.Exit(0) + default: + _, _ = os.Stderr.WriteString("unknown command") + os.Exit(2) + } +} + +func sourceKey(source *Source) string { + if source == nil { + return "" + } + return source.Key +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/gitimpact/cli.go b/internal/gitimpact/cli.go new file mode 100644 index 0000000..9302b9e --- /dev/null +++ b/internal/gitimpact/cli.go @@ -0,0 +1,81 @@ +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 { + _ = cwd + var configPath string + var analyzeSince string + var analyzePR int + var analyzeFeature 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(analyzeSince, analyzePR, analyzeFeature, configPath) + if err != nil { + return err + } + _ = ctx + return ErrAnalyzeNotImplemented + }, + } + analyzeCmd.Flags().StringVar(&analyzeSince, "since", "", "Analyze changes since YYYY-MM-DD") + analyzeCmd.Flags().IntVar(&analyzePR, "pr", 0, "Analyze a specific PR number") + analyzeCmd.Flags().StringVar(&analyzeFeature, "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("", 0, "", configPath) + if err != nil { + return err + } + _ = ctx + return ErrCheckSourcesNotImplemented + }, + } + + root.PersistentFlags().StringVar(&configPath, "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..1b36b26 --- /dev/null +++ b/internal/gitimpact/context.go @@ -0,0 +1,136 @@ +package gitimpact + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const sinceDateLayout = "2006-01-02" + +// NewAnalysisContext converts parsed CLI arguments into a runtime context. +func NewAnalysisContext(since string, prNum int, feature string, configPath string) (*AnalysisContext, error) { + workingDirectory, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("resolve working directory: %w", err) + } + + resolvedConfigPath, err := resolveConfigPath(workingDirectory, configPath) + if err != nil { + return nil, err + } + + // Load once here to ensure CLI context creation fails fast on config issues. + if _, err := LoadConfig(resolvedConfigPath); err != nil { + return nil, err + } + + parsedSince, err := parseSince(since) + if err != nil { + return nil, err + } + + trimmedFeature := strings.TrimSpace(feature) + if prNum < 0 { + return nil, fmt.Errorf("--pr must be zero or a positive integer") + } + if prNum > 0 && trimmedFeature != "" { + return nil, fmt.Errorf("--pr and --feature cannot be set together") + } + + return &AnalysisContext{ + WorkingDirectory: filepath.Clean(workingDirectory), + ConfigPath: resolvedConfigPath, + Since: parsedSince, + PRNumber: prNum, + Feature: trimmedFeature, + }, nil +} + +// BuildInitialPrompt constructs the first system prompt for the WTL analysis agent. +func BuildInitialPrompt(ctx *AnalysisContext, cfg *Config) string { + if ctx == nil { + ctx = &AnalysisContext{} + } + if cfg == nil { + cfg = &Config{} + } + + since := "not set" + if ctx.Since != nil { + since = ctx.Since.Format(sinceDateLayout) + } + + prScope := "all PRs" + if ctx.PRNumber > 0 { + prScope = fmt.Sprintf("PR #%d only", ctx.PRNumber) + } + + featureScope := "not set" + if strings.TrimSpace(ctx.Feature) != "" { + featureScope = strings.TrimSpace(ctx.Feature) + } + + githubSource := strings.TrimSpace(cfg.Velen.Sources.GitHub) + if githubSource == "" { + githubSource = "not configured" + } + analyticsSource := strings.TrimSpace(cfg.Velen.Sources.Analytics) + if analyticsSource == "" { + analyticsSource = "not configured" + } + + org := strings.TrimSpace(cfg.Velen.Org) + if org == "" { + org = "not configured" + } + + return strings.TrimSpace(fmt.Sprintf(` +You are the WTL agent for git-impact. Analyze repository changes and estimate product impact. + +Task context: +- working_directory: %s +- config_path: %s +- scope_since: %s +- scope_pr: %s +- scope_feature: %s + +Configured Velen context: +- org: %s +- github_source_key: %s +- analytics_source_key: %s + +Required startup flow: +1) Run source checks first (whoami, current org, source list). +2) Confirm GitHub and Analytics sources are available and QUERY-capable. +3) If required inputs are missing or ambiguous, pause and ask the user before continuing. +`, strings.TrimSpace(ctx.WorkingDirectory), strings.TrimSpace(ctx.ConfigPath), since, prScope, featureScope, org, githubSource, analyticsSource)) +} + +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..7cd6a83 --- /dev/null +++ b/internal/gitimpact/context_test.go @@ -0,0 +1,102 @@ +package gitimpact + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewAnalysisContext_WithDefaults(t *testing.T) { + t.Parallel() + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + configPath := writeTestConfig(t, t.TempDir()) + ctx, err := NewAnalysisContext("2026-01-01", 0, "", configPath) + if err != nil { + t.Fatalf("NewAnalysisContext returned error: %v", err) + } + if ctx.WorkingDirectory != filepath.Clean(cwd) { + t.Fatalf("expected working directory %q, got %q", filepath.Clean(cwd), ctx.WorkingDirectory) + } + if ctx.ConfigPath != filepath.Clean(configPath) { + t.Fatalf("expected config path %q, got %q", filepath.Clean(configPath), 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() + + configPath := writeTestConfig(t, t.TempDir()) + _, err := NewAnalysisContext("01-01-2026", 0, "", configPath) + if err == nil { + t.Fatalf("expected invalid --since error") + } +} + +func TestNewAnalysisContext_RejectsPRAndFeatureTogether(t *testing.T) { + t.Parallel() + + configPath := writeTestConfig(t, t.TempDir()) + _, err := NewAnalysisContext("", 42, "onboarding-v2", configPath) + if err == nil { + t.Fatalf("expected mutually exclusive --pr and --feature error") + } +} + +func TestNewAnalysisContext_RejectsMissingConfig(t *testing.T) { + t.Parallel() + + _, err := NewAnalysisContext("", 0, "", filepath.Join(t.TempDir(), "missing.yaml")) + if err == nil { + t.Fatalf("expected config load error") + } +} + +func TestBuildInitialPrompt_IncludesScopeAndSources(t *testing.T) { + t.Parallel() + + parsed, err := NewAnalysisContext("2026-01-01", 142, "", writeTestConfig(t, t.TempDir())) + if err != nil { + t.Fatalf("NewAnalysisContext returned error: %v", err) + } + + cfg := &Config{ + Velen: VelenConfig{ + Org: "impactable", + Sources: VelenSources{ + GitHub: "github-main", + Analytics: "amplitude-prod", + }, + }, + } + prompt := BuildInitialPrompt(parsed, cfg) + + expected := []string{ + "scope_since: 2026-01-01", + "scope_pr: PR #142 only", + "github_source_key: github-main", + "analytics_source_key: amplitude-prod", + } + for _, fragment := range expected { + if !strings.Contains(prompt, fragment) { + t.Fatalf("expected prompt to contain %q, got:\n%s", fragment, prompt) + } + } +} diff --git a/internal/gitimpact/engine.go b/internal/gitimpact/engine.go new file mode 100644 index 0000000..af7df87 --- /dev/null +++ b/internal/gitimpact/engine.go @@ -0,0 +1,268 @@ +package gitimpact + +import ( + "context" + "errors" + "fmt" +) + +const defaultMaxRetries = 3 + +// Phase identifies the current stage of the git-impact analysis run. +type Phase string + +const ( + PhaseSourceCheck Phase = "source_check" + PhaseCollect Phase = "collect" + PhaseLink Phase = "link" + PhaseScore Phase = "score" + PhaseReport Phase = "report" +) + +// Directive instructs the engine what to do after a turn. +type Directive string + +const ( + DirectiveAdvancePhase Directive = "advance_phase" + DirectiveContinue Directive = "continue" + DirectiveRetry Directive = "retry" + DirectiveWait Directive = "wait" + DirectiveComplete Directive = "complete" +) + +// TurnResult is the phase handler output consumed by the engine loop. +type TurnResult struct { + Directive Directive + WaitMessage string + Output string + Error error +} + +// PhaseHandler executes one phase turn. +type PhaseHandler interface { + Handle(ctx context.Context, runCtx *RunContext) (*TurnResult, error) +} + +// RunContext is mutable state shared across phase turns. +type RunContext struct { + Config *Config + AnalysisCtx *AnalysisContext + VelenClient *VelenClient + Phase Phase + Iteration int + CollectedData *CollectedData + LinkedData *LinkedData + ScoredData *ScoredData +} + +// CollectedData stores source collection outputs. +type CollectedData struct { + PRs []PR + Tags []string + Releases []Release + RawOutput string +} + +// LinkedData stores deployment-linking outputs. +type LinkedData struct { + Deployments []Deployment + FeatureGroups []FeatureGroup + AmbiguousItems []AmbiguousDeployment +} + +// ScoredData stores scoring outputs. +type ScoredData struct { + PRImpacts []PRImpact + ContributorStats []ContributorStats +} + +// Engine executes ordered git-impact phases using phased-delivery directives. +type Engine struct { + Handlers map[Phase]PhaseHandler + Observer Observer + WaitHandler WaitHandler + MaxRetries int +} + +var phaseOrder = []Phase{ + PhaseSourceCheck, + PhaseCollect, + PhaseLink, + PhaseScore, + PhaseReport, +} + +// Run executes the phased-delivery policy for one analysis run. +func (e *Engine) Run(ctx context.Context, runCtx *RunContext) (*AnalysisResult, error) { + if runCtx == nil { + err := errors.New("run context is required") + e.notifyRunExhausted(err) + return nil, err + } + + phaseIndex, err := resolveStartPhase(runCtx.Phase) + if err != nil { + e.notifyRunExhausted(err) + return nil, err + } + + maxRetries := e.MaxRetries + if maxRetries <= 0 { + maxRetries = defaultMaxRetries + } + + retries := 0 + for { + if err := ctx.Err(); err != nil { + e.notifyRunExhausted(err) + return nil, err + } + + phase := phaseOrder[phaseIndex] + runCtx.Phase = phase + runCtx.Iteration++ + e.notifyTurnStarted(phase, runCtx.Iteration) + + handler, ok := e.Handlers[phase] + if !ok || handler == nil { + err := fmt.Errorf("no handler registered for phase %q", phase) + e.notifyRunExhausted(err) + return nil, err + } + + turnResult, err := handler.Handle(ctx, runCtx) + if err != nil { + e.notifyRunExhausted(err) + return nil, err + } + if turnResult == nil { + err := fmt.Errorf("phase %q returned nil result", phase) + e.notifyRunExhausted(err) + return nil, err + } + + switch turnResult.Directive { + case DirectiveAdvancePhase: + retries = 0 + nextIndex := phaseIndex + 1 + if nextIndex >= len(phaseOrder) { + result := newAnalysisResult(runCtx, turnResult.Output) + e.notifyRunCompleted(result) + return result, nil + } + from := phaseOrder[phaseIndex] + to := phaseOrder[nextIndex] + phaseIndex = nextIndex + runCtx.Phase = to + e.notifyPhaseAdvanced(from, to) + case DirectiveComplete: + result := newAnalysisResult(runCtx, turnResult.Output) + e.notifyRunCompleted(result) + return result, nil + case DirectiveRetry: + if retries >= maxRetries { + err := fmt.Errorf("phase %q exceeded max retries (%d)", phase, maxRetries) + if turnResult.Error != nil { + err = fmt.Errorf("%w: %v", err, turnResult.Error) + } + e.notifyRunExhausted(err) + return nil, err + } + retries++ + case DirectiveWait: + if e.WaitHandler == nil { + err := errors.New("wait directive received but wait handler is not configured") + e.notifyRunExhausted(err) + return nil, err + } + e.notifyWaitEntered(turnResult.WaitMessage) + response, err := e.WaitHandler(turnResult.WaitMessage) + if err != nil { + e.notifyRunExhausted(err) + return nil, err + } + if runCtx.AnalysisCtx == nil { + runCtx.AnalysisCtx = &AnalysisContext{} + } + runCtx.AnalysisCtx.LastWaitResponse = response + e.notifyWaitResolved(response) + retries = 0 + case DirectiveContinue: + retries = 0 + default: + err := fmt.Errorf("unsupported directive %q", turnResult.Directive) + e.notifyRunExhausted(err) + return nil, err + } + } +} + +func resolveStartPhase(start Phase) (int, error) { + if start == "" { + return 0, nil + } + for i, phase := range phaseOrder { + if phase == start { + return i, nil + } + } + return 0, fmt.Errorf("unsupported start phase %q", start) +} + +func newAnalysisResult(runCtx *RunContext, output string) *AnalysisResult { + result := &AnalysisResult{Output: output, Phase: runCtx.Phase, Iteration: runCtx.Iteration} + if runCtx.CollectedData != nil { + result.PRs = runCtx.CollectedData.PRs + } + if runCtx.LinkedData != nil { + result.Deployments = runCtx.LinkedData.Deployments + result.FeatureGroups = runCtx.LinkedData.FeatureGroups + } + if runCtx.ScoredData != nil { + result.PRImpacts = runCtx.ScoredData.PRImpacts + result.Contributors = runCtx.ScoredData.ContributorStats + } + return result +} + +func (e *Engine) notifyTurnStarted(phase Phase, iteration int) { + if e.Observer == nil { + return + } + e.Observer.OnTurnStarted(phase, iteration) +} + +func (e *Engine) notifyPhaseAdvanced(from, to Phase) { + if e.Observer == nil { + return + } + e.Observer.OnPhaseAdvanced(from, to) +} + +func (e *Engine) notifyWaitEntered(message string) { + if e.Observer == nil { + return + } + e.Observer.OnWaitEntered(message) +} + +func (e *Engine) notifyWaitResolved(response string) { + if e.Observer == nil { + return + } + e.Observer.OnWaitResolved(response) +} + +func (e *Engine) notifyRunCompleted(result *AnalysisResult) { + if e.Observer == nil { + return + } + e.Observer.OnRunCompleted(result) +} + +func (e *Engine) notifyRunExhausted(err error) { + if e.Observer == nil { + return + } + e.Observer.OnRunExhausted(err) +} diff --git a/internal/gitimpact/engine_defaults.go b/internal/gitimpact/engine_defaults.go new file mode 100644 index 0000000..027a9aa --- /dev/null +++ b/internal/gitimpact/engine_defaults.go @@ -0,0 +1,66 @@ +package gitimpact + +import ( + "context" + "fmt" +) + +// ReportHandler finalizes the analysis run. +type ReportHandler struct{} + +// Handle completes the run and emits a summary output string. +func (h *ReportHandler) Handle(_ context.Context, runCtx *RunContext) (*TurnResult, error) { + if runCtx == nil { + return nil, fmt.Errorf("run context is required") + } + + return &TurnResult{ + Directive: DirectiveComplete, + Output: buildReportOutput(runCtx), + }, nil +} + +// DefaultHandlers returns the standard phase handler set for git-impact analysis. +func DefaultHandlers() map[Phase]PhaseHandler { + return map[Phase]PhaseHandler{ + PhaseSourceCheck: &SourceCheckHandler{}, + PhaseCollect: &CollectHandler{}, + PhaseLink: &LinkHandler{}, + PhaseScore: &ScoreHandler{}, + PhaseReport: &ReportHandler{}, + } +} + +// NewDefaultEngine builds an engine with all default phase handlers configured. +func NewDefaultEngine(client *VelenClient, observer Observer, waitHandler WaitHandler) *Engine { + _ = client + return &Engine{ + Handlers: DefaultHandlers(), + Observer: observer, + WaitHandler: waitHandler, + MaxRetries: defaultMaxRetries, + } +} + +func buildReportOutput(runCtx *RunContext) string { + prCount := 0 + deploymentCount := 0 + impactCount := 0 + + if runCtx.CollectedData != nil { + prCount = len(runCtx.CollectedData.PRs) + } + if runCtx.LinkedData != nil { + deploymentCount = len(runCtx.LinkedData.Deployments) + } + if runCtx.ScoredData != nil { + impactCount = len(runCtx.ScoredData.PRImpacts) + } + + return fmt.Sprintf( + "analysis complete: %d PRs, %d deployments, %d scored impacts", + prCount, + deploymentCount, + impactCount, + ) +} diff --git a/internal/gitimpact/engine_defaults_test.go b/internal/gitimpact/engine_defaults_test.go new file mode 100644 index 0000000..93144f9 --- /dev/null +++ b/internal/gitimpact/engine_defaults_test.go @@ -0,0 +1,37 @@ +package gitimpact + +import "testing" + +func TestDefaultHandlersIncludesAllPhases(t *testing.T) { + t.Parallel() + + handlers := DefaultHandlers() + for _, phase := range phaseOrder { + handler, ok := handlers[phase] + if !ok { + t.Fatalf("missing default handler for phase %q", phase) + } + if handler == nil { + t.Fatalf("default handler for phase %q is nil", phase) + } + } +} + +func TestNewDefaultEngineWiresDependencies(t *testing.T) { + t.Parallel() + + waitFn := func(string) (string, error) { return "y", nil } + engine := NewDefaultEngine(NewVelenClient(0), nil, waitFn) + if engine == nil { + t.Fatal("expected non-nil engine") + } + if engine.MaxRetries != defaultMaxRetries { + t.Fatalf("max retries = %d, want %d", engine.MaxRetries, defaultMaxRetries) + } + if len(engine.Handlers) != len(phaseOrder) { + t.Fatalf("handler count = %d, want %d", len(engine.Handlers), len(phaseOrder)) + } + if engine.WaitHandler == nil { + t.Fatal("wait handler is nil") + } +} diff --git a/internal/gitimpact/engine_test.go b/internal/gitimpact/engine_test.go new file mode 100644 index 0000000..bbc9d36 --- /dev/null +++ b/internal/gitimpact/engine_test.go @@ -0,0 +1,196 @@ +package gitimpact + +import ( + "context" + "errors" + "strings" + "testing" +) + +type phaseHandlerFunc func(ctx context.Context, runCtx *RunContext) (*TurnResult, error) + +func (fn phaseHandlerFunc) Handle(ctx context.Context, runCtx *RunContext) (*TurnResult, error) { + return fn(ctx, runCtx) +} + +type recordingObserver struct { + turns []Phase + iterations []int + advances [][2]Phase + waitMessages []string + waitResponses []string + completed *AnalysisResult + exhaustedErr error +} + +func (o *recordingObserver) OnTurnStarted(phase Phase, iteration int) { + o.turns = append(o.turns, phase) + o.iterations = append(o.iterations, iteration) +} + +func (o *recordingObserver) OnPhaseAdvanced(from, to Phase) { + o.advances = append(o.advances, [2]Phase{from, to}) +} + +func (o *recordingObserver) OnWaitEntered(message string) { + o.waitMessages = append(o.waitMessages, message) +} + +func (o *recordingObserver) OnWaitResolved(response string) { + o.waitResponses = append(o.waitResponses, response) +} + +func (o *recordingObserver) OnRunCompleted(result *AnalysisResult) { + o.completed = result +} + +func (o *recordingObserver) OnRunExhausted(err error) { + o.exhaustedErr = err +} + +func TestEngineRun_PhaseProgression(t *testing.T) { + t.Parallel() + + observer := &recordingObserver{} + engine := &Engine{ + Observer: observer, + Handlers: map[Phase]PhaseHandler{ + PhaseSourceCheck: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseCollect: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseLink: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseScore: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseReport: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveComplete, Output: "analysis complete"}, nil + }), + }, + } + + runCtx := &RunContext{} + result, err := engine.Run(context.Background(), runCtx) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Output != "analysis complete" { + t.Fatalf("expected complete output, got %q", result.Output) + } + if result.Phase != PhaseReport { + t.Fatalf("expected final phase %q, got %q", PhaseReport, result.Phase) + } + if result.Iteration != 5 { + t.Fatalf("expected 5 iterations, got %d", result.Iteration) + } + if len(observer.turns) != 5 { + t.Fatalf("expected 5 turn-start events, got %d", len(observer.turns)) + } + if len(observer.advances) != 4 { + t.Fatalf("expected 4 phase-advanced events, got %d", len(observer.advances)) + } + if observer.exhaustedErr != nil { + t.Fatalf("did not expect exhausted event, got %v", observer.exhaustedErr) + } + if observer.completed == nil { + t.Fatal("expected run-completed event") + } +} + +func TestEngineRun_RetryExhaustion(t *testing.T) { + t.Parallel() + + observer := &recordingObserver{} + collectCalls := 0 + engine := &Engine{ + Observer: observer, + MaxRetries: 3, + Handlers: map[Phase]PhaseHandler{ + PhaseSourceCheck: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseCollect: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + collectCalls++ + return &TurnResult{Directive: DirectiveRetry, Error: errors.New("transient failure")}, nil + }), + }, + } + + _, err := engine.Run(context.Background(), &RunContext{}) + if err == nil { + t.Fatal("expected retry exhaustion error") + } + if !strings.Contains(err.Error(), "exceeded max retries") { + t.Fatalf("expected max retries error, got %v", err) + } + if collectCalls != 4 { + t.Fatalf("expected 4 collect attempts (3 retries + 1 exhausted attempt), got %d", collectCalls) + } + if observer.exhaustedErr == nil { + t.Fatal("expected exhausted observer event") + } +} + +func TestEngineRun_WaitHandling(t *testing.T) { + t.Parallel() + + observer := &recordingObserver{} + sourceCheckCalls := 0 + waitMessages := make([]string, 0, 1) + engine := &Engine{ + Observer: observer, + WaitHandler: func(message string) (string, error) { + waitMessages = append(waitMessages, message) + return "proceed", nil + }, + Handlers: map[Phase]PhaseHandler{ + PhaseSourceCheck: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + sourceCheckCalls++ + if sourceCheckCalls == 1 { + return &TurnResult{Directive: DirectiveWait, WaitMessage: "confirm source mapping"}, nil + } + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseCollect: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseLink: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseScore: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + }), + PhaseReport: phaseHandlerFunc(func(context.Context, *RunContext) (*TurnResult, error) { + return &TurnResult{Directive: DirectiveComplete, Output: "done"}, nil + }), + }, + } + + runCtx := &RunContext{} + result, err := engine.Run(context.Background(), runCtx) + if err != nil { + t.Fatalf("run returned error: %v", err) + } + if result.Output != "done" { + t.Fatalf("expected done output, got %q", result.Output) + } + if len(waitMessages) != 1 || waitMessages[0] != "confirm source mapping" { + t.Fatalf("unexpected wait handler messages: %#v", waitMessages) + } + if len(observer.waitMessages) != 1 || observer.waitMessages[0] != "confirm source mapping" { + t.Fatalf("unexpected wait-entered events: %#v", observer.waitMessages) + } + if len(observer.waitResponses) != 1 || observer.waitResponses[0] != "proceed" { + t.Fatalf("unexpected wait-resolved events: %#v", observer.waitResponses) + } + if runCtx.AnalysisCtx == nil || runCtx.AnalysisCtx.LastWaitResponse != "proceed" { + t.Fatalf("expected wait response stored in analysis context, got %#v", runCtx.AnalysisCtx) + } +} diff --git a/internal/gitimpact/observer.go b/internal/gitimpact/observer.go new file mode 100644 index 0000000..d27beb5 --- /dev/null +++ b/internal/gitimpact/observer.go @@ -0,0 +1,14 @@ +package gitimpact + +// Observer receives run lifecycle notifications from the phased engine. +type Observer interface { + OnTurnStarted(phase Phase, iteration int) + OnPhaseAdvanced(from, to Phase) + OnWaitEntered(message string) + OnWaitResolved(response string) + OnRunCompleted(result *AnalysisResult) + OnRunExhausted(err error) +} + +// WaitHandler requests external input when the engine enters wait state. +type WaitHandler func(message string) (string, error) diff --git a/internal/gitimpact/phase_collect.go b/internal/gitimpact/phase_collect.go new file mode 100644 index 0000000..dd8a114 --- /dev/null +++ b/internal/gitimpact/phase_collect.go @@ -0,0 +1,295 @@ +package gitimpact + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +const ( + collectDateLayout = "2006-01-02" + defaultSinceDate = "1970-01-01" +) + +type collectQueryFn func(client *VelenClient, sourceKey string, sql string) (*QueryResult, error) + +// CollectHandler fetches GitHub PR, tag, and release metadata through Velen. +type CollectHandler struct { + Query collectQueryFn +} + +func (h *CollectHandler) Handle(_ context.Context, runCtx *RunContext) (*TurnResult, error) { + if runCtx == nil { + return nil, fmt.Errorf("run context is required") + } + if runCtx.VelenClient == nil { + return nil, fmt.Errorf("velen client is required") + } + + sourceKey := "" + if runCtx.Config != nil { + sourceKey = strings.TrimSpace(runCtx.Config.Velen.Sources.GitHub) + } + if sourceKey == "" { + return nil, fmt.Errorf("github source key is required") + } + + query := h.Query + if query == nil { + query = func(client *VelenClient, sourceKey string, sql string) (*QueryResult, error) { + return client.Query(sourceKey, sql) + } + } + + since := defaultSinceDate + if runCtx.AnalysisCtx != nil && runCtx.AnalysisCtx.Since != nil { + since = runCtx.AnalysisCtx.Since.Format(collectDateLayout) + } + + prSQL := fmt.Sprintf( + "SELECT number, title, author, merged_at, head_branch, labels FROM pull_requests WHERE merged_at > '%s' ORDER BY merged_at DESC LIMIT 100", + since, + ) + prResult, err := query(runCtx.VelenClient, sourceKey, prSQL) + if err != nil { + return nil, fmt.Errorf("collect prs: %w", err) + } + prs, err := parsePRRows(prResult) + if err != nil { + return nil, err + } + + tagSQL := "SELECT name, created_at FROM tags ORDER BY created_at DESC LIMIT 50" + tagResult, err := query(runCtx.VelenClient, sourceKey, tagSQL) + if err != nil { + return nil, fmt.Errorf("collect tags: %w", err) + } + tags, err := parseTagRows(tagResult) + if err != nil { + return nil, err + } + + releaseSQL := "SELECT name, tag_name, published_at FROM releases ORDER BY published_at DESC LIMIT 20" + releaseResult, err := query(runCtx.VelenClient, sourceKey, releaseSQL) + if err != nil { + return nil, fmt.Errorf("collect releases: %w", err) + } + releases, err := parseReleaseRows(releaseResult) + if err != nil { + return nil, err + } + + runCtx.CollectedData = &CollectedData{ + PRs: prs, + Tags: tags, + Releases: releases, + } + + return &TurnResult{Directive: DirectiveAdvancePhase}, nil +} + +func parsePRRows(result *QueryResult) ([]PR, error) { + if result == nil { + return nil, fmt.Errorf("collect prs: query result is nil") + } + + prs := make([]PR, 0, len(result.Rows)) + for idx, row := range result.Rows { + if len(row) < 6 { + return nil, fmt.Errorf("collect prs: row %d has %d columns, expected 6", idx, len(row)) + } + + number, err := asInt(row[0]) + if err != nil { + return nil, fmt.Errorf("collect prs: row %d invalid number: %w", idx, err) + } + mergedAt, err := asTime(row[3]) + if err != nil { + return nil, fmt.Errorf("collect prs: row %d invalid merged_at: %w", idx, err) + } + labels, err := asStringSlice(row[5]) + if err != nil { + return nil, fmt.Errorf("collect prs: row %d invalid labels: %w", idx, err) + } + + prs = append(prs, PR{ + Number: number, + Title: asString(row[1]), + Author: asString(row[2]), + MergedAt: mergedAt, + Branch: asString(row[4]), + Labels: labels, + }) + } + + return prs, nil +} + +func parseTagRows(result *QueryResult) ([]string, error) { + if result == nil { + return nil, fmt.Errorf("collect tags: query result is nil") + } + + tags := make([]string, 0, len(result.Rows)) + for idx, row := range result.Rows { + if len(row) < 2 { + return nil, fmt.Errorf("collect tags: row %d has %d columns, expected 2", idx, len(row)) + } + createdAt, err := asTime(row[1]) + if err != nil { + return nil, fmt.Errorf("collect tags: row %d invalid created_at: %w", idx, err) + } + tags = append(tags, formatTagWithTimestamp(asString(row[0]), createdAt)) + } + return tags, nil +} + +func formatTagWithTimestamp(name string, createdAt time.Time) string { + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + return "" + } + if createdAt.IsZero() { + return trimmedName + } + return fmt.Sprintf("%s|%s", trimmedName, createdAt.UTC().Format(time.RFC3339)) +} + +func parseReleaseRows(result *QueryResult) ([]Release, error) { + if result == nil { + return nil, fmt.Errorf("collect releases: query result is nil") + } + + releases := make([]Release, 0, len(result.Rows)) + for idx, row := range result.Rows { + if len(row) < 3 { + return nil, fmt.Errorf("collect releases: row %d has %d columns, expected 3", idx, len(row)) + } + + publishedAt, err := asTime(row[2]) + if err != nil { + return nil, fmt.Errorf("collect releases: row %d invalid published_at: %w", idx, err) + } + + releases = append(releases, Release{ + Name: asString(row[0]), + TagName: asString(row[1]), + PublishedAt: publishedAt, + }) + } + + return releases, nil +} + +func asString(value interface{}) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + case fmt.Stringer: + return typed.String() + default: + return fmt.Sprintf("%v", typed) + } +} + +func asInt(value interface{}) (int, error) { + switch typed := value.(type) { + case int: + return typed, nil + case int32: + return int(typed), nil + case int64: + return int(typed), nil + case float64: + return int(typed), nil + case json.Number: + parsed, err := typed.Int64() + if err != nil { + return 0, err + } + return int(parsed), nil + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return 0, fmt.Errorf("empty string") + } + parsed, err := strconv.Atoi(trimmed) + if err != nil { + return 0, err + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T", typed) + } +} + +func asTime(value interface{}) (time.Time, error) { + switch typed := value.(type) { + case time.Time: + return typed, nil + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return time.Time{}, fmt.Errorf("empty string") + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + collectDateLayout, + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, trimmed); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("unsupported time format %q", trimmed) + default: + return time.Time{}, fmt.Errorf("unsupported type %T", typed) + } +} + +func asStringSlice(value interface{}) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case []string: + return typed, nil + case []interface{}: + values := make([]string, 0, len(typed)) + for _, item := range typed { + values = append(values, asString(item)) + } + return values, nil + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return nil, nil + } + + var decoded []string + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil { + return decoded, nil + } + } + + parts := strings.Split(trimmed, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.TrimSpace(part) + if item != "" { + values = append(values, item) + } + } + return values, nil + default: + return nil, fmt.Errorf("unsupported type %T", typed) + } +} diff --git a/internal/gitimpact/phase_collect_test.go b/internal/gitimpact/phase_collect_test.go new file mode 100644 index 0000000..e19b3e1 --- /dev/null +++ b/internal/gitimpact/phase_collect_test.go @@ -0,0 +1,165 @@ +package gitimpact + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +func TestCollectHandlerHandle_Success(t *testing.T) { + t.Parallel() + + since := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + expectedPRSQL := "SELECT number, title, author, merged_at, head_branch, labels FROM pull_requests WHERE merged_at > '2026-01-02' ORDER BY merged_at DESC LIMIT 100" + expectedTagSQL := "SELECT name, created_at FROM tags ORDER BY created_at DESC LIMIT 50" + expectedReleaseSQL := "SELECT name, tag_name, published_at FROM releases ORDER BY published_at DESC LIMIT 20" + + seenSQL := make([]string, 0, 3) + handler := &CollectHandler{ + Query: func(_ *VelenClient, sourceKey string, sql string) (*QueryResult, error) { + if sourceKey != "github-main" { + return nil, errors.New("unexpected source key") + } + seenSQL = append(seenSQL, sql) + + switch sql { + case expectedPRSQL: + return &QueryResult{ + Rows: [][]interface{}{ + {float64(101), "Improve onboarding", "alice", "2026-01-03T12:30:00Z", "feature/onboarding-v2", []interface{}{"feature/onboarding-v2", "ux"}}, + }, + }, nil + case expectedTagSQL: + return &QueryResult{ + Rows: [][]interface{}{ + {"v1.2.3", "2026-01-04T08:00:00Z"}, + }, + }, nil + case expectedReleaseSQL: + return &QueryResult{ + Rows: [][]interface{}{ + {"January Release", "v1.2.3", "2026-01-05T09:15:00Z"}, + }, + }, nil + default: + return nil, errors.New("unexpected sql") + } + }, + } + + runCtx := &RunContext{ + VelenClient: &VelenClient{}, + Config: &Config{ + Velen: VelenConfig{ + Sources: VelenSources{GitHub: "github-main"}, + }, + }, + AnalysisCtx: &AnalysisContext{Since: &since}, + } + + result, err := handler.Handle(context.Background(), runCtx) + if err != nil { + t.Fatalf("handle returned error: %v", err) + } + if result == nil || result.Directive != DirectiveAdvancePhase { + t.Fatalf("expected advance directive, got %+v", result) + } + if runCtx.CollectedData == nil { + t.Fatal("expected collected data to be populated") + } + if len(runCtx.CollectedData.PRs) != 1 { + t.Fatalf("expected 1 PR, got %d", len(runCtx.CollectedData.PRs)) + } + if runCtx.CollectedData.PRs[0].Number != 101 { + t.Fatalf("unexpected PR number: %d", runCtx.CollectedData.PRs[0].Number) + } + if !reflect.DeepEqual(runCtx.CollectedData.PRs[0].Labels, []string{"feature/onboarding-v2", "ux"}) { + t.Fatalf("unexpected PR labels: %#v", runCtx.CollectedData.PRs[0].Labels) + } + if !reflect.DeepEqual(runCtx.CollectedData.Tags, []string{"v1.2.3|2026-01-04T08:00:00Z"}) { + t.Fatalf("unexpected tags: %#v", runCtx.CollectedData.Tags) + } + if len(runCtx.CollectedData.Releases) != 1 || runCtx.CollectedData.Releases[0].TagName != "v1.2.3" { + t.Fatalf("unexpected releases: %#v", runCtx.CollectedData.Releases) + } + if !reflect.DeepEqual(seenSQL, []string{expectedPRSQL, expectedTagSQL, expectedReleaseSQL}) { + t.Fatalf("unexpected query sequence: %#v", seenSQL) + } +} + +func TestCollectHandlerHandle_RequiresGitHubSourceKey(t *testing.T) { + t.Parallel() + + handler := &CollectHandler{} + _, err := handler.Handle(context.Background(), &RunContext{ + VelenClient: &VelenClient{}, + Config: &Config{}, + }) + if err == nil { + t.Fatal("expected error when github source key is missing") + } + if !strings.Contains(err.Error(), "github source key is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCollectHandlerHandle_PropagatesQueryError(t *testing.T) { + t.Parallel() + + handler := &CollectHandler{ + Query: func(*VelenClient, string, string) (*QueryResult, error) { + return nil, errors.New("query failed") + }, + } + _, err := handler.Handle(context.Background(), &RunContext{ + VelenClient: &VelenClient{}, + Config: &Config{ + Velen: VelenConfig{ + Sources: VelenSources{GitHub: "github-main"}, + }, + }, + }) + if err == nil { + t.Fatal("expected query error") + } + if !strings.Contains(err.Error(), "collect prs") { + t.Fatalf("expected collect prs wrapper, got %v", err) + } +} + +func TestCollectHandlerHandle_InvalidPRRowsReturnError(t *testing.T) { + t.Parallel() + + prSQL := "SELECT number, title, author, merged_at, head_branch, labels FROM pull_requests WHERE merged_at > '1970-01-01' ORDER BY merged_at DESC LIMIT 100" + + handler := &CollectHandler{ + Query: func(_ *VelenClient, _ string, sql string) (*QueryResult, error) { + if sql == prSQL { + return &QueryResult{ + Rows: [][]interface{}{ + {float64(1), "missing columns"}, + }, + }, nil + } + return &QueryResult{}, nil + }, + } + + _, err := handler.Handle(context.Background(), &RunContext{ + VelenClient: &VelenClient{}, + Config: &Config{ + Velen: VelenConfig{ + Sources: VelenSources{GitHub: "github-main"}, + }, + }, + }) + if err == nil { + t.Fatal("expected parsing error") + } + if !strings.Contains(err.Error(), "collect prs: row 0 has 2 columns, expected 6") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/gitimpact/phase_link.go b/internal/gitimpact/phase_link.go new file mode 100644 index 0000000..5aa3903 --- /dev/null +++ b/internal/gitimpact/phase_link.go @@ -0,0 +1,350 @@ +package gitimpact + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +const ( + linkInferenceWindow = 48 * time.Hour + ambiguousWindow = 24 * time.Hour + tagTimestampSeparator = "|" +) + +// LinkHandler maps collected GitHub metadata to inferred deployments and feature groups. +type LinkHandler struct{} + +// Handle infers deployment markers for collected PRs and advances to scoring when unambiguous. +func (h *LinkHandler) Handle(_ context.Context, runCtx *RunContext) (*TurnResult, error) { + if runCtx == nil { + return nil, fmt.Errorf("run context is required") + } + if runCtx.CollectedData == nil { + return nil, fmt.Errorf("collected data is required") + } + + prs := runCtx.CollectedData.PRs + releases := runCtx.CollectedData.Releases + tags := runCtx.CollectedData.Tags + + deployments := make([]Deployment, 0, len(prs)) + for _, pr := range prs { + deployment, _ := inferDeployment(pr, releases, tags) + deployments = append(deployments, deployment) + } + + featureGroups := proposeFeatureGroups(prs) + ambiguousItems := detectAmbiguousDeployments(prs, releases) + if len(ambiguousItems) > 0 { + return &TurnResult{ + Directive: DirectiveWait, + WaitMessage: buildAmbiguityWaitMessage(ambiguousItems), + }, nil + } + + runCtx.LinkedData = &LinkedData{ + Deployments: deployments, + FeatureGroups: featureGroups, + AmbiguousItems: ambiguousItems, + } + + return &TurnResult{Directive: DirectiveAdvancePhase}, nil +} + +func inferDeployment(pr PR, releases []Release, tags []string) (Deployment, bool) { + deployment := Deployment{ + PRNumber: pr.Number, + Marker: fmt.Sprintf("pr-%d-merge", pr.Number), + Source: "pr_merge", + DeployedAt: pr.MergedAt, + } + if pr.MergedAt.IsZero() { + return deployment, false + } + + if release, ok := nearestReleaseAfter(pr.MergedAt, releases); ok { + deployment.Marker = releaseMarker(release) + deployment.Source = "release" + deployment.DeployedAt = release.PublishedAt + return deployment, true + } + + if tagName, createdAt, ok := nearestVersionTagAfter(pr.MergedAt, tags); ok { + deployment.Marker = tagName + deployment.Source = "tag" + deployment.DeployedAt = createdAt + return deployment, true + } + + return deployment, true +} + +func proposeFeatureGroups(prs []PR) []FeatureGroup { + type groupBucket struct { + Name string + PRNumbers map[int]struct{} + } + + groups := map[string]*groupBucket{} + addToGroup := func(name string, prNumber int) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return + } + if !strings.HasPrefix(strings.ToLower(trimmed), "feature/") { + return + } + + key := strings.ToLower(trimmed) + bucket, ok := groups[key] + if !ok { + bucket = &groupBucket{ + Name: trimmed, + PRNumbers: map[int]struct{}{}, + } + groups[key] = bucket + } + bucket.PRNumbers[prNumber] = struct{}{} + } + + for _, pr := range prs { + for _, label := range pr.Labels { + addToGroup(label, pr.Number) + } + addToGroup(pr.Branch, pr.Number) + } + + groupNames := make([]string, 0, len(groups)) + for key := range groups { + groupNames = append(groupNames, key) + } + sort.Strings(groupNames) + + result := make([]FeatureGroup, 0, len(groups)) + for _, key := range groupNames { + bucket := groups[key] + prNumbers := make([]int, 0, len(bucket.PRNumbers)) + for number := range bucket.PRNumbers { + prNumbers = append(prNumbers, number) + } + sort.Ints(prNumbers) + result = append(result, FeatureGroup{ + Name: bucket.Name, + PRNumbers: prNumbers, + }) + } + return result +} + +func detectAmbiguousDeployments(prs []PR, releases []Release) []AmbiguousDeployment { + if len(prs) < 2 || len(releases) < 2 { + return nil + } + + sortedReleases := append([]Release(nil), releases...) + sort.Slice(sortedReleases, func(i, j int) bool { + return sortedReleases[i].PublishedAt.Before(sortedReleases[j].PublishedAt) + }) + + ambiguousByPR := map[int]AmbiguousDeployment{} + for start := 0; start < len(sortedReleases); { + end := start + 1 + for end < len(sortedReleases) { + if sortedReleases[end].PublishedAt.Sub(sortedReleases[end-1].PublishedAt) > ambiguousWindow { + break + } + end++ + } + + cluster := sortedReleases[start:end] + if len(cluster) > 1 { + windowStart := cluster[0].PublishedAt + windowEnd := cluster[len(cluster)-1].PublishedAt + prNumbers := prsMergedWithinWindow(prs, windowStart, windowEnd) + if len(prNumbers) > 1 { + options := clusterReleaseOptions(cluster) + for _, prNumber := range prNumbers { + current, ok := ambiguousByPR[prNumber] + if !ok { + ambiguousByPR[prNumber] = AmbiguousDeployment{ + PRNumber: prNumber, + Options: append([]string(nil), options...), + Reason: "multiple releases were published within 24h while multiple PRs merged in that window", + } + continue + } + + current.Options = mergeDistinctOptions(current.Options, options) + ambiguousByPR[prNumber] = current + } + } + } + + start = end + } + + if len(ambiguousByPR) == 0 { + return nil + } + + result := make([]AmbiguousDeployment, 0, len(ambiguousByPR)) + for _, item := range ambiguousByPR { + sort.Strings(item.Options) + result = append(result, item) + } + sort.Slice(result, func(i, j int) bool { + return result[i].PRNumber < result[j].PRNumber + }) + return result +} + +func isVersionTag(name string) bool { + trimmed := strings.ToLower(strings.TrimSpace(name)) + return strings.HasPrefix(trimmed, "v") || strings.HasPrefix(trimmed, "release-") +} + +func nearestReleaseAfter(mergedAt time.Time, releases []Release) (Release, bool) { + var chosen Release + found := false + for _, release := range releases { + if release.PublishedAt.IsZero() || release.PublishedAt.Before(mergedAt) { + continue + } + if release.PublishedAt.Sub(mergedAt) > linkInferenceWindow { + continue + } + if !found || release.PublishedAt.Before(chosen.PublishedAt) { + chosen = release + found = true + } + } + return chosen, found +} + +func nearestVersionTagAfter(mergedAt time.Time, tags []string) (string, time.Time, bool) { + var ( + selectedName string + selectedAt time.Time + found bool + ) + + for _, rawTag := range tags { + tagName, createdAt, ok := parseTagWithTimestamp(rawTag) + if !ok || !isVersionTag(tagName) { + continue + } + if createdAt.Before(mergedAt) || createdAt.Sub(mergedAt) > linkInferenceWindow { + continue + } + if !found || createdAt.Before(selectedAt) { + selectedName = tagName + selectedAt = createdAt + found = true + } + } + + return selectedName, selectedAt, found +} + +func parseTagWithTimestamp(raw string) (string, time.Time, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", time.Time{}, false + } + + parts := strings.SplitN(trimmed, tagTimestampSeparator, 2) + if len(parts) != 2 { + return strings.TrimSpace(trimmed), time.Time{}, false + } + + name := strings.TrimSpace(parts[0]) + if name == "" { + return "", time.Time{}, false + } + + createdAt, err := asTime(strings.TrimSpace(parts[1])) + if err != nil { + return "", time.Time{}, false + } + return name, createdAt, true +} + +func releaseMarker(release Release) string { + if strings.TrimSpace(release.TagName) != "" { + return strings.TrimSpace(release.TagName) + } + if strings.TrimSpace(release.Name) != "" { + return strings.TrimSpace(release.Name) + } + return "release" +} + +func prsMergedWithinWindow(prs []PR, windowStart time.Time, windowEnd time.Time) []int { + matches := make([]int, 0, len(prs)) + for _, pr := range prs { + if pr.MergedAt.Before(windowStart) || pr.MergedAt.After(windowEnd) { + continue + } + matches = append(matches, pr.Number) + } + sort.Ints(matches) + return matches +} + +func clusterReleaseOptions(cluster []Release) []string { + options := make([]string, 0, len(cluster)) + seen := map[string]struct{}{} + for _, release := range cluster { + option := releaseMarker(release) + if _, exists := seen[option]; exists { + continue + } + seen[option] = struct{}{} + options = append(options, option) + } + sort.Strings(options) + return options +} + +func mergeDistinctOptions(current []string, additions []string) []string { + seen := map[string]struct{}{} + merged := make([]string, 0, len(current)+len(additions)) + for _, option := range current { + if _, exists := seen[option]; exists { + continue + } + seen[option] = struct{}{} + merged = append(merged, option) + } + for _, option := range additions { + if _, exists := seen[option]; exists { + continue + } + seen[option] = struct{}{} + merged = append(merged, option) + } + sort.Strings(merged) + return merged +} + +func buildAmbiguityWaitMessage(items []AmbiguousDeployment) string { + if len(items) == 0 { + return "" + } + + descriptions := make([]string, 0, len(items)) + for _, item := range items { + descriptions = append( + descriptions, + fmt.Sprintf("PR #%d -> [%s]", item.PRNumber, strings.Join(item.Options, ", ")), + ) + } + return fmt.Sprintf( + "Ambiguous deployment mapping detected (%d PRs). Multiple releases were published within 24h. Please confirm mappings: %s", + len(items), + strings.Join(descriptions, "; "), + ) +} diff --git a/internal/gitimpact/phase_link_test.go b/internal/gitimpact/phase_link_test.go new file mode 100644 index 0000000..7ae55be --- /dev/null +++ b/internal/gitimpact/phase_link_test.go @@ -0,0 +1,212 @@ +package gitimpact + +import ( + "context" + "reflect" + "strings" + "testing" + "time" +) + +func TestInferDeployment_PrefersReleaseOverTag(t *testing.T) { + t.Parallel() + + mergedAt := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC) + pr := PR{Number: 42, MergedAt: mergedAt} + + deployment, ok := inferDeployment(pr, []Release{ + {TagName: "v2.0.1", PublishedAt: mergedAt.Add(4 * time.Hour)}, + }, []string{ + formatTagWithTimestamp("v2.0.0", mergedAt.Add(30*time.Minute)), + }) + if !ok { + t.Fatal("expected deployment inference to succeed") + } + if deployment.Source != "release" { + t.Fatalf("expected release source, got %q", deployment.Source) + } + if deployment.Marker != "v2.0.1" { + t.Fatalf("expected release marker v2.0.1, got %q", deployment.Marker) + } + if !deployment.DeployedAt.Equal(mergedAt.Add(4 * time.Hour)) { + t.Fatalf("unexpected deployed_at: %s", deployment.DeployedAt) + } +} + +func TestInferDeployment_UsesVersionTagWhenReleaseUnavailable(t *testing.T) { + t.Parallel() + + mergedAt := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC) + pr := PR{Number: 77, MergedAt: mergedAt} + + tagCreatedAt := mergedAt.Add(2 * time.Hour) + deployment, ok := inferDeployment(pr, []Release{ + {TagName: "v2.1.0", PublishedAt: mergedAt.Add(72 * time.Hour)}, + }, []string{ + formatTagWithTimestamp("build-20260210", tagCreatedAt), + formatTagWithTimestamp("release-2.0.0", tagCreatedAt), + }) + if !ok { + t.Fatal("expected deployment inference to succeed") + } + if deployment.Source != "tag" { + t.Fatalf("expected tag source, got %q", deployment.Source) + } + if deployment.Marker != "release-2.0.0" { + t.Fatalf("expected version tag marker, got %q", deployment.Marker) + } + if !deployment.DeployedAt.Equal(tagCreatedAt) { + t.Fatalf("expected deployed_at to equal tag timestamp, got %s", deployment.DeployedAt) + } +} + +func TestInferDeployment_FallsBackToMergeTime(t *testing.T) { + t.Parallel() + + mergedAt := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC) + pr := PR{Number: 101, MergedAt: mergedAt} + + deployment, ok := inferDeployment(pr, []Release{ + {TagName: "v2.0.2", PublishedAt: mergedAt.Add(72 * time.Hour)}, + }, []string{ + formatTagWithTimestamp("v2.0.1", mergedAt.Add(72*time.Hour)), + "v2.0.0", + }) + if !ok { + t.Fatal("expected fallback inference to be considered successful") + } + if deployment.Source != "pr_merge" { + t.Fatalf("expected pr_merge source, got %q", deployment.Source) + } + if !deployment.DeployedAt.Equal(mergedAt) { + t.Fatalf("expected merge timestamp fallback, got %s", deployment.DeployedAt) + } +} + +func TestDetectAmbiguousDeployments(t *testing.T) { + t.Parallel() + + releases := []Release{ + {Name: "Release A", TagName: "v1.0.0", PublishedAt: time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC)}, + {Name: "Release B", TagName: "v1.0.1", PublishedAt: time.Date(2026, 2, 10, 20, 0, 0, 0, time.UTC)}, + } + prs := []PR{ + {Number: 1, MergedAt: time.Date(2026, 2, 10, 11, 0, 0, 0, time.UTC)}, + {Number: 2, MergedAt: time.Date(2026, 2, 10, 19, 0, 0, 0, time.UTC)}, + } + + items := detectAmbiguousDeployments(prs, releases) + if len(items) != 2 { + t.Fatalf("expected 2 ambiguous deployments, got %d", len(items)) + } + for _, item := range items { + if !reflect.DeepEqual(item.Options, []string{"v1.0.0", "v1.0.1"}) { + t.Fatalf("unexpected options for PR #%d: %#v", item.PRNumber, item.Options) + } + } +} + +func TestLinkHandlerHandle_AdvancePhaseAndPopulateLinkedData(t *testing.T) { + t.Parallel() + + mergedA := time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC) + mergedB := time.Date(2026, 2, 11, 10, 0, 0, 0, time.UTC) + releaseAt := mergedA.Add(6 * time.Hour) + tagAt := mergedB.Add(2 * time.Hour) + + runCtx := &RunContext{ + CollectedData: &CollectedData{ + PRs: []PR{ + { + Number: 10, + MergedAt: mergedA, + Branch: "feature/checkout", + Labels: []string{"feature/checkout"}, + }, + { + Number: 11, + MergedAt: mergedB, + Branch: "feature/onboarding", + Labels: []string{"feature/onboarding"}, + }, + }, + Releases: []Release{ + {TagName: "v3.0.0", PublishedAt: releaseAt}, + }, + Tags: []string{ + formatTagWithTimestamp("release-3.1.0", tagAt), + }, + }, + } + + handler := &LinkHandler{} + result, err := handler.Handle(context.Background(), runCtx) + if err != nil { + t.Fatalf("handle returned error: %v", err) + } + if result == nil || result.Directive != DirectiveAdvancePhase { + t.Fatalf("expected advance_phase, got %+v", result) + } + if runCtx.LinkedData == nil { + t.Fatal("expected linked data to be populated") + } + if len(runCtx.LinkedData.Deployments) != 2 { + t.Fatalf("expected 2 deployments, got %d", len(runCtx.LinkedData.Deployments)) + } + if len(runCtx.LinkedData.FeatureGroups) != 2 { + t.Fatalf("expected 2 feature groups, got %d", len(runCtx.LinkedData.FeatureGroups)) + } +} + +func TestLinkHandlerHandle_WaitsOnAmbiguousDeployments(t *testing.T) { + t.Parallel() + + releases := []Release{ + {TagName: "v4.0.0", PublishedAt: time.Date(2026, 2, 10, 10, 0, 0, 0, time.UTC)}, + {TagName: "v4.0.1", PublishedAt: time.Date(2026, 2, 10, 16, 0, 0, 0, time.UTC)}, + } + runCtx := &RunContext{ + CollectedData: &CollectedData{ + PRs: []PR{ + {Number: 20, MergedAt: time.Date(2026, 2, 10, 11, 0, 0, 0, time.UTC)}, + {Number: 21, MergedAt: time.Date(2026, 2, 10, 12, 0, 0, 0, time.UTC)}, + }, + Releases: releases, + }, + } + + handler := &LinkHandler{} + result, err := handler.Handle(context.Background(), runCtx) + if err != nil { + t.Fatalf("handle returned error: %v", err) + } + if result == nil || result.Directive != DirectiveWait { + t.Fatalf("expected wait directive, got %+v", result) + } + if !strings.Contains(result.WaitMessage, "Ambiguous deployment mapping") { + t.Fatalf("unexpected wait message: %q", result.WaitMessage) + } + if runCtx.LinkedData != nil { + t.Fatalf("expected linked data to remain nil while waiting, got %#v", runCtx.LinkedData) + } +} + +func TestIsVersionTag(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + expected bool + }{ + {name: "v1.2.3", expected: true}, + {name: "release-2026-02", expected: true}, + {name: "hotfix-1", expected: false}, + {name: "build-999", expected: false}, + } + + for _, tc := range cases { + if got := isVersionTag(tc.name); got != tc.expected { + t.Fatalf("isVersionTag(%q) = %t, want %t", tc.name, got, tc.expected) + } + } +} diff --git a/internal/gitimpact/phase_score.go b/internal/gitimpact/phase_score.go new file mode 100644 index 0000000..8db20b4 --- /dev/null +++ b/internal/gitimpact/phase_score.go @@ -0,0 +1,443 @@ +package gitimpact + +import ( + "context" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" +) + +const ( + defaultScoreWindowDays = 7 + metricDateLayout = "2006-01-02" + analyticsSchemaSQL = "SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = current_schema() LIMIT 200" +) + +type scoreQueryFn func(client *VelenClient, sourceKey string, sql string) (*QueryResult, error) + +// ScoreHandler calculates PR impact scores from analytics metrics and deployment windows. +type ScoreHandler struct { + Query scoreQueryFn +} + +// Handle runs Turn 3 scoring and advances to the reporting phase. +func (h *ScoreHandler) Handle(_ context.Context, runCtx *RunContext) (*TurnResult, error) { + if runCtx == nil { + return nil, fmt.Errorf("run context is required") + } + if runCtx.VelenClient == nil { + return nil, fmt.Errorf("velen client is required") + } + if runCtx.LinkedData == nil { + return nil, fmt.Errorf("linked data is required") + } + + sourceKey := "" + if runCtx.Config != nil { + sourceKey = strings.TrimSpace(runCtx.Config.Velen.Sources.Analytics) + } + if sourceKey == "" { + return nil, fmt.Errorf("analytics source key is required") + } + + query := h.Query + if query == nil { + query = func(client *VelenClient, sourceKey string, sql string) (*QueryResult, error) { + return client.Query(sourceKey, sql) + } + } + + schemaResult, err := query(runCtx.VelenClient, sourceKey, analyticsSchemaSQL) + if err != nil { + return nil, fmt.Errorf("score schema discovery: %w", err) + } + tableName, metricCol := selectMetricFromSchema(schemaResult) + + beforeDays, afterDays := resolveScoreWindows(runCtx.Config) + deployments := runCtx.LinkedData.Deployments + impacts := make([]PRImpact, 0, len(deployments)) + for _, deployment := range deployments { + beforeStart := deployment.DeployedAt.AddDate(0, 0, -beforeDays) + afterEnd := deployment.DeployedAt.AddDate(0, 0, afterDays) + overlapCount := countOverlappingDeployments(deployment.DeployedAt, deployments, defaultScoreWindowDays) + confidence := assessConfidence(deployment.DeployedAt, deployments) + + impact := PRImpact{ + PRNumber: deployment.PRNumber, + Score: 0, + Confidence: confidence, + Reasoning: "", + } + + if tableName == "" || metricCol == "" { + impact.Reasoning = fmt.Sprintf( + "No analytics metric discovered from source %q. Assigned neutral score 0.0 with %s confidence (%d overlapping deployments in +/-7 days).", + sourceKey, + confidence, + overlapCount, + ) + impacts = append(impacts, impact) + continue + } + + beforeSQL := buildMetricQuery(tableName, metricCol, sourceKey, beforeStart, deployment.DeployedAt) + afterSQL := buildMetricQuery(tableName, metricCol, sourceKey, deployment.DeployedAt, afterEnd) + + beforeResult, err := query(runCtx.VelenClient, sourceKey, beforeSQL) + if err != nil { + impact.Reasoning = fmt.Sprintf( + "Metric query failed for %s.%s before deployment: %v. Assigned neutral score 0.0 with %s confidence (%d overlapping deployments in +/-7 days).", + tableName, + metricCol, + err, + confidence, + overlapCount, + ) + impacts = append(impacts, impact) + continue + } + + afterResult, err := query(runCtx.VelenClient, sourceKey, afterSQL) + if err != nil { + impact.Reasoning = fmt.Sprintf( + "Metric query failed for %s.%s after deployment: %v. Assigned neutral score 0.0 with %s confidence (%d overlapping deployments in +/-7 days).", + tableName, + metricCol, + err, + confidence, + overlapCount, + ) + impacts = append(impacts, impact) + continue + } + + beforeValue, beforeOk, err := extractAverage(beforeResult) + if err != nil { + return nil, fmt.Errorf("parse before metric for PR #%d: %w", deployment.PRNumber, err) + } + afterValue, afterOk, err := extractAverage(afterResult) + if err != nil { + return nil, fmt.Errorf("parse after metric for PR #%d: %w", deployment.PRNumber, err) + } + + if !beforeOk || !afterOk { + impact.Reasoning = fmt.Sprintf( + "Metric %s.%s had insufficient data between %s and %s. Assigned neutral score 0.0 with %s confidence (%d overlapping deployments in +/-7 days).", + tableName, + metricCol, + beforeStart.UTC().Format(metricDateLayout), + afterEnd.UTC().Format(metricDateLayout), + confidence, + overlapCount, + ) + impacts = append(impacts, impact) + continue + } + + impact.Score = calculateScore(beforeValue, afterValue) + delta := afterValue - beforeValue + impact.Reasoning = fmt.Sprintf( + "Metric %s.%s moved from %.4f to %.4f (delta %+0.4f) between %s and %s. Confidence %s due to %d overlapping deployments in +/-7 days.", + tableName, + metricCol, + beforeValue, + afterValue, + delta, + beforeStart.UTC().Format(metricDateLayout), + afterEnd.UTC().Format(metricDateLayout), + confidence, + overlapCount, + ) + impacts = append(impacts, impact) + } + + var prs []PR + if runCtx.CollectedData != nil { + prs = runCtx.CollectedData.PRs + } + + runCtx.ScoredData = &ScoredData{ + PRImpacts: impacts, + ContributorStats: buildContributorStats(impacts, prs), + } + + return &TurnResult{Directive: DirectiveAdvancePhase}, nil +} + +func resolveScoreWindows(cfg *Config) (beforeDays int, afterDays int) { + beforeDays = defaultScoreWindowDays + afterDays = defaultScoreWindowDays + if cfg == nil { + return beforeDays, afterDays + } + if cfg.Analysis.BeforeWindowDays > 0 { + beforeDays = cfg.Analysis.BeforeWindowDays + } + if cfg.Analysis.AfterWindowDays > 0 { + afterDays = cfg.Analysis.AfterWindowDays + } + return beforeDays, afterDays +} + +func selectMetricFromSchema(schema *QueryResult) (tableName string, metricCol string) { + if schema == nil || len(schema.Rows) == 0 { + return "", "" + } + + firstTable := "" + firstColumn := "" + for _, row := range schema.Rows { + if len(row) < 2 { + continue + } + + table := strings.TrimSpace(asString(row[0])) + column := strings.TrimSpace(asString(row[1])) + if table == "" || column == "" { + continue + } + + if firstTable == "" { + firstTable = table + firstColumn = column + } + + dataType := "" + if len(row) > 2 { + dataType = strings.ToLower(strings.TrimSpace(asString(row[2]))) + } + if isLikelyMetricColumn(column, dataType) { + return table, column + } + } + + return firstTable, firstColumn +} + +func isLikelyMetricColumn(column string, dataType string) bool { + columnLower := strings.ToLower(strings.TrimSpace(column)) + if strings.Contains(columnLower, "date") || strings.Contains(columnLower, "time") { + return false + } + + typeLower := strings.ToLower(strings.TrimSpace(dataType)) + if typeLower == "" { + return true + } + if strings.Contains(typeLower, "int") || + strings.Contains(typeLower, "float") || + strings.Contains(typeLower, "double") || + strings.Contains(typeLower, "numeric") || + strings.Contains(typeLower, "decimal") || + strings.Contains(typeLower, "real") { + return true + } + if strings.Contains(typeLower, "date") || strings.Contains(typeLower, "time") { + return false + } + + return true +} + +func extractAverage(result *QueryResult) (float64, bool, error) { + if result == nil || len(result.Rows) == 0 || len(result.Rows[0]) == 0 { + return 0, false, nil + } + value := result.Rows[0][0] + if value == nil { + return 0, false, nil + } + parsed, err := asFloat(value) + if err != nil { + return 0, false, err + } + return parsed, true, nil +} + +func asFloat(value interface{}) (float64, error) { + switch typed := value.(type) { + case float64: + return typed, nil + case float32: + return float64(typed), nil + case int: + return float64(typed), nil + case int64: + return float64(typed), nil + case int32: + return float64(typed), nil + case json.Number: + parsed, err := typed.Float64() + if err != nil { + return 0, err + } + return parsed, nil + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return 0, fmt.Errorf("empty string") + } + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, err + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T", typed) + } +} + +// calculateScore converts before/after metric movement into a 0-10 score. +func calculateScore(before, after float64) float64 { + if before == 0 && after == 0 { + return 0 + } + baseline := math.Max(math.Abs(before), 1) + relativeChange := math.Abs(after-before) / baseline + score := relativeChange * 10 + if score > 10 { + return 10 + } + if score < 0 { + return 0 + } + return score +} + +// assessConfidence estimates confidence from deployment overlap density. +func assessConfidence(deployedAt time.Time, allDeployments []Deployment) string { + overlapCount := countOverlappingDeployments(deployedAt, allDeployments, defaultScoreWindowDays) + switch { + case overlapCount == 0: + return "high" + case overlapCount <= 2: + return "medium" + default: + return "low" + } +} + +func countOverlappingDeployments(deployedAt time.Time, allDeployments []Deployment, windowDays int) int { + if deployedAt.IsZero() || len(allDeployments) == 0 { + return 0 + } + window := time.Duration(windowDays) * 24 * time.Hour + withinWindow := 0 + for _, deployment := range allDeployments { + if deployment.DeployedAt.IsZero() { + continue + } + delta := deployment.DeployedAt.Sub(deployedAt) + if delta < 0 { + delta = -delta + } + if delta <= window { + withinWindow++ + } + } + if withinWindow > 0 { + withinWindow-- + } + return withinWindow +} + +// buildMetricQuery constructs a simple average metric query for a time window. +func buildMetricQuery(tableName, metricCol, source string, from, to time.Time) string { + tableExpr := quoteIdentifier(tableName) + metricExpr := quoteIdentifier(metricCol) + fromValue := from.UTC().Format(metricDateLayout) + toValue := to.UTC().Format(metricDateLayout) + + sourceComment := "" + trimmedSource := strings.TrimSpace(source) + if trimmedSource != "" { + sanitized := strings.ReplaceAll(trimmedSource, "*/", "") + sourceComment = fmt.Sprintf("/* source:%s */ ", sanitized) + } + + return fmt.Sprintf( + "%sSELECT avg(%s) FROM %s WHERE date BETWEEN '%s' AND '%s'", + sourceComment, + metricExpr, + tableExpr, + fromValue, + toValue, + ) +} + +func quoteIdentifier(name string) string { + parts := strings.Split(strings.TrimSpace(name), ".") + quotedParts := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + escaped := strings.ReplaceAll(trimmed, `"`, `""`) + quotedParts = append(quotedParts, fmt.Sprintf(`"%s"`, escaped)) + } + if len(quotedParts) == 0 { + return `""` + } + return strings.Join(quotedParts, ".") +} + +func buildContributorStats(impacts []PRImpact, prs []PR) []ContributorStats { + if len(impacts) == 0 { + return nil + } + + authorByPR := map[int]string{} + for _, pr := range prs { + author := strings.TrimSpace(pr.Author) + authorByPR[pr.Number] = author + } + + type contributorBucket struct { + count int + total float64 + topPR int + topScore float64 + } + + buckets := map[string]*contributorBucket{} + for _, impact := range impacts { + author := strings.TrimSpace(authorByPR[impact.PRNumber]) + if author == "" { + author = "unknown" + } + bucket, exists := buckets[author] + if !exists { + bucket = &contributorBucket{topPR: impact.PRNumber, topScore: impact.Score} + buckets[author] = bucket + } + bucket.count++ + bucket.total += impact.Score + if impact.Score > bucket.topScore || (impact.Score == bucket.topScore && impact.PRNumber < bucket.topPR) { + bucket.topScore = impact.Score + bucket.topPR = impact.PRNumber + } + } + + authors := make([]string, 0, len(buckets)) + for author := range buckets { + authors = append(authors, author) + } + sort.Strings(authors) + + stats := make([]ContributorStats, 0, len(authors)) + for _, author := range authors { + bucket := buckets[author] + stats = append(stats, ContributorStats{ + Author: author, + PRCount: bucket.count, + AverageScore: bucket.total / float64(bucket.count), + TopPRNumber: bucket.topPR, + }) + } + return stats +} diff --git a/internal/gitimpact/phase_score_test.go b/internal/gitimpact/phase_score_test.go new file mode 100644 index 0000000..641debf --- /dev/null +++ b/internal/gitimpact/phase_score_test.go @@ -0,0 +1,202 @@ +package gitimpact + +import ( + "context" + "math" + "strings" + "testing" + "time" +) + +func TestCalculateScore(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + before float64 + after float64 + want float64 + }{ + {name: "no change", before: 100, after: 100, want: 0}, + {name: "half change", before: 100, after: 150, want: 5}, + {name: "full change", before: 10, after: 0, want: 10}, + {name: "zero baseline", before: 0, after: 0.4, want: 4}, + {name: "cap at ten", before: 1, after: 5, want: 10}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := calculateScore(tc.before, tc.after) + if math.Abs(got-tc.want) > 0.0001 { + t.Fatalf("calculateScore(%v, %v) = %v, want %v", tc.before, tc.after, got, tc.want) + } + }) + } +} + +func TestAssessConfidence(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC) + + high := assessConfidence(base, []Deployment{{PRNumber: 1, DeployedAt: base}}) + if high != "high" { + t.Fatalf("expected high confidence, got %q", high) + } + + medium := assessConfidence(base, []Deployment{ + {PRNumber: 1, DeployedAt: base}, + {PRNumber: 2, DeployedAt: base.Add(24 * time.Hour)}, + {PRNumber: 3, DeployedAt: base.Add(-2 * 24 * time.Hour)}, + {PRNumber: 4, DeployedAt: base.Add(10 * 24 * time.Hour)}, + }) + if medium != "medium" { + t.Fatalf("expected medium confidence, got %q", medium) + } + + low := assessConfidence(base, []Deployment{ + {PRNumber: 1, DeployedAt: base}, + {PRNumber: 2, DeployedAt: base.Add(2 * time.Hour)}, + {PRNumber: 3, DeployedAt: base.Add(-3 * time.Hour)}, + {PRNumber: 4, DeployedAt: base.Add(48 * time.Hour)}, + }) + if low != "low" { + t.Fatalf("expected low confidence, got %q", low) + } +} + +func TestBuildContributorStats(t *testing.T) { + t.Parallel() + + impacts := []PRImpact{ + {PRNumber: 10, Score: 8.0}, + {PRNumber: 11, Score: 4.0}, + {PRNumber: 12, Score: 9.0}, + } + prs := []PR{ + {Number: 10, Author: "alice"}, + {Number: 11, Author: "alice"}, + {Number: 12, Author: "bob"}, + } + + stats := buildContributorStats(impacts, prs) + if len(stats) != 2 { + t.Fatalf("expected 2 contributor stats rows, got %d", len(stats)) + } + + byAuthor := map[string]ContributorStats{} + for _, stat := range stats { + byAuthor[stat.Author] = stat + } + + alice, ok := byAuthor["alice"] + if !ok { + t.Fatal("missing alice contributor stats") + } + if alice.PRCount != 2 { + t.Fatalf("alice PR count = %d, want 2", alice.PRCount) + } + if math.Abs(alice.AverageScore-6.0) > 0.0001 { + t.Fatalf("alice average score = %v, want 6", alice.AverageScore) + } + if alice.TopPRNumber != 10 { + t.Fatalf("alice top PR = %d, want 10", alice.TopPRNumber) + } + + bob, ok := byAuthor["bob"] + if !ok { + t.Fatal("missing bob contributor stats") + } + if bob.PRCount != 1 { + t.Fatalf("bob PR count = %d, want 1", bob.PRCount) + } + if math.Abs(bob.AverageScore-9.0) > 0.0001 { + t.Fatalf("bob average score = %v, want 9", bob.AverageScore) + } + if bob.TopPRNumber != 12 { + t.Fatalf("bob top PR = %d, want 12", bob.TopPRNumber) + } +} + +func TestScoreHandlerHandle_EmptyAnalyticsSchemaGracefulDegradation(t *testing.T) { + t.Parallel() + + schemaCalls := 0 + metricCalls := 0 + handler := &ScoreHandler{ + Query: func(_ *VelenClient, sourceKey string, sql string) (*QueryResult, error) { + if sourceKey != "analytics-main" { + t.Fatalf("unexpected source key %q", sourceKey) + } + if sql == analyticsSchemaSQL { + schemaCalls++ + return &QueryResult{Rows: [][]interface{}{}}, nil + } + metricCalls++ + return &QueryResult{}, nil + }, + } + + deployedAt := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC) + runCtx := &RunContext{ + VelenClient: &VelenClient{}, + Config: &Config{ + Velen: VelenConfig{ + Sources: VelenSources{Analytics: "analytics-main"}, + }, + }, + CollectedData: &CollectedData{ + PRs: []PR{{Number: 101, Author: "alice"}}, + }, + LinkedData: &LinkedData{ + Deployments: []Deployment{{PRNumber: 101, DeployedAt: deployedAt}}, + }, + } + + result, err := handler.Handle(context.Background(), runCtx) + if err != nil { + t.Fatalf("handle returned error: %v", err) + } + if result == nil || result.Directive != DirectiveAdvancePhase { + t.Fatalf("expected advance directive, got %+v", result) + } + if schemaCalls != 1 { + t.Fatalf("expected one schema query, got %d", schemaCalls) + } + if metricCalls != 0 { + t.Fatalf("expected no metric queries for empty schema, got %d", metricCalls) + } + + if runCtx.ScoredData == nil { + t.Fatal("expected scored data to be populated") + } + if len(runCtx.ScoredData.PRImpacts) != 1 { + t.Fatalf("expected one PR impact, got %d", len(runCtx.ScoredData.PRImpacts)) + } + impact := runCtx.ScoredData.PRImpacts[0] + if impact.PRNumber != 101 { + t.Fatalf("unexpected PR number %d", impact.PRNumber) + } + if impact.Score != 0 { + t.Fatalf("expected neutral score 0, got %v", impact.Score) + } + if impact.Confidence != "high" { + t.Fatalf("expected high confidence for isolated deployment, got %q", impact.Confidence) + } + if !strings.Contains(impact.Reasoning, "No analytics metric discovered") { + t.Fatalf("unexpected reasoning: %q", impact.Reasoning) + } + + if len(runCtx.ScoredData.ContributorStats) != 1 { + t.Fatalf("expected one contributor stat, got %d", len(runCtx.ScoredData.ContributorStats)) + } + stat := runCtx.ScoredData.ContributorStats[0] + if stat.Author != "alice" { + t.Fatalf("unexpected contributor author %q", stat.Author) + } + if stat.PRCount != 1 || stat.TopPRNumber != 101 || stat.AverageScore != 0 { + t.Fatalf("unexpected contributor stats: %#v", stat) + } +} diff --git a/internal/gitimpact/phase_source_check.go b/internal/gitimpact/phase_source_check.go new file mode 100644 index 0000000..e37edc3 --- /dev/null +++ b/internal/gitimpact/phase_source_check.go @@ -0,0 +1,69 @@ +package gitimpact + +import ( + "context" + "fmt" + "strings" +) + +type checkSourcesFn func(ctx context.Context, client *VelenClient, cfg *Config) (*SourceCheckResult, error) + +// SourceCheckHandler verifies required Velen sources before collection starts. +type SourceCheckHandler struct { + CheckSources checkSourcesFn +} + +// Handle validates source readiness and can pause for user confirmation when requirements are not met. +func (h *SourceCheckHandler) Handle(ctx context.Context, runCtx *RunContext) (*TurnResult, error) { + if runCtx == nil { + return nil, fmt.Errorf("run context is required") + } + + waitResponse := "" + if runCtx.AnalysisCtx != nil { + waitResponse = strings.ToLower(strings.TrimSpace(runCtx.AnalysisCtx.LastWaitResponse)) + } + if waitResponse != "" { + switch waitResponse { + case "y": + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + case "n": + return nil, fmt.Errorf("source check aborted by user") + default: + return nil, fmt.Errorf("invalid wait response %q: expected y or n", waitResponse) + } + } + + checker := h.CheckSources + if checker == nil { + checker = CheckSources + } + + result, err := checker(ctx, runCtx.VelenClient, runCtx.Config) + if err != nil { + return nil, err + } + if result != nil && result.GitHubOK && result.AnalyticsOK { + return &TurnResult{Directive: DirectiveAdvancePhase}, nil + } + + return &TurnResult{ + Directive: DirectiveWait, + WaitMessage: sourceCheckWaitMessage(result), + }, nil +} + +func sourceCheckWaitMessage(result *SourceCheckResult) string { + if result == nil { + return "Required sources could not be verified. Continue anyway? (y/n)" + } + + if len(result.Errors) == 0 { + return "Required sources are not QUERY-capable. Continue anyway? (y/n)" + } + + return fmt.Sprintf( + "Required Velen sources are not ready: %s. Continue anyway? (y/n)", + strings.Join(result.Errors, "; "), + ) +} diff --git a/internal/gitimpact/phase_source_check_test.go b/internal/gitimpact/phase_source_check_test.go new file mode 100644 index 0000000..b011814 --- /dev/null +++ b/internal/gitimpact/phase_source_check_test.go @@ -0,0 +1,120 @@ +package gitimpact + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestSourceCheckHandlerHandle_AdvancesWhenSourcesReady(t *testing.T) { + t.Parallel() + + handler := &SourceCheckHandler{ + CheckSources: func(context.Context, *VelenClient, *Config) (*SourceCheckResult, error) { + return &SourceCheckResult{GitHubOK: true, AnalyticsOK: true}, nil + }, + } + + result, err := handler.Handle(context.Background(), &RunContext{ + VelenClient: &VelenClient{}, + Config: &Config{}, + }) + if err != nil { + t.Fatalf("handle returned error: %v", err) + } + if result == nil || result.Directive != DirectiveAdvancePhase { + t.Fatalf("expected advance directive, got %+v", result) + } +} + +func TestSourceCheckHandlerHandle_WaitsWhenSourcesNotReady(t *testing.T) { + t.Parallel() + + handler := &SourceCheckHandler{ + CheckSources: func(context.Context, *VelenClient, *Config) (*SourceCheckResult, error) { + return &SourceCheckResult{ + GitHubOK: false, + AnalyticsOK: true, + Errors: []string{"github source not found"}, + }, nil + }, + } + + result, err := handler.Handle(context.Background(), &RunContext{ + VelenClient: &VelenClient{}, + Config: &Config{}, + }) + if err != nil { + t.Fatalf("handle returned error: %v", err) + } + if result == nil || result.Directive != DirectiveWait { + t.Fatalf("expected wait directive, got %+v", result) + } + if !strings.Contains(result.WaitMessage, "github source not found") { + t.Fatalf("expected wait message to contain source error, got %q", result.WaitMessage) + } + if !strings.Contains(result.WaitMessage, "(y/n)") { + t.Fatalf("expected wait message to ask for y/n confirmation, got %q", result.WaitMessage) + } +} + +func TestSourceCheckHandlerHandle_UsesWaitResponseYToAdvance(t *testing.T) { + t.Parallel() + + called := false + handler := &SourceCheckHandler{ + CheckSources: func(context.Context, *VelenClient, *Config) (*SourceCheckResult, error) { + called = true + return &SourceCheckResult{}, nil + }, + } + + result, err := handler.Handle(context.Background(), &RunContext{ + AnalysisCtx: &AnalysisContext{LastWaitResponse: " y "}, + }) + if err != nil { + t.Fatalf("handle returned error: %v", err) + } + if result == nil || result.Directive != DirectiveAdvancePhase { + t.Fatalf("expected advance directive, got %+v", result) + } + if called { + t.Fatalf("did not expect CheckSources to run when wait response already exists") + } +} + +func TestSourceCheckHandlerHandle_UsesWaitResponseNToError(t *testing.T) { + t.Parallel() + + handler := &SourceCheckHandler{} + _, err := handler.Handle(context.Background(), &RunContext{ + AnalysisCtx: &AnalysisContext{LastWaitResponse: "n"}, + }) + if err == nil { + t.Fatal("expected error for wait response n") + } + if !strings.Contains(err.Error(), "aborted") { + t.Fatalf("expected abort error, got %v", err) + } +} + +func TestSourceCheckHandlerHandle_PropagatesCheckSourcesError(t *testing.T) { + t.Parallel() + + handler := &SourceCheckHandler{ + CheckSources: func(context.Context, *VelenClient, *Config) (*SourceCheckResult, error) { + return nil, errors.New("boom") + }, + } + _, err := handler.Handle(context.Background(), &RunContext{ + VelenClient: &VelenClient{}, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected wrapped check-sources error, got %v", err) + } +} + diff --git a/internal/gitimpact/tui_bridge.go b/internal/gitimpact/tui_bridge.go new file mode 100644 index 0000000..72b0312 --- /dev/null +++ b/internal/gitimpact/tui_bridge.go @@ -0,0 +1,78 @@ +package gitimpact + +import tea "github.com/charmbracelet/bubbletea" + +// TurnStartedMsg notifies the TUI that a turn has started. +type TurnStartedMsg struct { + Phase Phase + Iteration int +} + +// PhaseAdvancedMsg notifies the TUI that the run moved to another phase. +type PhaseAdvancedMsg struct { + From Phase + To Phase +} + +// WaitEnteredMsg notifies the TUI that execution is paused for user input. +type WaitEnteredMsg struct { + Message string +} + +// WaitResolvedMsg notifies the TUI that a paused wait state was resolved. +type WaitResolvedMsg struct { + Response string +} + +// RunCompletedMsg notifies the TUI that the run finished successfully. +type RunCompletedMsg struct { + Result *AnalysisResult +} + +// RunExhaustedMsg notifies the TUI that the run ended due to exhaustion/error. +type RunExhaustedMsg struct { + Err error +} + +// TUIObserver bridges engine observer callbacks into Bubble Tea messages. +type TUIObserver struct { + program *tea.Program +} + +var _ Observer = (*TUIObserver)(nil) + +// NewTUIObserver constructs an Observer that forwards callbacks to Bubble Tea. +func NewTUIObserver(program *tea.Program) *TUIObserver { + return &TUIObserver{program: program} +} + +func (o *TUIObserver) OnTurnStarted(phase Phase, iteration int) { + o.send(TurnStartedMsg{Phase: phase, Iteration: iteration}) +} + +func (o *TUIObserver) OnPhaseAdvanced(from, to Phase) { + o.send(PhaseAdvancedMsg{From: from, To: to}) +} + +func (o *TUIObserver) OnWaitEntered(message string) { + o.send(WaitEnteredMsg{Message: message}) +} + +func (o *TUIObserver) OnWaitResolved(response string) { + o.send(WaitResolvedMsg{Response: response}) +} + +func (o *TUIObserver) OnRunCompleted(result *AnalysisResult) { + o.send(RunCompletedMsg{Result: result}) +} + +func (o *TUIObserver) OnRunExhausted(err error) { + o.send(RunExhaustedMsg{Err: err}) +} + +func (o *TUIObserver) send(msg tea.Msg) { + if o == nil || o.program == nil { + return + } + o.program.Send(msg) +} diff --git a/internal/gitimpact/tui_bridge_test.go b/internal/gitimpact/tui_bridge_test.go new file mode 100644 index 0000000..e7f8a72 --- /dev/null +++ b/internal/gitimpact/tui_bridge_test.go @@ -0,0 +1,164 @@ +package gitimpact + +import ( + "io" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +type bridgeCaptureModel struct { + ready chan struct{} + expected int + msgs []tea.Msg +} + +func (m *bridgeCaptureModel) Init() tea.Cmd { + close(m.ready) + return nil +} + +func (m *bridgeCaptureModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !isBridgeMsg(msg) { + return m, nil + } + + m.msgs = append(m.msgs, msg) + if len(m.msgs) >= m.expected { + return m, tea.Quit + } + return m, nil +} + +func (m *bridgeCaptureModel) View() string { + return "" +} + +func isBridgeMsg(msg tea.Msg) bool { + switch msg.(type) { + case TurnStartedMsg, PhaseAdvancedMsg, WaitEnteredMsg, WaitResolvedMsg, RunCompletedMsg, RunExhaustedMsg: + return true + default: + return false + } +} + +func TestTUIObserverSendsBubbleTeaMessages(t *testing.T) { + model := &bridgeCaptureModel{ + ready: make(chan struct{}), + expected: 6, + } + + program := tea.NewProgram( + model, + tea.WithInput(nil), + tea.WithOutput(io.Discard), + tea.WithoutRenderer(), + tea.WithoutSignalHandler(), + ) + + type runResult struct { + model tea.Model + err error + } + done := make(chan runResult, 1) + go func() { + finalModel, err := program.Run() + done <- runResult{model: finalModel, err: err} + }() + + select { + case <-model.ready: + case <-time.After(2 * time.Second): + t.Fatal("bubble tea program did not initialize in time") + } + + observer := NewTUIObserver(program) + result := &AnalysisResult{} + exhaustedErr := &testErr{msg: "max iterations reached"} + + observer.OnTurnStarted(PhaseCollect, 2) + observer.OnPhaseAdvanced(PhaseCollect, PhaseLink) + observer.OnWaitEntered("Need deployment mapping confirmation") + observer.OnWaitResolved("Use release tags") + observer.OnRunCompleted(result) + observer.OnRunExhausted(exhaustedErr) + + var run runResult + select { + case run = <-done: + case <-time.After(2 * time.Second): + program.Kill() + t.Fatal("bubble tea program did not stop in time") + } + + if run.err != nil { + t.Fatalf("program run failed: %v", run.err) + } + + finalModel, ok := run.model.(*bridgeCaptureModel) + if !ok { + t.Fatalf("expected *bridgeCaptureModel, got %T", run.model) + } + + if len(finalModel.msgs) != 6 { + t.Fatalf("expected 6 bridge messages, got %d", len(finalModel.msgs)) + } + + turnStarted, ok := finalModel.msgs[0].(TurnStartedMsg) + if !ok { + t.Fatalf("msg 0 type = %T, want TurnStartedMsg", finalModel.msgs[0]) + } + if turnStarted.Phase != PhaseCollect || turnStarted.Iteration != 2 { + t.Fatalf("unexpected TurnStartedMsg payload: %+v", turnStarted) + } + + phaseAdvanced, ok := finalModel.msgs[1].(PhaseAdvancedMsg) + if !ok { + t.Fatalf("msg 1 type = %T, want PhaseAdvancedMsg", finalModel.msgs[1]) + } + if phaseAdvanced.From != PhaseCollect || phaseAdvanced.To != PhaseLink { + t.Fatalf("unexpected PhaseAdvancedMsg payload: %+v", phaseAdvanced) + } + + waitEntered, ok := finalModel.msgs[2].(WaitEnteredMsg) + if !ok { + t.Fatalf("msg 2 type = %T, want WaitEnteredMsg", finalModel.msgs[2]) + } + if waitEntered.Message != "Need deployment mapping confirmation" { + t.Fatalf("unexpected WaitEnteredMsg payload: %+v", waitEntered) + } + + waitResolved, ok := finalModel.msgs[3].(WaitResolvedMsg) + if !ok { + t.Fatalf("msg 3 type = %T, want WaitResolvedMsg", finalModel.msgs[3]) + } + if waitResolved.Response != "Use release tags" { + t.Fatalf("unexpected WaitResolvedMsg payload: %+v", waitResolved) + } + + runCompleted, ok := finalModel.msgs[4].(RunCompletedMsg) + if !ok { + t.Fatalf("msg 4 type = %T, want RunCompletedMsg", finalModel.msgs[4]) + } + if runCompleted.Result != result { + t.Fatalf("unexpected RunCompletedMsg result pointer: got %p want %p", runCompleted.Result, result) + } + + runExhausted, ok := finalModel.msgs[5].(RunExhaustedMsg) + if !ok { + t.Fatalf("msg 5 type = %T, want RunExhaustedMsg", finalModel.msgs[5]) + } + if runExhausted.Err != exhaustedErr { + t.Fatalf("unexpected RunExhaustedMsg err pointer: got %v want %v", runExhausted.Err, exhaustedErr) + } +} + +type testErr struct { + msg string +} + +func (e *testErr) Error() string { + return e.msg +} diff --git a/internal/gitimpact/tui_model.go b/internal/gitimpact/tui_model.go new file mode 100644 index 0000000..b2714b0 --- /dev/null +++ b/internal/gitimpact/tui_model.go @@ -0,0 +1,260 @@ +package gitimpact + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + phaseStatusWaiting = "waiting" + phaseStatusRunning = "running" + phaseStatusDone = "done" +) + +// PhaseStatus tracks progress status for each analysis phase. +type PhaseStatus struct { + Phase Phase + DisplayName string + Status string + Detail string +} + +// AnalysisModel is the Bubble Tea model for analysis progress. +type AnalysisModel struct { + phases []PhaseStatus + currentPhase Phase + iteration int + totalPhases int + isWaiting bool + waitMessage string + spinner spinner.Model + done bool + result *AnalysisResult + err error +} + +var _ tea.Model = (*AnalysisModel)(nil) + +// DefaultAnalysisPhases returns default status rows for the progress view. +func DefaultAnalysisPhases() []PhaseStatus { + return []PhaseStatus{ + {Phase: PhaseSourceCheck, DisplayName: "Sources", Status: phaseStatusWaiting}, + {Phase: PhaseCollect, DisplayName: "Collect", Status: phaseStatusWaiting}, + {Phase: PhaseLink, DisplayName: "Link", Status: phaseStatusWaiting}, + {Phase: PhaseScore, DisplayName: "Score", Status: phaseStatusWaiting}, + {Phase: PhaseReport, DisplayName: "Report", Status: phaseStatusWaiting}, + } +} + +// NewAnalysisModel builds progress state from the supplied phase rows. +func NewAnalysisModel(phases []PhaseStatus) AnalysisModel { + if len(phases) == 0 { + phases = DefaultAnalysisPhases() + } + + phaseCopy := make([]PhaseStatus, len(phases)) + copy(phaseCopy, phases) + + spin := spinner.New() + spin.Spinner = spinner.Dot + + return AnalysisModel{ + phases: phaseCopy, + totalPhases: len(phaseCopy), + spinner: spin, + } +} + +// Init implements tea.Model. +func (m *AnalysisModel) Init() tea.Cmd { + return m.spinner.Tick +} + +// Update implements tea.Model. +func (m *AnalysisModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch typed := msg.(type) { + case TurnStartedMsg: + m.currentPhase = typed.Phase + m.iteration = typed.Iteration + m.isWaiting = false + m.waitMessage = "" + m.err = nil + m.setRunning(typed.Phase) + case PhaseAdvancedMsg: + m.currentPhase = typed.To + m.setStatus(typed.From, phaseStatusDone) + m.setRunning(typed.To) + case WaitEnteredMsg: + m.isWaiting = true + m.waitMessage = typed.Message + case WaitResolvedMsg: + m.isWaiting = false + m.waitMessage = "" + case RunCompletedMsg: + m.isWaiting = false + m.waitMessage = "" + m.currentPhase = PhaseReport + m.iteration = m.totalPhases + m.setAllDone() + m.done = true + m.result = typed.Result + m.err = nil + return m, tea.Quit + case RunExhaustedMsg: + m.isWaiting = false + m.waitMessage = "" + m.err = typed.Err + return m, tea.Quit + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(typed) + return m, cmd + } + return m, nil +} + +// View implements tea.Model. +func (m *AnalysisModel) View() string { + var b strings.Builder + + b.WriteString("Git Impact Analyzer\n") + b.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + + b.WriteString(fmt.Sprintf("[%s] Turn %d/%d - %s\n\n", m.progressBar(18), m.iteration, maxInt(m.totalPhases, 1), m.currentPhaseDisplayName())) + + labelWidth := m.maxDisplayNameWidth() + for _, phase := range m.phases { + prefix := " " + detail := strings.TrimSpace(phase.Detail) + switch phase.Status { + case phaseStatusDone: + prefix = "✓" + if detail == "" { + detail = "Done" + } + case phaseStatusRunning: + prefix = "→" + if detail == "" { + detail = fmt.Sprintf("%s Running", m.spinner.View()) + } + default: + if detail == "" { + detail = "Waiting" + } + } + b.WriteString(fmt.Sprintf("%s %-*s %s\n", prefix, labelWidth, phase.DisplayName, detail)) + } + + if m.isWaiting { + b.WriteString("\n") + b.WriteString(strings.TrimSpace(m.waitMessage)) + b.WriteString("\n") + } + if m.err != nil { + b.WriteString("\n") + b.WriteString(fmt.Sprintf("Error: %v\n", m.err)) + } + + return strings.TrimRight(b.String(), "\n") +} + +func (m *AnalysisModel) setStatus(target Phase, status string) { + for i := range m.phases { + if m.phases[i].Phase == target { + m.phases[i].Status = status + return + } + } +} + +func (m *AnalysisModel) setRunning(target Phase) { + for i := range m.phases { + switch { + case m.phases[i].Phase == target: + m.phases[i].Status = phaseStatusRunning + case m.phases[i].Status != phaseStatusDone: + m.phases[i].Status = phaseStatusWaiting + } + } +} + +func (m *AnalysisModel) setAllDone() { + for i := range m.phases { + m.phases[i].Status = phaseStatusDone + } +} + +func (m *AnalysisModel) maxDisplayNameWidth() int { + width := 0 + for _, phase := range m.phases { + if len(phase.DisplayName) > width { + width = len(phase.DisplayName) + } + } + if width == 0 { + return len("Phase") + } + return width +} + +func (m *AnalysisModel) progressBar(width int) string { + if width <= 0 { + width = 10 + } + total := maxInt(m.totalPhases, 1) + completed := m.completedPhases() + filled := (completed * width) / total + if filled < 0 { + filled = 0 + } + if filled > width { + filled = width + } + return strings.Repeat("■", filled) + strings.Repeat("░", width-filled) +} + +func (m *AnalysisModel) completedPhases() int { + if m.done { + return maxInt(m.totalPhases, 1) + } + + completed := 0 + hasRunning := false + for _, phase := range m.phases { + switch phase.Status { + case phaseStatusDone: + completed++ + case phaseStatusRunning: + hasRunning = true + } + } + if hasRunning { + completed++ + } + return completed +} + +func (m *AnalysisModel) currentPhaseDisplayName() string { + if m.currentPhase == "" { + return "Starting" + } + for _, phase := range m.phases { + if phase.Phase == m.currentPhase { + if strings.TrimSpace(phase.DisplayName) != "" { + return phase.DisplayName + } + break + } + } + return string(m.currentPhase) +} + +func maxInt(a int, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/gitimpact/tui_model_test.go b/internal/gitimpact/tui_model_test.go new file mode 100644 index 0000000..2f61473 --- /dev/null +++ b/internal/gitimpact/tui_model_test.go @@ -0,0 +1,152 @@ +package gitimpact + +import ( + "errors" + "testing" + + "github.com/charmbracelet/bubbles/spinner" +) + +func TestAnalysisModelUpdateTurnStarted(t *testing.T) { + t.Parallel() + + model := NewAnalysisModel(DefaultAnalysisPhases()) + modelPtr := &model + + updated, cmd := modelPtr.Update(TurnStartedMsg{Phase: PhaseCollect, Iteration: 2}) + if cmd != nil { + t.Fatalf("expected nil cmd for TurnStartedMsg, got %#v", cmd) + } + got, ok := updated.(*AnalysisModel) + if !ok { + t.Fatalf("expected *AnalysisModel, got %T", updated) + } + + if got.currentPhase != PhaseCollect { + t.Fatalf("current phase = %q, want %q", got.currentPhase, PhaseCollect) + } + if got.iteration != 2 { + t.Fatalf("iteration = %d, want 2", got.iteration) + } + if got.isWaiting { + t.Fatal("isWaiting = true, want false") + } + + assertPhaseStatus(t, got, PhaseCollect, phaseStatusRunning) + assertPhaseStatus(t, got, PhaseScore, phaseStatusWaiting) +} + +func TestAnalysisModelUpdatePhaseAdvanced(t *testing.T) { + t.Parallel() + + model := NewAnalysisModel(DefaultAnalysisPhases()) + modelPtr := &model + modelPtr.setRunning(PhaseCollect) + + updated, cmd := modelPtr.Update(PhaseAdvancedMsg{From: PhaseCollect, To: PhaseLink}) + if cmd != nil { + t.Fatalf("expected nil cmd for PhaseAdvancedMsg, got %#v", cmd) + } + got := updated.(*AnalysisModel) + + assertPhaseStatus(t, got, PhaseCollect, phaseStatusDone) + assertPhaseStatus(t, got, PhaseLink, phaseStatusRunning) +} + +func TestAnalysisModelUpdateWaitState(t *testing.T) { + t.Parallel() + + model := NewAnalysisModel(DefaultAnalysisPhases()) + modelPtr := &model + + updated, _ := modelPtr.Update(WaitEnteredMsg{Message: "Need confirmation"}) + got := updated.(*AnalysisModel) + if !got.isWaiting { + t.Fatal("isWaiting = false, want true") + } + if got.waitMessage != "Need confirmation" { + t.Fatalf("waitMessage = %q, want %q", got.waitMessage, "Need confirmation") + } + + updated, _ = got.Update(WaitResolvedMsg{Response: "y"}) + got = updated.(*AnalysisModel) + if got.isWaiting { + t.Fatal("isWaiting = true, want false") + } + if got.waitMessage != "" { + t.Fatalf("waitMessage = %q, want empty", got.waitMessage) + } +} + +func TestAnalysisModelUpdateRunCompleted(t *testing.T) { + t.Parallel() + + model := NewAnalysisModel(DefaultAnalysisPhases()) + modelPtr := &model + result := &AnalysisResult{Output: "done"} + + updated, cmd := modelPtr.Update(RunCompletedMsg{Result: result}) + if cmd == nil { + t.Fatal("expected tea quit cmd for RunCompletedMsg, got nil") + } + got := updated.(*AnalysisModel) + + if !got.done { + t.Fatal("done = false, want true") + } + if got.result != result { + t.Fatalf("result pointer mismatch: got %p want %p", got.result, result) + } + for _, phase := range got.phases { + if phase.Status != phaseStatusDone { + t.Fatalf("phase %q status = %q, want %q", phase.Phase, phase.Status, phaseStatusDone) + } + } +} + +func TestAnalysisModelUpdateRunExhausted(t *testing.T) { + t.Parallel() + + model := NewAnalysisModel(DefaultAnalysisPhases()) + modelPtr := &model + expectedErr := errors.New("run exhausted") + + updated, cmd := modelPtr.Update(RunExhaustedMsg{Err: expectedErr}) + if cmd == nil { + t.Fatal("expected tea quit cmd for RunExhaustedMsg, got nil") + } + got := updated.(*AnalysisModel) + if !errors.Is(got.err, expectedErr) { + t.Fatalf("err = %v, want %v", got.err, expectedErr) + } +} + +func TestAnalysisModelUpdateSpinnerTick(t *testing.T) { + t.Parallel() + + model := NewAnalysisModel(DefaultAnalysisPhases()) + modelPtr := &model + + updated, cmd := modelPtr.Update(spinner.TickMsg{}) + if cmd == nil { + t.Fatal("expected non-nil cmd for spinner.TickMsg, got nil") + } + if _, ok := updated.(*AnalysisModel); !ok { + t.Fatalf("expected *AnalysisModel, got %T", updated) + } +} + +func assertPhaseStatus(t *testing.T, model *AnalysisModel, phase Phase, wantStatus string) { + t.Helper() + + for _, item := range model.phases { + if item.Phase != phase { + continue + } + if item.Status != wantStatus { + t.Fatalf("phase %q status = %q, want %q", phase, item.Status, wantStatus) + } + return + } + t.Fatalf("phase %q not found", phase) +} diff --git a/internal/gitimpact/types.go b/internal/gitimpact/types.go new file mode 100644 index 0000000..4acf921 --- /dev/null +++ b/internal/gitimpact/types.go @@ -0,0 +1,163 @@ +package gitimpact + +import ( + "strings" + "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 + LastWaitResponse 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 { + // Engine metadata + Output string + Phase Phase + Iteration int + GeneratedAt time.Time + PRs []PR + Deployments []Deployment + FeatureGroups []FeatureGroup + Contributors []ContributorStats + PRImpacts []PRImpact +} + +// Velen types + +type WhoAmIResult struct { + Email string `json:"email"` + Org string `json:"org"` +} + +type OrgResult struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +type Source struct { + Key string `json:"key"` + Name string `json:"name"` + ProviderType string `json:"provider_type"` + Capabilities []string `json:"capabilities"` +} + +func (s Source) SupportsQuery() bool { + for _, capability := range s.Capabilities { + if strings.EqualFold(strings.TrimSpace(capability), "QUERY") { + return true + } + } + return false +} + +type QueryResult struct { + Columns []string `json:"columns"` + Rows [][]interface{} `json:"rows"` + RowCount int `json:"row_count"` +} + +type VelenError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *VelenError) Error() string { + if e == nil { + return "" + } + if strings.TrimSpace(e.Code) == "" { + return e.Message + } + if strings.TrimSpace(e.Message) == "" { + return e.Code + } + return e.Code + ": " + e.Message +} + +// Release represents a GitHub release. +type Release struct { + Name string + TagName string + PublishedAt time.Time +} + +// AmbiguousDeployment represents a deployment that could not be unambiguously inferred. +type AmbiguousDeployment struct { + PRNumber int + Options []string + Reason string +} + +// EngineRunMeta holds engine-level metadata about a run (used by tests and engine internals). +// These fields are populated by the engine and not part of the analysis content. +// We embed these directly in AnalysisResult for convenience. diff --git a/internal/gitimpact/velen.go b/internal/gitimpact/velen.go new file mode 100644 index 0000000..d263117 --- /dev/null +++ b/internal/gitimpact/velen.go @@ -0,0 +1,217 @@ +package gitimpact + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +const defaultVelenTimeout = 30 * time.Second + +type cmdFactory func(ctx context.Context, name string, args ...string) *exec.Cmd + +type VelenClient struct { + binary string + timeout time.Duration + cmdFactory cmdFactory +} + +func NewVelenClient(timeout time.Duration) *VelenClient { + if timeout <= 0 { + timeout = defaultVelenTimeout + } + return &VelenClient{ + binary: "velen", + timeout: timeout, + cmdFactory: exec.CommandContext, + } +} + +func (c *VelenClient) WhoAmI() (*WhoAmIResult, error) { + result := &WhoAmIResult{} + if err := c.runAndDecode(result, "auth", "whoami"); err != nil { + return nil, err + } + return result, nil +} + +func (c *VelenClient) CurrentOrg() (*OrgResult, error) { + result := &OrgResult{} + if err := c.runAndDecode(result, "org", "current"); err != nil { + return nil, err + } + return result, nil +} + +func (c *VelenClient) ListSources() ([]Source, error) { + payload, err := c.run("source", "list") + if err != nil { + return nil, err + } + + var direct []Source + if err := json.Unmarshal(payload, &direct); err == nil { + return direct, nil + } + + var envelope struct { + Sources []Source `json:"sources"` + } + if err := json.Unmarshal(payload, &envelope); err == nil { + return envelope.Sources, nil + } + + return nil, fmt.Errorf("decode velen \"source list\" response") +} + +func (c *VelenClient) ShowSource(key string) (*Source, error) { + payload, err := c.run("source", "show", key) + if err != nil { + return nil, err + } + + result := &Source{} + if err := json.Unmarshal(payload, result); err == nil { + return result, nil + } + + var envelope struct { + Source Source `json:"source"` + } + if err := json.Unmarshal(payload, &envelope); err == nil { + return &envelope.Source, nil + } + + return nil, fmt.Errorf("decode velen \"source show\" response") +} + +func (c *VelenClient) Query(sourceKey, sql string) (*QueryResult, error) { + result := &QueryResult{} + if err := c.runAndDecode(result, "query", "--source", sourceKey, "--sql", sql); err != nil { + return nil, err + } + return result, nil +} + +func (c *VelenClient) runAndDecode(target any, args ...string) error { + payload, err := c.run(args...) + if err != nil { + return err + } + if err := json.Unmarshal(payload, target); err != nil { + return fmt.Errorf("decode velen response: %w", err) + } + return nil +} + +func (c *VelenClient) run(args ...string) ([]byte, error) { + if c == nil { + return nil, fmt.Errorf("velen client is nil") + } + + timeout := c.timeout + if timeout <= 0 { + timeout = defaultVelenTimeout + } + binary := strings.TrimSpace(c.binary) + if binary == "" { + binary = "velen" + } + runner := c.cmdFactory + if runner == nil { + runner = exec.CommandContext + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := runner(ctx, binary, args...) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, &VelenError{ + Code: "timeout", + Message: fmt.Sprintf("velen command timed out after %s", timeout), + } + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil, buildVelenError(exitErr.ExitCode(), stdout.String(), stderr.String()) + } + + return nil, fmt.Errorf("run velen command: %w", err) + } + + return bytes.TrimSpace(stdout.Bytes()), nil +} + +func buildVelenError(exitCode int, stdout string, stderr string) *VelenError { + if parsed := parseVelenError(stderr); parsed != nil { + if strings.TrimSpace(parsed.Code) == "" { + parsed.Code = fmt.Sprintf("exit_%d", exitCode) + } + return parsed + } + if parsed := parseVelenError(stdout); parsed != nil { + if strings.TrimSpace(parsed.Code) == "" { + parsed.Code = fmt.Sprintf("exit_%d", exitCode) + } + return parsed + } + + trimmedStderr := strings.TrimSpace(stderr) + trimmedStdout := strings.TrimSpace(stdout) + parts := []string{} + if trimmedStderr != "" { + parts = append(parts, trimmedStderr) + } + if trimmedStdout != "" { + parts = append(parts, "stdout: "+trimmedStdout) + } + message := strings.Join(parts, " | ") + if message == "" { + message = fmt.Sprintf("velen command failed with exit code %d", exitCode) + } + + return &VelenError{ + Code: fmt.Sprintf("exit_%d", exitCode), + Message: message, + } +} + +func parseVelenError(payload string) *VelenError { + trimmed := strings.TrimSpace(payload) + if trimmed == "" { + return nil + } + + var direct VelenError + if err := json.Unmarshal([]byte(trimmed), &direct); err == nil { + if strings.TrimSpace(direct.Code) != "" || strings.TrimSpace(direct.Message) != "" { + return &direct + } + } + + var wrapped struct { + Error *VelenError `json:"error"` + } + if err := json.Unmarshal([]byte(trimmed), &wrapped); err == nil { + if wrapped.Error != nil { + if strings.TrimSpace(wrapped.Error.Code) != "" || strings.TrimSpace(wrapped.Error.Message) != "" { + return wrapped.Error + } + } + } + + return nil +} diff --git a/internal/gitimpact/velen_test.go b/internal/gitimpact/velen_test.go new file mode 100644 index 0000000..dbc71ce --- /dev/null +++ b/internal/gitimpact/velen_test.go @@ -0,0 +1,281 @@ +package gitimpact + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestNewVelenClientDefaultTimeout(t *testing.T) { + client := NewVelenClient(0) + if client.timeout != defaultVelenTimeout { + t.Fatalf("expected default timeout %s, got %s", defaultVelenTimeout, client.timeout) + } + if client.binary != "velen" { + t.Fatalf("expected binary velen, got %q", client.binary) + } +} + +func TestSourceSupportsQuery(t *testing.T) { + source := Source{Capabilities: []string{"SYNC", "query"}} + if !source.SupportsQuery() { + t.Fatalf("expected SupportsQuery to be true") + } + + source = Source{Capabilities: []string{"SYNC"}} + if source.SupportsQuery() { + t.Fatalf("expected SupportsQuery to be false") + } +} + +func TestWhoAmISuccess(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + client := newHelperClient(t, "whoami_success", time.Second, argsFile) + + result, err := client.WhoAmI() + if err != nil { + t.Fatalf("WhoAmI returned error: %v", err) + } + if result.Email != "agent@example.com" || result.Org != "impactable" { + t.Fatalf("unexpected whoami result: %+v", result) + } + + expectArgs(t, argsFile, []string{"velen", "auth", "whoami"}) +} + +func TestCurrentOrgSuccess(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + client := newHelperClient(t, "org_success", time.Second, argsFile) + + result, err := client.CurrentOrg() + if err != nil { + t.Fatalf("CurrentOrg returned error: %v", err) + } + if result.Slug != "impactable" || result.Name != "Impactable" { + t.Fatalf("unexpected org result: %+v", result) + } + + expectArgs(t, argsFile, []string{"velen", "org", "current"}) +} + +func TestListSourcesSuccess(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + client := newHelperClient(t, "source_list_success", time.Second, argsFile) + + result, err := client.ListSources() + if err != nil { + t.Fatalf("ListSources returned error: %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 source, got %d", len(result)) + } + if result[0].Key != "github-main" || !result[0].SupportsQuery() { + t.Fatalf("unexpected source result: %+v", result[0]) + } + + expectArgs(t, argsFile, []string{"velen", "source", "list"}) +} + +func TestShowSourceSuccess(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + client := newHelperClient(t, "source_show_success", time.Second, argsFile) + + result, err := client.ShowSource("amplitude-prod") + if err != nil { + t.Fatalf("ShowSource returned error: %v", err) + } + if result.Key != "amplitude-prod" || result.ProviderType != "ANALYTICS" { + t.Fatalf("unexpected source result: %+v", result) + } + + expectArgs(t, argsFile, []string{"velen", "source", "show", "amplitude-prod"}) +} + +func TestQuerySuccessAndSafeArgs(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + client := newHelperClient(t, "query_success", time.Second, argsFile) + + sql := "SELECT * FROM events WHERE note = '; rm -rf /' LIMIT 10" + result, err := client.Query("github-main", sql) + if err != nil { + t.Fatalf("Query returned error: %v", err) + } + if result.RowCount != 1 || len(result.Rows) != 1 || len(result.Columns) != 1 { + t.Fatalf("unexpected query result: %+v", result) + } + + expectArgs(t, argsFile, []string{"velen", "query", "--source", "github-main", "--sql", sql}) +} + +func TestNonZeroExitReturnsStructuredVelenError(t *testing.T) { + client := newHelperClient(t, "nonzero_json_error", time.Second, "") + + _, err := client.WhoAmI() + if err == nil { + t.Fatalf("expected error") + } + + var velenErr *VelenError + if !errors.As(err, &velenErr) { + t.Fatalf("expected VelenError, got %T", err) + } + if velenErr.Code != "unauthorized" || velenErr.Message != "bad token" { + t.Fatalf("unexpected velen error: %+v", velenErr) + } +} + +func TestNonZeroExitFallbackVelenError(t *testing.T) { + client := newHelperClient(t, "nonzero_plain_error", time.Second, "") + + _, err := client.WhoAmI() + if err == nil { + t.Fatalf("expected error") + } + + var velenErr *VelenError + if !errors.As(err, &velenErr) { + t.Fatalf("expected VelenError, got %T", err) + } + if velenErr.Code != "exit_7" { + t.Fatalf("expected code exit_7, got %q", velenErr.Code) + } + if !strings.Contains(velenErr.Message, "permission denied") || !strings.Contains(velenErr.Message, "partial output") { + t.Fatalf("unexpected velen error message: %q", velenErr.Message) + } +} + +func TestInvalidJSONReturnsDecodeError(t *testing.T) { + client := newHelperClient(t, "invalid_json", time.Second, "") + + _, err := client.WhoAmI() + if err == nil { + t.Fatalf("expected error") + } + + var velenErr *VelenError + if errors.As(err, &velenErr) { + t.Fatalf("expected non-VelenError decode error, got %+v", velenErr) + } +} + +func TestTimeoutReturnsVelenError(t *testing.T) { + client := newHelperClient(t, "sleep", 25*time.Millisecond, "") + + _, err := client.WhoAmI() + if err == nil { + t.Fatalf("expected timeout error") + } + + var velenErr *VelenError + if !errors.As(err, &velenErr) { + t.Fatalf("expected VelenError, got %T", err) + } + if velenErr.Code != "timeout" { + t.Fatalf("expected timeout code, got %q", velenErr.Code) + } +} + +func newHelperClient(t *testing.T, scenario string, timeout time.Duration, argsFile string) *VelenClient { + t.Helper() + client := NewVelenClient(timeout) + client.cmdFactory = func(ctx context.Context, name string, args ...string) *exec.Cmd { + helperArgs := []string{"-test.run=TestVelenHelperProcess", "--", name} + helperArgs = append(helperArgs, args...) + cmd := exec.CommandContext(ctx, os.Args[0], helperArgs...) + + env := append(os.Environ(), + "GO_WANT_VELEN_HELPER_PROCESS=1", + "VELEN_HELPER_SCENARIO="+scenario, + ) + if argsFile != "" { + env = append(env, "VELEN_ARGS_FILE="+argsFile) + } + cmd.Env = env + return cmd + } + return client +} + +func expectArgs(t *testing.T, argsFile string, expected []string) { + t.Helper() + payload, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args file: %v", err) + } + content := strings.TrimSpace(string(payload)) + if content == "" { + t.Fatalf("args file is empty") + } + actual := strings.Split(content, "\n") + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("unexpected args: got %#v, want %#v", actual, expected) + } +} + +func TestVelenHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_VELEN_HELPER_PROCESS") != "1" { + return + } + + separatorIndex := -1 + for idx, arg := range os.Args { + if arg == "--" { + separatorIndex = idx + break + } + } + if separatorIndex == -1 || separatorIndex+1 >= len(os.Args) { + _, _ = os.Stderr.WriteString("missing helper args") + os.Exit(2) + } + + args := os.Args[separatorIndex+1:] + if path := os.Getenv("VELEN_ARGS_FILE"); path != "" { + if err := os.WriteFile(path, []byte(strings.Join(args, "\n")), 0o600); err != nil { + _, _ = os.Stderr.WriteString(fmt.Sprintf("write args: %v", err)) + os.Exit(2) + } + } + + switch os.Getenv("VELEN_HELPER_SCENARIO") { + case "whoami_success": + _, _ = os.Stdout.WriteString(`{"email":"agent@example.com","org":"impactable"}`) + os.Exit(0) + case "org_success": + _, _ = os.Stdout.WriteString(`{"slug":"impactable","name":"Impactable"}`) + os.Exit(0) + case "source_list_success": + _, _ = os.Stdout.WriteString(`[{"key":"github-main","name":"GitHub","provider_type":"GITHUB","capabilities":["QUERY","SYNC"]}]`) + os.Exit(0) + case "source_show_success": + _, _ = os.Stdout.WriteString(`{"key":"amplitude-prod","name":"Amplitude","provider_type":"ANALYTICS","capabilities":["QUERY"]}`) + os.Exit(0) + case "query_success": + _, _ = os.Stdout.WriteString(`{"columns":["id"],"rows":[[1]],"row_count":1}`) + os.Exit(0) + case "nonzero_json_error": + _, _ = os.Stderr.WriteString(`{"code":"unauthorized","message":"bad token"}`) + os.Exit(3) + case "nonzero_plain_error": + _, _ = os.Stderr.WriteString("permission denied") + _, _ = os.Stdout.WriteString("partial output") + os.Exit(7) + case "invalid_json": + _, _ = os.Stdout.WriteString("not-json") + os.Exit(0) + case "sleep": + time.Sleep(200 * time.Millisecond) + _, _ = os.Stdout.WriteString(`{"email":"late@example.com","org":"late"}`) + os.Exit(0) + default: + _, _ = os.Stderr.WriteString("unknown helper scenario") + os.Exit(2) + } +}