Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f9cd10b
Fix migration credential reference
colinstark Aug 25, 2026
34fd2cf
Preserve source repository metadata
colinstark Aug 26, 2026
a3012af
Align TUI visuals and action focus
colinstark Aug 26, 2026
355c696
Document TUI metadata and action shortcuts
colinstark Aug 26, 2026
b058d19
Fix TUI state management and tests
colinstark Aug 26, 2026
aa28773
Add configuration form actions
colinstark Aug 28, 2026
6c663a6
Disable TUI actions until configured
colinstark Aug 28, 2026
564928a
Refine configuration TUI
colinstark Aug 28, 2026
754f30c
Show all migrations with refined progress
colinstark Aug 28, 2026
6c0a777
Fix migration details and configuration flow
colinstark Aug 31, 2026
200fce9
Refine TUI migration workflows
colinstark Aug 31, 2026
a5c705d
Refine TUI migration interactions
colinstark Aug 31, 2026
8bda361
Polish TUI detail layouts
colinstark Aug 31, 2026
d527612
Refine target configuration and status
colinstark Aug 31, 2026
15433ee
Fix TUI popover layout
colinstark Aug 31, 2026
4307d8d
Simplify unstarted cutover status
colinstark Aug 31, 2026
cf2e909
Improve TUI detail navigation
colinstark Aug 31, 2026
f51d284
Bound advanced migration list loading
colinstark Aug 31, 2026
e708d06
Polish TUI page headers
colinstark Aug 31, 2026
03b44f3
Avoid loading flash during migration refresh
colinstark Sep 1, 2026
b311346
Refine migration actions and form editing
colinstark Sep 1, 2026
1556d43
Polish migration state and list rendering
colinstark Sep 1, 2026
e007180
Fix cutover status rendering
colinstark Sep 10, 2026
cf9782b
Add actions to all TUI forms
colinstark Sep 10, 2026
6eff6a5
Remove advanced destination entry
colinstark Sep 10, 2026
322b9fe
Refine destination migration details
colinstark Sep 10, 2026
f9a3b59
Show native cursor in form fields
colinstark Sep 14, 2026
3441ee8
Adapt mannequin form tests to TUI fields
colinstark Sep 18, 2026
fbc0362
Use explicit subtests for TUI forms
colinstark Sep 18, 2026
0a7e3aa
Normalize target resource type enums
colinstark Sep 18, 2026
d6ec5d6
Remove private Go proxy action
colinstark Sep 18, 2026
2634b88
Address TUI lint findings
colinstark Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 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,6 @@ 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

- name: Run Go linter
run: make lint
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,18 @@ gh elm config set-target-pat ORG # set an organization's TARGET_PAT secret
When standard input and output are interactive terminals, invoking `gh elm` with no
arguments opens a full-screen TUI. Its main migration workflow supports creating,
monitoring, and controlling migrations, then links to destination details, resources,
and reports without repeatedly copying IDs. Lower-level destination migration controls
remain available under **Advanced destination operations**. Use arrow keys or `j`/`k`
to move, Enter to select, Escape to go back, `/` to search migrations, `Ctrl+V` to
toggle list density, `?` for contextual help, and `q` to quit. Long detail and result
views support Page Up and Page Down.
and reports without repeatedly copying IDs. Use arrow keys or `j`/`k` to move, Enter
to select, Escape to go back, `/` to search migrations, `Ctrl+V` to toggle list density,
`?` for contextual help, and `q` to quit. Long detail and result views support Page Up
and Page Down. Action screens always focus their first button; use Left/Right and Enter
or the shortcut shown inside a button to activate it.

Migration creation lazily loads searchable source repositories and destination
organizations from the configured APIs. The destination repository name defaults to
the selected source name and remains editable. Press `Ctrl+E` from either picker to
fall back to manual `org/repo` entry.
fall back to manual `org/repo` entry. Source repository rows include metadata returned
by the source appliance; press `?` to inspect the selected repository when its detail
panel does not fit beside the list.

In scripts, redirected output, and other non-interactive environments, bare `gh elm`
continues to print help and exit successfully. Explicit commands and machine-readable
Expand Down
8 changes: 3 additions & 5 deletions internal/cmd/migration/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,9 @@ func newCreateCmd() *cobra.Command {
TargetOrganizationLogin: repositories.target.organization,
TargetRepositoryName: repositories.target.repository,
TargetAPIEndpoint: targetAPI,
// WORKAROUND (API defect): the create endpoint requires a
// non-empty pat_name, but migration credentials are supplied by
// the system rather than this CLI, so there is nothing meaningful
// to send. Stub it with a sentinel until the API stops requiring it.
PATName: "BOGON",
// The API resolves the source and target tokens server-side from
// this required static credential reference.
PATName: elmapi.SystemPATName,
TargetVisibility: visibility,
}

Expand Down
7 changes: 4 additions & 3 deletions internal/cmd/migration/migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ func TestCreate(t *testing.T) {
assert.True(t, strings.HasSuffix(gotPath, "/enterprise/live-migrations"), "path suffix: %q", gotPath)
// The target endpoint is derived from GH_TARGET_HOST (API-defect workaround).
assert.Equal(t, "https://api.example.ghe.com", gotBody.TargetAPIEndpoint)
// pat_name is stubbed with a sentinel (API-defect workaround).
assert.Equal(t, "BOGON", gotBody.PATName)
assert.Equal(t, elmapi.SystemPATName, gotBody.PATName)
assert.Equal(t, "acme", gotBody.SourceOrganizationLogin)
assert.Equal(t, "web", gotBody.SourceRepositoryName)
assert.Equal(t, "acme-cloud", gotBody.TargetOrganizationLogin)
Expand Down Expand Up @@ -580,9 +579,11 @@ func TestCutoverStatus(t *testing.T) {
out := run(t, "cutover", "status", "m",
"--source-url", srv.URL, "--source-token", "tok")

for _, want := range []string{" Not ready for cutover", "Backfill in progress", "backfill incomplete", "acme/web · Backfill · In progress"} {
for _, want := range []string{" Not ready for cutover", "backfill incomplete", "acme/web · Backfill · In progress"} {
assert.Contains(t, out, want)
}
assert.NotContains(t, out, "Backfilling")
assert.NotContains(t, out, "Backfill in progress")
assert.NotContains(t, out, "Ready for cutover: false")
})

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/migration/watch/phases.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (o Overlay) String() string {
case OverlayFailed:
return "Failed"
case OverlayTerminated:
return "Terminated"
return "Cancelled"
case OverlayPaused:
return "Paused"
case OverlayDegraded:
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/migration/watch/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ func (m Model) phaseDetail(p Phase) string {
case p == PhaseCompleted && m.basePhase == PhaseCompleted && m.overlay == OverlayNone:
return m.completedDetail()
case p == PhaseCompleted && m.overlay == OverlayTerminated:
return m.styles.Failure.Render("Migration terminated")
return m.styles.Failure.Render("Migration cancelled")
case p == PhaseCompleted && m.overlay == OverlayFailed:
return m.failedDetail()
}
Expand Down
13 changes: 13 additions & 0 deletions internal/cmd/migration/watch/watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,17 @@ func TestView(t *testing.T) {
m := New("id", time.Second, nil)
assert.Contains(t, m.View(), "Loading migration status")
})

t.Run("terminated migration displays as cancelled", func(t *testing.T) {
m := New("id", time.Second, nil)
m.detail = combined(combinedTerminated)
m.basePhase, m.overlay = DerivePhase(m.detail)

output := m.View()

assert.Equal(t, "Cancelled", OverlayTerminated.String())
assert.Contains(t, output, "Migration cancelled")
assert.NotContains(t, output, "Terminated")
assert.NotContains(t, output, "Migration terminated")
})
}
2 changes: 1 addition & 1 deletion internal/cmd/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func runRoot(cmd *cobra.Command) error {
tea.WithContext(cmd.Context()),
tea.WithAltScreen(),
tea.WithInput(cmd.InOrStdin()),
tea.WithOutput(cmd.OutOrStdout()),
tea.WithOutput(elmtui.NativeCursorOutput(cmd.OutOrStdout())),
)
if _, err := program.Run(); err != nil {
if errors.Is(err, tea.ErrProgramKilled) && cmd.Context().Err() != nil {
Expand Down
12 changes: 10 additions & 2 deletions internal/elmapi/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ const catalogPageSize = 100

// Repository is a repository visible to the authenticated user.
type Repository struct {
FullName string `json:"full_name"`
Owner struct {
FullName string `json:"full_name"`
Description string `json:"description"`
Language string `json:"language"`
Visibility string `json:"visibility"`
Private bool `json:"private"`
Archived bool `json:"archived"`
Fork bool `json:"fork"`
Stargazers int `json:"stargazers_count"`
OpenIssueCount int `json:"open_issues_count"`
Owner struct {
Type string `json:"type"`
} `json:"owner"`
}
Expand Down
16 changes: 14 additions & 2 deletions internal/elmapi/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func TestListRepositories(t *testing.T) {
assert.Equal(t, "100", r.URL.Query().Get("per_page"))
assert.Equal(t, "owner,collaborator,organization_member", r.URL.Query().Get("affiliation"))
_, _ = w.Write([]byte(`[
{"full_name":"zeta/repo","owner":{"type":"Organization"}},
{"full_name":"zeta/repo","description":"API service","language":"Go","visibility":"private","private":true,"archived":true,"fork":false,"stargazers_count":12,"open_issues_count":4,"owner":{"type":"Organization"}},
{"full_name":"Acme/api","owner":{"type":"Organization"}}
]`))
}))
Expand All @@ -26,7 +26,19 @@ func TestListRepositories(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, []Repository{
repository("Acme/api", "Organization"),
repository("zeta/repo", "Organization"),
{
FullName: "zeta/repo",
Description: "API service",
Language: "Go",
Visibility: "private",
Private: true,
Archived: true,
Stargazers: 12,
OpenIssueCount: 4,
Owner: struct {
Type string `json:"type"`
}{Type: "Organization"},
},
}, repositories)
}

Expand Down
8 changes: 6 additions & 2 deletions internal/elmapi/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ const (
StatusFailed = "failed"
StatusTerminated = "terminated"
StatusAll = "all"

// SystemPATName is the credential reference required by the live-migration
// create API. The source and target token values remain stored server-side.
SystemPATName = "system-pat"
)

// CreateMigrationRequest is the body of a create-migration call. TargetVisibility
// is optional and defaults to internal server-side when omitted. TargetAPIEndpoint
// and PATName are required by the API; the migration commands derive/stub them
// (see newCreateCmd) rather than exposing dedicated flags.
// and PATName are required by the API; callers derive the endpoint and use the
// static SystemPATName credential reference rather than exposing dedicated flags.
type CreateMigrationRequest struct {
SourceOrganizationLogin string `json:"source_organization_login"`
SourceRepositoryName string `json:"source_repository_name"`
Expand Down
112 changes: 106 additions & 6 deletions internal/elmapi/target_migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,118 @@ type TargetRepositoryProgress struct {
LiveUpdateResourcesAcknowledged int64 `json:"liveUpdateResourcesAcknowledged"`
}

// TargetRepositoryStateSummary contains per-type node counts for a repository.
type TargetRepositoryStateSummary struct {
Repository string `json:"repository"`
Backfill TargetOriginStateSummary `json:"backfill"`
LiveUpdate TargetOriginStateSummary `json:"liveUpdate"`
}

// TargetOriginStateSummary contains node counts for one migration origin.
type TargetOriginStateSummary struct {
Breakdown []TargetStateBreakdownEntry `json:"breakdown"`
Total int64 `json:"total"`
}

// UnmarshalJSON accepts the quoted int64 values emitted by protobuf JSON.
func (s *TargetOriginStateSummary) UnmarshalJSON(data []byte) error {
type summaryFields TargetOriginStateSummary
var fields struct {
summaryFields
Total wireInt64 `json:"total"`
}
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
*s = TargetOriginStateSummary(fields.summaryFields)
s.Total = int64(fields.Total)
return nil
}

// TargetStateBreakdownEntry is one state, kind, and resource-type bucket.
type TargetStateBreakdownEntry struct {
State string `json:"state"`
Kind string `json:"kind"`
Type string `json:"type"`
Count int64 `json:"count"`
Origin string `json:"origin"`
}

// UnmarshalJSON accepts the quoted int64 values emitted by protobuf JSON.
func (e *TargetStateBreakdownEntry) UnmarshalJSON(data []byte) error {
type entryFields TargetStateBreakdownEntry
var fields struct {
entryFields
Count wireInt64 `json:"count"`
}
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
*e = TargetStateBreakdownEntry(fields.entryFields)
e.Count = int64(fields.Count)
return nil
}

// UnmarshalJSON accepts the quoted int64 values returned by the status endpoint
// while remaining compatible with the numeric values documented by its schema.
func (p *TargetRepositoryProgress) UnmarshalJSON(data []byte) error {
type progressFields TargetRepositoryProgress
var fields struct {
progressFields
ResourcesAdded wireInt64 `json:"resourcesAdded"`
ResourcesProcessed wireInt64 `json:"resourcesProcessed"`
EventsAdded wireInt64 `json:"eventsAdded"`
EventsProcessed wireInt64 `json:"eventsProcessed"`
BackfillResourcesAcknowledged wireInt64 `json:"backfillResourcesAcknowledged"`
LiveUpdateResourcesAcknowledged wireInt64 `json:"liveUpdateResourcesAcknowledged"`
}
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
*p = TargetRepositoryProgress(fields.progressFields)
p.ResourcesAdded = int64(fields.ResourcesAdded)
p.ResourcesProcessed = int64(fields.ResourcesProcessed)
p.EventsAdded = int64(fields.EventsAdded)
p.EventsProcessed = int64(fields.EventsProcessed)
p.BackfillResourcesAcknowledged = int64(fields.BackfillResourcesAcknowledged)
p.LiveUpdateResourcesAcknowledged = int64(fields.LiveUpdateResourcesAcknowledged)
return nil
}

type wireInt64 int64

func (v *wireInt64) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
value := string(data)
if len(data) > 0 && data[0] == '"' {
if err := json.Unmarshal(data, &value); err != nil {
return err
}
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fmt.Errorf("invalid integer %q: %w", value, err)
}
*v = wireInt64(parsed)
return nil
}

// TargetMigration is a target-side migration record, as returned by the list
// and status endpoints. Raw holds the exact JSON object the API returned for
// this migration, so callers rendering JSON can echo the API's response
// verbatim — preserving fields this struct does not model and avoiding
// zero-valued fields that re-marshaling would inject.
type TargetMigration struct {
MigrationID string `json:"migrationId"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
Description string `json:"description,omitempty"`
Repositories []string `json:"repositories,omitempty"`
RepositoryProgress []TargetRepositoryProgress `json:"repositoryProgress,omitempty"`
MigrationID string `json:"migrationId"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
Description string `json:"description,omitempty"`
Repositories []string `json:"repositories,omitempty"`
RepositoryProgress []TargetRepositoryProgress `json:"repositoryProgress,omitempty"`
RepositoryStateSummaries []TargetRepositoryStateSummary `json:"repositoryStateSummaries,omitempty"`
ExporterMigrationGUID string `json:"exporterMigrationGuid,omitempty"`

// Raw is the original JSON object for this migration. It is populated on
// decode and excluded from (re-)marshaling.
Expand Down
53 changes: 52 additions & 1 deletion internal/elmapi/target_migrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ func TestGetTargetMigrationStatus(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","repositoryProgress":[{"repositoryNwo":"octo/repo","resourcesAdded":10,"resourcesProcessed":5}]}}`))
_, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","exporterMigrationGuid":"source-guid","repositoryProgress":[{"repositoryNwo":"octo/repo","resourcesAdded":10,"resourcesProcessed":5}]}}`))
}))
defer srv.Close()

Expand All @@ -304,11 +304,62 @@ func TestGetTargetMigrationStatus(t *testing.T) {

assert.Equal(t, "/enterprise/migration/42/status", gotPath)
assert.Equal(t, "42", resp.Migration.MigrationID)
assert.Equal(t, "source-guid", resp.Migration.ExporterMigrationGUID)
require.Len(t, resp.Migration.RepositoryProgress, 1)
assert.Equal(t, "octo/repo", resp.Migration.RepositoryProgress[0].RepositoryNWO)
assert.Equal(t, int64(10), resp.Migration.RepositoryProgress[0].ResourcesAdded)
})

t.Run("decodes string-encoded progress counts", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","repositoryProgress":[{"repositoryNwo":"octo/repo","resourcesAdded":"10","resourcesProcessed":"5","eventsAdded":"4","eventsProcessed":"3","backfillResourcesAcknowledged":"2","liveUpdateResourcesAcknowledged":"1"}]}}`))
}))
defer srv.Close()

resp, err := NewClient(srv.URL, "tok").GetTargetMigrationStatus(t.Context(), 42)

require.NoError(t, err)
require.Len(t, resp.Migration.RepositoryProgress, 1)
progress := resp.Migration.RepositoryProgress[0]
assert.Equal(t, int64(10), progress.ResourcesAdded)
assert.Equal(t, int64(5), progress.ResourcesProcessed)
assert.Equal(t, int64(4), progress.EventsAdded)
assert.Equal(t, int64(3), progress.EventsProcessed)
assert.Equal(t, int64(2), progress.BackfillResourcesAcknowledged)
assert.Equal(t, int64(1), progress.LiveUpdateResourcesAcknowledged)
})

t.Run("decodes repository state summaries", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"migration":{"migrationId":"42","repositoryStateSummaries":[{"repository":"octo/repo","backfill":{"total":"12","breakdown":[{"state":"processed","kind":"resource","type":"issue_comment","count":"10","origin":"backfill"},{"state":"failed","kind":"resource","type":"issue_comment","count":2,"origin":"backfill"}]},"liveUpdate":{"total":"3","breakdown":[{"state":"pending","kind":"event","type":"pull_request","count":"3","origin":"live_update"}]}}]}}`))
}))
defer srv.Close()

resp, err := NewClient(srv.URL, "tok").GetTargetMigrationStatus(t.Context(), 42)

require.NoError(t, err)
require.Len(t, resp.Migration.RepositoryStateSummaries, 1)
summary := resp.Migration.RepositoryStateSummaries[0]
assert.Equal(t, "octo/repo", summary.Repository)
assert.Equal(t, int64(12), summary.Backfill.Total)
require.Len(t, summary.Backfill.Breakdown, 2)
assert.Equal(t, "issue_comment", summary.Backfill.Breakdown[0].Type)
assert.Equal(t, int64(10), summary.Backfill.Breakdown[0].Count)
assert.Equal(t, int64(3), summary.LiveUpdate.Total)
})

t.Run("rejects malformed progress counts", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","repositoryProgress":[{"resourcesAdded":"many"}]}}`))
}))
defer srv.Close()

_, err := NewClient(srv.URL, "tok").GetTargetMigrationStatus(t.Context(), 42)

require.Error(t, err)
assert.Contains(t, err.Error(), `invalid integer "many"`)
})

t.Run("returns HTTPError on non-200", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
Expand Down
Loading
Loading