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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 | completed | `go build ./...` and `go test ./...` pass after Step 4 changes. |

## Current progress
- Overall status: complete (`M1` through `M5` complete).
- 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.
- Re-ran dependency resolution and full validation for final milestone: `go get` (Bubble Tea deps), `go build ./...`, and `go test ./...` all succeeded.

## 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
- None for Step 4 scope.

## Links
- Product spec: `SPEC.md` (sections 3, 3.1)
- Plan policy: `docs/PLANS.md`
- Merge gates: `NON_NEGOTIABLE_RULES.md`
- System boundaries: `ARCHITECTURE.md`
26 changes: 26 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
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/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/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
48 changes: 48 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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/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/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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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=
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=
16 changes: 16 additions & 0 deletions internal/gitimpact/engine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package gitimpact

// Phase represents a lifecycle stage in 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"
)

// AnalysisResult is the terminal analysis payload produced by a completed run.
// Fields will be expanded in later milestones as report data is introduced.
type AnalysisResult struct{}
11 changes: 11 additions & 0 deletions internal/gitimpact/observer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package gitimpact

// Observer receives lifecycle callbacks from the git-impact engine.
type Observer interface {
TurnStarted(phase Phase, iteration int)
PhaseAdvanced(from, to Phase)
WaitEntered(message string)
WaitResolved(response string)
RunCompleted(result *AnalysisResult)
RunExhausted(err error)
}
78 changes: 78 additions & 0 deletions internal/gitimpact/tui_bridge.go
Original file line number Diff line number Diff line change
@@ -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) TurnStarted(phase Phase, iteration int) {
o.send(TurnStartedMsg{Phase: phase, Iteration: iteration})
}

func (o *TUIObserver) PhaseAdvanced(from, to Phase) {
o.send(PhaseAdvancedMsg{From: from, To: to})
}

func (o *TUIObserver) WaitEntered(message string) {
o.send(WaitEnteredMsg{Message: message})
}

func (o *TUIObserver) WaitResolved(response string) {
o.send(WaitResolvedMsg{Response: response})
}

func (o *TUIObserver) RunCompleted(result *AnalysisResult) {
o.send(RunCompletedMsg{Result: result})
}

func (o *TUIObserver) RunExhausted(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)
}
164 changes: 164 additions & 0 deletions internal/gitimpact/tui_bridge_test.go
Original file line number Diff line number Diff line change
@@ -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.TurnStarted(PhaseCollect, 2)
observer.PhaseAdvanced(PhaseCollect, PhaseLink)
observer.WaitEntered("Need deployment mapping confirmation")
observer.WaitResolved("Use release tags")
observer.RunCompleted(result)
observer.RunExhausted(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
}
Loading