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
8 changes: 1 addition & 7 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ on:
merge_group:

permissions:
id-token: write
contents: read

jobs:
Expand All @@ -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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ gh elm migration cutover revert <uuid>
gh elm migration cutover revert <uuid> --json | jq .success
```

Migration status, live watch (`gh elm migration watch <uuid>`), 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
Expand Down
64 changes: 64 additions & 0 deletions integration/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}`

Expand Down
46 changes: 45 additions & 1 deletion internal/cmd/migration/migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))
}))
Expand Down
8 changes: 5 additions & 3 deletions internal/cmd/migration/watch/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"
"time"

"github.com/github/gh-elm/internal/render"
"github.com/github/gh-elm/internal/theme"
)

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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))
Expand Down
120 changes: 119 additions & 1 deletion internal/cmd/migration/watch/watch_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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{
Expand All @@ -116,6 +169,7 @@ func TestView(t *testing.T) {
{MessageType: "info", Message: "hello world"},
},
}

m.basePhase, m.overlay = DerivePhase(m.detail)

out := m.View()
Expand All @@ -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")
})
}
32 changes: 16 additions & 16 deletions internal/elmapi/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down
Loading
Loading