diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 287e05f..5d00f5f 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -10,7 +10,6 @@ on: merge_group: permissions: - id-token: write contents: read jobs: @@ -26,12 +25,7 @@ jobs: - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod - # setup-go caches by default; disable it here because this workflow - # also runs on tags and has id-token: write. - cache: false - - - name: OIDC Setup for goproxy - uses: github/setup-goproxy@5e60e1074d42316dfe2949ebf9a92bf77b24645b # v1.1.0 + cache: true - name: Run Go linter run: make lint diff --git a/README.md b/README.md index de9476d..e9e8b1a 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,15 @@ gh elm migration cutover revert gh elm migration cutover revert --json | jq .success ``` +Migration status, live watch (`gh elm migration watch `), and TUI details +show the source repository's observed archive state separately from migration +progress and completion. The `migration.source_repository_archived` response field is +`true`, `false`, or `null`. A missing or null field, including on servers that do +not support the observation, displays "Source repository archive state unavailable". +The observation is a sample, not proof of a successful archive operation or a +guarantee that an unarchived repository is writable. `--json` preserves the complete +response, including this field and legacy progress fields. + Look up a migration's destination (GitHub with Data Residency) migration ID — `gh elm migration target-id` (human-readable by default; add `--json` for a machine-readable object). The numeric target migration ID it returns is the positional diff --git a/integration/cli_test.go b/integration/cli_test.go index 1a74379..c5cf02c 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -61,6 +61,70 @@ func TestInvalidCommand(t *testing.T) { } func TestMigrationStatus(t *testing.T) { + t.Run("source archive observation and raw JSON", func(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + {"true", `{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {"false with completed legacy true", `{"migration":{"source_repository_archived":false,"future_field":{"value":1}},"combined_state":{"status":"completed"},"target_state":{"repository_progress":[{"repository_locked":true}]},"future_field":{"value":1}}`, "Source repository not archived"}, + {"null", `{"migration":{"source_repository_archived":null}}`, "Source repository archive state unavailable"}, + {"absent", `{"migration":{}}`, "Source repository archive state unavailable"}, + {"missing migration", `{"combined_state":{"status":"completed"}}`, "Source repository archive state unavailable"}, + {"null migration", `{"migration":null,"combined_state":{"status":"completed"}}`, "Source repository archive state unavailable"}, + {"ignores top-level observation", `{"migration":{},"source_repository_archived":true}`, "Source repository archive state unavailable"}, + {"empty", `{}`, "No migration status data returned."}, + {"null only", `{"migration":null}`, "No migration status data returned."}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v3/enterprise/live-migrations/mig-1", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.body)) + })) + t.Cleanup(srv.Close) + args := []string{"migration", "status", "mig-1", "--source-url", srv.URL, "--source-token", "fixture-token"} + result := runCLI(t, nil, args...) + require.Zero(t, result.ExitCode, result.Stderr) + assert.Empty(t, result.Stderr) + assert.Contains(t, result.Stdout, tc.want) + assert.Equal(t, 1, strings.Count(result.Stdout, tc.want)) + assert.NotContains(t, result.Stdout, "Source repository locked") + assert.NotContains(t, result.Stdout, "Source repository unlocked") + t.Logf("Controlled-response CLI output:\n%s", result.Stdout) + + result = runCLI(t, nil, append(args, "--json")...) + require.Zero(t, result.ExitCode, result.Stderr) + assert.Empty(t, result.Stderr) + assert.JSONEq(t, tc.body, result.Stdout) + assert.Equal(t, int32(2), requests.Load()) + }) + } + }) + + t.Run("invalid observation is an error only for typed human output", func(t *testing.T) { + const response = `{"migration":{"source_repository_archived":"unexpected"},"future_field":[false,null,42]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(srv.Close) + args := []string{"migration", "status", "mig-1", "--source-url", srv.URL, "--source-token", "fixture-token"} + result := runCLI(t, nil, args...) + require.NotZero(t, result.ExitCode) + assert.Empty(t, result.Stdout) + assert.Contains(t, result.Stderr, "source_repository_archived") + + result = runCLI(t, nil, append(args, "--json")...) + require.Zero(t, result.ExitCode, result.Stderr) + assert.Empty(t, result.Stderr) + assert.JSONEq(t, response, result.Stdout) + }) + t.Run("succeeds", func(t *testing.T) { const response = `{"migration":{"migration_id":"mig-1","status":"in_progress"}}` diff --git a/internal/cmd/migration/migration_test.go b/internal/cmd/migration/migration_test.go index 690b26a..ef1aa5e 100644 --- a/internal/cmd/migration/migration_test.go +++ b/internal/cmd/migration/migration_test.go @@ -263,6 +263,50 @@ func TestStart(t *testing.T) { } func TestStatus(t *testing.T) { + t.Run("renders source-only observations and empty documents", func(t *testing.T) { + cases := []struct { + body string + want string + }{ + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":{"source_repository_archived":false}}`, "Source repository not archived"}, + {`{"migration":{"source_repository_archived":null}}`, "Source repository archive state unavailable"}, + {`{"migration":{}}`, "Source repository archive state unavailable"}, + {`{"target_state":{}}`, "Source repository archive state unavailable"}, + {`{"migration":null,"target_state":{}}`, "Source repository archive state unavailable"}, + {`{}`, "No migration status data returned."}, + {`{"migration":null}`, "No migration status data returned."}, + {`null`, "No migration status data returned."}, + } + for _, tc := range cases { + t.Run(tc.body, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(tc.body)) + })) + t.Cleanup(srv.Close) + + out := run(t, "status", "mig-1", "--source-url", srv.URL, "--source-token", "tok") + assert.Contains(t, out, tc.want) + assert.NotContains(t, out, "Source repository locked") + assert.NotContains(t, out, "Source repository unlocked") + }) + } + }) + + t.Run("invalid observation fails human output but survives raw JSON", func(t *testing.T) { + const body = `{"migration":{"source_repository_archived":"unexpected"},"future_field":{"value":1}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + out, err := exec(t, "status", "mig-1", "--source-url", srv.URL, "--source-token", "tok") + require.ErrorContains(t, err, "source_repository_archived") + assert.NotContains(t, out, "Source repository archived") + out = run(t, "status", "mig-1", "--json", "--source-url", srv.URL, "--source-token", "tok") + assert.JSONEq(t, body, out) + }) + t.Run("prints human-readable status", func(t *testing.T) { const respBody = `{"migration":{"migration_id":"mig-1","status":"in_progress"},"target_state":null,"combined_state":null,"messages":[]}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -280,7 +324,7 @@ func TestStatus(t *testing.T) { }) t.Run("--json preserves the raw status response", func(t *testing.T) { - const respBody = `{"migration":{"migration_id":"mig-1"},"future_field":{"value":1}}` + const respBody = `{"migration":{"migration_id":"mig-1","source_repository_archived":false,"future_field":{"value":1}},"target_state":{"repository_progress":[{"repository_locked":true}]},"future_field":{"value":1}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(respBody)) })) diff --git a/internal/cmd/migration/watch/view.go b/internal/cmd/migration/watch/view.go index 8aec740..a39986c 100644 --- a/internal/cmd/migration/watch/view.go +++ b/internal/cmd/migration/watch/view.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/github/gh-elm/internal/render" "github.com/github/gh-elm/internal/theme" ) @@ -37,6 +38,8 @@ func (m Model) View() string { var b strings.Builder w(&b, m.renderHeader()) + w(&b, render.SourceRepositoryArchiveState(m.detail.Migration)) + w(&b, "\n") w(&b, "\n") w(&b, m.renderTimeline()) w(&b, m.renderPreflight()) @@ -308,13 +311,12 @@ func (m Model) cutoverDetail() string { return "Waiting for cutover status..." } - locked := boolCheck(rp.RepositoryLocked, m.styles) gitPush := boolCheck(rp.InitialGitPushComplete, m.styles) resourcesSent := boolCheck(rp.AllResourcesSent, m.styles) var b strings.Builder - w(&b, fmt.Sprintf("Repo locked: %s Git push: %s All resources sent: %s", - locked, gitPush, resourcesSent)) + w(&b, fmt.Sprintf("Git push: %s All resources sent: %s", + gitPush, resourcesSent)) if cs := m.detail.CombinedState; cs != nil && cs.DisplayMessage != "" { w(&b, "\n"+m.styles.Warning.Render(cs.DisplayMessage)) diff --git a/internal/cmd/migration/watch/watch_test.go b/internal/cmd/migration/watch/watch_test.go index 709e148..9de1c64 100644 --- a/internal/cmd/migration/watch/watch_test.go +++ b/internal/cmd/migration/watch/watch_test.go @@ -1,10 +1,14 @@ package watch import ( + "net/http" + "net/http/httptest" + "strings" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/github/gh-elm/internal/elmapi" ) @@ -90,6 +94,55 @@ func TestDerivePhase(t *testing.T) { } func TestView(t *testing.T) { + t.Run("source observation stays above timeline through cutover and completion", func(t *testing.T) { + cases := []struct { + name string + archived *bool + want string + }{ + {"true", new(true), "Source repository archived"}, + {"false", new(false), "Source repository not archived"}, + {"unavailable", nil, "Source repository archive state unavailable"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := New("mig-1", time.Second, nil) + m.width = 60 + m.detail = &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{SourceRepositoryArchived: tc.archived}, + TargetState: &elmapi.TargetState{RepositoryProgress: []elmapi.RepositoryProgress{{ + RepositoryLocked: true, + InitialGitPushComplete: true, + AllResourcesSent: true, + }}}, + } + m.basePhase = PhaseCuttingOver + out := m.View() + assert.Contains(t, out, tc.want) + assert.Equal(t, 1, strings.Count(out, "Source repository")) + assert.Less(t, strings.Index(out, tc.want), strings.Index(out, "Created")) + assert.NotContains(t, out, "Repo locked") + assert.Contains(t, out, "Git push: ✓ All resources sent: ✓") + + m.basePhase = PhaseCompleted + m.detail.TargetState = nil + out = m.View() + assert.Contains(t, out, tc.want) + assert.Equal(t, 1, strings.Count(out, "Source repository")) + assert.Contains(t, out, "Migration completed successfully.") + }) + } + }) + + t.Run("source-only response and empty response preserve existing timeline", func(t *testing.T) { + m := New("id", time.Second, nil) + m.detail = &elmapi.MigrationDetail{Migration: &elmapi.MigrationSummary{SourceRepositoryArchived: new(false)}} + assert.Contains(t, m.View(), "Source repository not archived") + m.detail = &elmapi.MigrationDetail{} + assert.Contains(t, m.View(), "Source repository archive state unavailable") + assert.Contains(t, m.View(), "Created") + }) + t.Run("renders timeline and progress", func(t *testing.T) { m := New("11112222-3333-4444-5555-666677778888", 2*time.Second, nil) m.detail = &elmapi.MigrationDetail{ @@ -116,6 +169,7 @@ func TestView(t *testing.T) { {MessageType: "info", Message: "hello world"}, }, } + m.basePhase, m.overlay = DerivePhase(m.detail) out := m.View() @@ -136,6 +190,70 @@ func TestView(t *testing.T) { t.Run("loading", func(t *testing.T) { m := New("id", time.Second, nil) - assert.Contains(t, m.View(), "Loading migration status") + assert.Equal(t, "Loading migration status...\n", m.View()) + }) +} + +func TestUpdate(t *testing.T) { + t.Run("successful refresh replaces archive observation and request failure retains it", func(t *testing.T) { + type response struct { + body string + status int + } + responses := make(chan response, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + next := <-responses + w.WriteHeader(next.status) + _, _ = w.Write([]byte(next.body)) + })) + t.Cleanup(srv.Close) + m := New("mig-1", time.Second, elmapi.NewClient(srv.URL, "tok")) + cases := []struct { + body string + text string + }{ + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":{"source_repository_archived":false}}`, "Source repository not archived"}, + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":{"source_repository_archived":null}}`, "Source repository archive state unavailable"}, + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":{}}`, "Source repository archive state unavailable"}, + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":null}`, "Source repository archive state unavailable"}, + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{}`, "Source repository archive state unavailable"}, + } + for _, tc := range cases { + responses <- response{body: tc.body, status: http.StatusOK} + msg := fetchStatus(m.client, m.migrationID, m.interval) + updated, cmd := m.Update(msg) + m = updated.(Model) + require.NoError(t, m.fetchErr) + require.NotNil(t, cmd) + assert.Contains(t, m.View(), tc.text) + assert.Equal(t, 1, strings.Count(m.View(), "Source repository")) + } + + responses <- response{body: `{"migration":{"source_repository_archived":true}}`, status: http.StatusOK} + updated, _ := m.Update(fetchStatus(m.client, m.migrationID, m.interval)) + m = updated.(Model) + lastDetail, lastUpdated := m.detail, m.lastUpdated + responses <- response{status: http.StatusServiceUnavailable} + updated, cmd := m.Update(fetchStatus(m.client, m.migrationID, m.interval)) + m = updated.(Model) + require.Error(t, m.fetchErr) + assert.NotNil(t, cmd) + assert.Same(t, lastDetail, m.detail) + assert.Equal(t, lastUpdated, m.lastUpdated) + assert.Contains(t, m.View(), "Source repository archived") + assert.Contains(t, m.View(), "Failed to refresh (retrying...)") + assert.Contains(t, m.View(), "Last updated: "+formatTimestamp(lastUpdated)) + + responses <- response{body: `{}`, status: http.StatusOK} + updated, _ = m.Update(fetchStatus(m.client, m.migrationID, m.interval)) + m = updated.(Model) + require.NoError(t, m.fetchErr) + assert.Nil(t, m.detail.Migration) + assert.NotContains(t, m.View(), "Failed to refresh") }) } diff --git a/internal/elmapi/migrations.go b/internal/elmapi/migrations.go index 51db8f3..924e854 100644 --- a/internal/elmapi/migrations.go +++ b/internal/elmapi/migrations.go @@ -197,11 +197,10 @@ func (c *Client) migrationPath(migrationID string, action ...string) string { return p.String() } -// --- Typed views over the GET status document, used by `watch`. --- +// --- Typed views over the GET status document. --- -// MigrationDetail is a partial typed decode of the GET status document. Only the -// fields the watch renderer needs are modeled; the raw document (from -// GetMigration) is the source of truth for `status`. +// MigrationDetail is a partial typed decode for human-readable status displays. +// GetMigration preserves the complete raw document for JSON output. type MigrationDetail struct { Migration *MigrationSummary `json:"migration"` TargetState *TargetState `json:"target_state"` @@ -211,18 +210,19 @@ type MigrationDetail struct { // MigrationSummary is the core migration record. type MigrationSummary struct { - MigrationID string `json:"migration_id"` - Status *string `json:"status"` - SourceOrganizationLogin string `json:"source_organization_login"` - TargetOrganizationLogin string `json:"target_organization_login"` - SourceRepositoryName string `json:"source_repository_name"` - TargetRepositoryName string `json:"target_repository_name"` - TargetVisibility *string `json:"target_visibility"` - TargetMigrationID int64 `json:"target_migration_id"` - CreatedAt *string `json:"created_at"` - StartedAt *string `json:"started_at"` - CompletedAt *string `json:"completed_at"` - ExpiresAt *string `json:"expires_at"` + MigrationID string `json:"migration_id"` + Status *string `json:"status"` + SourceOrganizationLogin string `json:"source_organization_login"` + TargetOrganizationLogin string `json:"target_organization_login"` + SourceRepositoryName string `json:"source_repository_name"` + SourceRepositoryArchived *bool `json:"source_repository_archived"` + TargetRepositoryName string `json:"target_repository_name"` + TargetVisibility *string `json:"target_visibility"` + TargetMigrationID int64 `json:"target_migration_id"` + CreatedAt *string `json:"created_at"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + ExpiresAt *string `json:"expires_at"` } // TargetState carries destination-side aggregate progress. diff --git a/internal/elmapi/migrations_test.go b/internal/elmapi/migrations_test.go index d395625..dddfadf 100644 --- a/internal/elmapi/migrations_test.go +++ b/internal/elmapi/migrations_test.go @@ -1,6 +1,7 @@ package elmapi import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -9,6 +10,81 @@ import ( "github.com/stretchr/testify/require" ) +func TestGetMigrationDetail(t *testing.T) { + t.Run("decodes nullable source archive observation independently of progress", func(t *testing.T) { + cases := []struct { + name string + body string + want *bool + }{ + {"true", `{"migration":{"source_repository_archived":true}}`, new(true)}, + {"false", `{"migration":{"source_repository_archived":false}}`, new(false)}, + {"null", `{"migration":{"source_repository_archived":null}}`, nil}, + {"absent", `{"migration":{}}`, nil}, + {"completed with disagreeing legacy progress", `{"migration":{"status":"completed","source_repository_archived":false},"target_state":{"repository_progress":[{"repository_locked":true}]}}`, new(false)}, + {"ignores top-level observation", `{"migration":{},"source_repository_archived":true}`, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/enterprise/live-migrations/mig-1", r.URL.Path) + _, _ = w.Write([]byte(tc.body)) + })) + t.Cleanup(srv.Close) + + detail, err := NewClient(srv.URL, "tok").GetMigrationDetail(t.Context(), "mig-1") + require.NoError(t, err) + require.NotNil(t, detail.Migration) + assert.Equal(t, tc.want, detail.Migration.SourceRepositoryArchived) + }) + } + }) + + t.Run("does not synthesize missing migration metadata", func(t *testing.T) { + for _, body := range []string{`{}`, `{"migration":null}`, `null`, `{"source_repository_archived":true}`} { + t.Run(body, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + detail, err := NewClient(srv.URL, "tok").GetMigrationDetail(t.Context(), "mig-1") + require.NoError(t, err) + assert.Nil(t, detail.Migration) + }) + } + }) + + t.Run("rejects invalid observation types", func(t *testing.T) { + for _, value := range []string{`"true"`, `1`, `{}`, `[]`} { + t.Run(value, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"migration":{"source_repository_archived":` + value + `}}`)) + })) + t.Cleanup(srv.Close) + + detail, err := NewClient(srv.URL, "tok").GetMigrationDetail(t.Context(), "mig-1") + var typeError *json.UnmarshalTypeError + require.ErrorAs(t, err, &typeError) + assert.Equal(t, "migration.source_repository_archived", typeError.Field) + assert.Nil(t, detail) + }) + } + }) + + t.Run("preserves whole request failure", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + + detail, err := NewClient(srv.URL, "tok").GetMigrationDetail(t.Context(), "mig-1") + require.ErrorContains(t, err, "503") + assert.Nil(t, detail) + }) +} + func TestMigrationResponses(t *testing.T) { t.Run("create retains raw JSON while decoding typed fields", func(t *testing.T) { const body = `{"migration_id":"mig-1","expires_at":null,"future_field":"preserved"}` diff --git a/internal/render/migration.go b/internal/render/migration.go index f974113..d2244ed 100644 --- a/internal/render/migration.go +++ b/internal/render/migration.go @@ -35,12 +35,25 @@ func MigrationStatus(v elmapi.MigrationDetail) string { return joinSections( renderMigrationSummary(v.Migration), + renderSection("Source", " "+SourceRepositoryArchiveState(v.Migration)), renderTargetState(v.TargetState), renderCombinedState(v.CombinedState), renderMessages(v.Messages), ) } +// SourceRepositoryArchiveState renders a nullable source observation, not migration progress. +func SourceRepositoryArchiveState(migration *elmapi.MigrationSummary) string { + styles := theme.New() + if migration == nil || migration.SourceRepositoryArchived == nil { + return styles.Muted.Render("Source repository archive state unavailable") + } + if *migration.SourceRepositoryArchived { + return styles.Primary.Render("Source repository archived") + } + return styles.Primary.Render("Source repository not archived") +} + // CutoverStatus renders the cutover portion of a migration status response. func CutoverStatus(v elmapi.MigrationDetail) string { if v.CombinedState == nil { @@ -106,11 +119,9 @@ func renderRepositoryProgress(progress elmapi.RepositoryProgress) string { resources := positiveState(progress.AllResourcesSent, "All resources sent", "Resources still being sent") gitPush := positiveState(progress.InitialGitPushComplete, "Initial Git push complete", "Initial Git push pending") - lock := neutralState(progress.RepositoryLocked, "Source repository locked", "Source repository unlocked") lines = append(lines, bullet(resources.glyph, resources.text), bullet(gitPush.glyph, gitPush.text), - bullet(lock.glyph, lock.text), ) return renderSection("Progress · "+valueOrEmpty(progress.RepositoryNWO), lines...) @@ -367,20 +378,6 @@ func failureState(value bool, trueText, falseText string) state { } } -func neutralState(value bool, trueText, falseText string) state { - styles := theme.New() - if value { - return state{ - glyph: styles.Warning.Render("●"), - text: styles.Warning.Render(trueText), - } - } - return state{ - glyph: styles.Muted.Render("○"), - text: styles.Muted.Render(falseText), - } -} - func stateLine(value bool, trueText, falseText string) string { result := positiveState(value, trueText, falseText) return bullet(result.glyph, result.text) diff --git a/internal/render/migration_test.go b/internal/render/migration_test.go index edccf59..ea9fcbd 100644 --- a/internal/render/migration_test.go +++ b/internal/render/migration_test.go @@ -3,6 +3,7 @@ package render import ( "bytes" "errors" + "strings" "testing" "github.com/charmbracelet/lipgloss" @@ -74,6 +75,50 @@ func TestMigrationCancel(t *testing.T) { } func TestMigrationStatus(t *testing.T) { + t.Run("source observation is independent of target progress and completion", func(t *testing.T) { + cases := []struct { + name string + archived *bool + locked bool + want string + }{ + {"archived with legacy false", new(true), false, "Source repository archived"}, + {"not archived with legacy true", new(false), true, "Source repository not archived"}, + {"unavailable with legacy true", nil, true, "Source repository archive state unavailable"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + output := MigrationStatus(elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{SourceRepositoryArchived: tc.archived}, + CombinedState: &elmapi.CombinedState{Status: new("completed")}, + TargetState: &elmapi.TargetState{RepositoryProgress: []elmapi.RepositoryProgress{ + {RepositoryNWO: "target/one", RepositoryLocked: tc.locked}, + {RepositoryNWO: "target/two", RepositoryLocked: tc.locked}, + }}, + }) + assert.Contains(t, output, tc.want) + assert.Equal(t, 1, strings.Count(output, "Source repository")) + assert.Less(t, strings.Index(output, tc.want), strings.Index(output, "Target\n")) + assert.Contains(t, output, "Completed") + assert.Contains(t, output, "Progress · target/two") + assert.NotContains(t, output, "Source repository locked") + assert.NotContains(t, output, "Source repository unlocked") + }) + } + }) + + t.Run("renders source-only true", func(t *testing.T) { + assert.Contains(t, MigrationStatus(elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{SourceRepositoryArchived: new(true)}, + }), "Source\n Source repository archived\n") //nolint:dupword // header label followed by output line, not a real repeated word + }) + + t.Run("renders source-only false", func(t *testing.T) { + assert.Contains(t, MigrationStatus(elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{SourceRepositoryArchived: new(false)}, + }), "Source\n Source repository not archived\n") //nolint:dupword // header label followed by output line, not a real repeated word + }) + t.Run("renders nested status sections", func(t *testing.T) { status := "in_progress" phase := "backfill" @@ -136,12 +181,15 @@ func TestMigrationStatus(t *testing.T) { }, }) - assert.Equal(t, `Cutover - ✓ Ready for cutover - -Repository states - • elm-test/the-hook2 · Ready for cutover -`, output) + want := "Source\n" + + " Source repository archive state unavailable\n" + + "\n" + + "Cutover\n" + + " ✓ Ready for cutover\n" + + "\n" + + "Repository states\n" + + " • elm-test/the-hook2 · Ready for cutover\n" + assert.Equal(t, want, output) }) t.Run("suppresses completed-state readiness and stale blockers", func(t *testing.T) { @@ -156,10 +204,13 @@ Repository states }, }) - assert.Equal(t, `Cutover - ✓ Completed - Migration completed successfully -`, output) + want := "Source\n" + + " Source repository archive state unavailable\n" + + "\n" + + "Cutover\n" + + " ✓ Completed\n" + + " Migration completed successfully\n" + assert.Equal(t, want, output) }) t.Run("preserves distinct repository phase and status", func(t *testing.T) { @@ -183,6 +234,31 @@ Repository states }) } +func TestSourceRepositoryArchiveState(t *testing.T) { + previousProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + t.Cleanup(func() { + lipgloss.SetColorProfile(previousProfile) + }) + styles := theme.New() + + t.Run("true is a neutral fact", func(t *testing.T) { + assert.Equal(t, styles.Primary.Render("Source repository archived"), + SourceRepositoryArchiveState(&elmapi.MigrationSummary{SourceRepositoryArchived: new(true)})) + }) + t.Run("false is a neutral fact", func(t *testing.T) { + assert.Equal(t, styles.Primary.Render("Source repository not archived"), + SourceRepositoryArchiveState(&elmapi.MigrationSummary{SourceRepositoryArchived: new(false)})) + }) + t.Run("nil observation is explicitly unavailable", func(t *testing.T) { + assert.Equal(t, styles.Muted.Render("Source repository archive state unavailable"), + SourceRepositoryArchiveState(&elmapi.MigrationSummary{})) + }) + t.Run("nil migration is explicitly unavailable", func(t *testing.T) { + assert.Equal(t, styles.Muted.Render("Source repository archive state unavailable"), SourceRepositoryArchiveState(nil)) + }) +} + func TestProgressBar(t *testing.T) { t.Run("renders proportional progress", func(t *testing.T) { assert.Equal(t, "████████░░", ProgressBar(8, 10, 10)) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index cdf27dd..ea46617 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -333,6 +333,68 @@ func TestModel(t *testing.T) { assert.Zero(t, model.targetID) }) + t.Run("source archive refresh replaces observations and retains detail on request failure", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + model.width, model.height = 100, 60 + model.sourceWatching = true + cases := []struct { + body string + text string + }{ + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":{"source_repository_archived":false}}`, "Source repository not archived"}, + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":{"source_repository_archived":null}}`, "Source repository archive state unavailable"}, + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":{}}`, "Source repository archive state unavailable"}, + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"migration":null,"combined_state":{"status":"completed"}}`, "Source repository archive state unavailable"}, + {`{"migration":{"source_repository_archived":true}}`, "Source repository archived"}, + {`{"combined_state":{"status":"completed"}}`, "Source repository archive state unavailable"}, + } + for _, tc := range cases { + var detail elmapi.MigrationDetail + require.NoError(t, json.Unmarshal([]byte(tc.body), &detail)) + updated, cmd := model.Update(sourceDetailMsg{detail: &detail}) + model = updated.(*Model) + require.NoError(t, model.err) + assert.NotNil(t, cmd) + assert.Same(t, &detail, model.sourceDetail) + assert.Contains(t, model.View(), tc.text) + assert.Equal(t, 1, strings.Count(model.View(), "Source repository")) + } + + previous := &elmapi.MigrationDetail{Migration: &elmapi.MigrationSummary{SourceRepositoryArchived: new(true)}} + _, _ = model.Update(sourceDetailMsg{detail: previous}) + _, cmd := model.Update(sourceDetailMsg{err: assert.AnError}) + require.ErrorIs(t, model.err, assert.AnError) + assert.Same(t, previous, model.sourceDetail) + assert.NotNil(t, cmd) + assert.Contains(t, model.View(), "Source repository archived") + assert.Contains(t, model.View(), assert.AnError.Error()) + }) + + t.Run("completed source detail with disagreeing target progress displays archive state once", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + model.width, model.height = 100, 60 + model.sourceDetail = &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{SourceRepositoryArchived: new(false)}, + CombinedState: &elmapi.CombinedState{Status: new("completed")}, + TargetState: &elmapi.TargetState{RepositoryProgress: []elmapi.RepositoryProgress{ + {RepositoryNWO: "target/one", RepositoryLocked: true}, + {RepositoryNWO: "target/two", RepositoryLocked: true}, + }}, + } + out := model.View() + assert.Contains(t, out, "Source repository not archived") + assert.Equal(t, 1, strings.Count(out, "Source repository")) + assert.Contains(t, out, "Completed") + assert.NotContains(t, out, "Source repository locked") + assert.NotContains(t, out, "Source repository unlocked") + }) + t.Run("source actions remain visible in a standard terminal", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenSourceDetail