mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
68ce60c760
* merge cmd
* Refine the merge TUI and simplify the async-merge client
Follow-up polish for `gh stack merge` (the command itself landed in the
previous commit). These changes refine the interactive wizard, enrich the
PR picker, and replace the merge client's bespoke HTTP handling with the
standard go-gh REST client.
Wizard and stepper:
- Redesign the top stepper as a segmented bar: completed steps are green,
the active step is the brightest, and upcoming steps are dimmed. Steps
are separated by a Powerline arrow that blends into the shading, with a
graceful fallback to abutting segments on terminals that lack the glyph
(e.g. Apple Terminal). Set GH_STACK_POWERLINE=1/0 to override detection.
- Show the stack number in the header ("Merge stack #123").
- Hide the header and stepper once the merge is submitted so the live
progress view stands on its own.
PR picker:
- Render each pull request on two lines: the title (white/black, a touch
bolder when selected) above its "#number • branch" (gray, fainter when
deselected). Titles are fetched in one batched GraphQL query (PRTitles)
and fall back to the branch name.
- Scroll long stacks in a fixed 10-item window with persistent "N more"
indicators, so the list no longer jumps as those hints appear and
disappear. Add shift+up / shift+down to jump to the top or bottom.
Progress and outcome:
- Always render a status line ("Submitting merge request...") so it does
not pop in later and shift the view, and normalize messages to end in an
ellipsis.
- Print the final result from the command layer rather than the TUI: a
success line that includes the merge commit SHA
("Merged #1, #2 into main (abc1234)"), an atomic-rollback note on
failure, a distinct message when the user stops watching an in-flight
merge, and "Cancelled operation, nothing merged" on cancel.
- Clamp every rendered line to the terminal width so resizing no longer
leaves duplicated header lines behind, and make truncation ANSI-aware.
Async-merge client:
- Use the go-gh REST client (c.rest.Put / c.rest.Get) for both the submit
and poll endpoints, removing the bespoke http.Client, base-URL helper,
and manual response decoding. The REST client discards non-2xx bodies,
but that only costs the rare 400 message and 409 UUID: real merge
failures still surface through the 200 poll body, and the in-range PRs
are validated open, non-draft, and non-merged before submitting.
- Add classifyAsyncMergeError to map status codes to clear errors (404
unavailable, 409 already exists, 400 no longer mergeable) and drop the
now-unused AsyncMergeResult.StatusCode field. Rework the client tests to
drive the REST client through a stub http.RoundTripper.
* warn merge queue unsupported
* update for new status field from api
* merge cmd docs
* more helpful error msgs
* update to support merge queue
* addressing review comments
* hide merge method step for merge queue
* set merge action explicitly
* address review comments to clarify docs on merge/api behavior
190 lines
7.4 KiB
Go
190 lines
7.4 KiB
Go
package github
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/cli/go-gh/v2/pkg/api"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
|
|
|
type recordedRequest struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}
|
|
|
|
// testAsyncClient builds a Client whose REST client is backed by a stub
|
|
// transport returning the given status and body. When rec is non-nil the
|
|
// request's method, path and body are captured for assertions.
|
|
func testAsyncClient(t *testing.T, status int, respBody string, rec *recordedRequest) *Client {
|
|
t.Helper()
|
|
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
if rec != nil {
|
|
rec.method = r.Method
|
|
rec.path = r.URL.Path
|
|
if r.Body != nil {
|
|
b, _ := io.ReadAll(r.Body)
|
|
rec.body = string(b)
|
|
}
|
|
}
|
|
return &http.Response{
|
|
StatusCode: status,
|
|
Body: io.NopCloser(strings.NewReader(respBody)),
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Request: r,
|
|
}, nil
|
|
})
|
|
rest, err := api.NewRESTClient(api.ClientOptions{Host: "github.com", AuthToken: "x", Transport: rt})
|
|
require.NoError(t, err)
|
|
return &Client{rest: rest, owner: "o", repo: "r"}
|
|
}
|
|
|
|
func TestMergeStackAsync_Accepted(t *testing.T) {
|
|
var rec recordedRequest
|
|
body := `{"status":"pending","details":{"message":"Merge request enqueued.","uuid":"u-1","merge_method":"squash","expected_head_sha":"abc"}}`
|
|
c := testAsyncClient(t, http.StatusAccepted, body, &rec)
|
|
|
|
res, err := c.MergeStackAsync(42, "squash", MergeActionDirectMerge)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, http.MethodPut, rec.method)
|
|
assert.Equal(t, "/repos/o/r/pulls/42/merge-async", rec.path)
|
|
assert.JSONEq(t, `{"merge_method":"squash","merge_action":"direct_merge"}`, rec.body)
|
|
|
|
assert.True(t, res.IsPending())
|
|
assert.False(t, res.IsMerged())
|
|
assert.Equal(t, "u-1", res.Details.UUID)
|
|
assert.Equal(t, "squash", res.Details.MergeMethod)
|
|
}
|
|
|
|
func TestMergeStackAsync_AlreadyMerged(t *testing.T) {
|
|
body := `{"status":"merged","details":{"message":"Pull request is already merged.","sha":"deadbeef"}}`
|
|
res, err := testAsyncClient(t, http.StatusOK, body, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge)
|
|
require.NoError(t, err)
|
|
assert.True(t, res.IsMerged())
|
|
assert.Equal(t, "deadbeef", res.Details.SHA)
|
|
}
|
|
|
|
func TestMergeStackAsync_ExistingRequestConflict(t *testing.T) {
|
|
// The go-gh REST client discards the 409 body, so we can't recover the
|
|
// existing UUID; the request surfaces as a clear "already exists" error.
|
|
_, err := testAsyncClient(t, http.StatusConflict, `{"status":"pending","details":{"uuid":"u-2"}}`, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "already exists")
|
|
}
|
|
|
|
func TestMergeStackAsync_NotMergeable(t *testing.T) {
|
|
// A 400 preflight failure is reported as a clear error (the specific
|
|
// details.message isn't recoverable through the REST client).
|
|
_, err := testAsyncClient(t, http.StatusBadRequest, `{"status":"failed","details":{"message":"Pull request is closed."}}`, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "can no longer be merged")
|
|
}
|
|
|
|
func TestMergeStackAsync_NotAvailable(t *testing.T) {
|
|
_, err := testAsyncClient(t, http.StatusNotFound, `{"message":"Not Found"}`, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge)
|
|
assert.ErrorIs(t, err, ErrAsyncMergeUnavailable)
|
|
}
|
|
|
|
func TestMergeStackAsync_ValidationFailed(t *testing.T) {
|
|
_, err := testAsyncClient(t, http.StatusUnprocessableEntity, `{"message":"Validation Failed"}`, nil).MergeStackAsync(42, "merge", MergeActionDirectMerge)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "Validation Failed")
|
|
}
|
|
|
|
func TestGetAsyncMergeResult_States(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantStatus string
|
|
}{
|
|
{"pending", `{"status":"pending","details":{"message":"Merge request is in progress.","uuid":"u","merge_method":"merge","merge_action":"default","expected_head_sha":"abc"}}`, AsyncMergeStatusPending},
|
|
{"merged", `{"status":"merged","details":{"message":"Pull request was merged.","sha":"abc"}}`, AsyncMergeStatusMerged},
|
|
{"enqueued", `{"status":"enqueued","details":{"message":"Pull request was added to the merge queue."}}`, AsyncMergeStatusEnqueued},
|
|
{"failed", `{"status":"failed","details":{"message":"Merge conflict."}}`, AsyncMergeStatusFailed},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var rec recordedRequest
|
|
res, err := testAsyncClient(t, http.StatusOK, tt.body, &rec).GetAsyncMergeResult(42, "u")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.MethodGet, rec.method)
|
|
assert.Equal(t, "/repos/o/r/pulls/42/merge-async/u", rec.path)
|
|
assert.Equal(t, tt.wantStatus, res.Status)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetAsyncMergeResult_NotFound(t *testing.T) {
|
|
_, err := testAsyncClient(t, http.StatusNotFound, `{"message":"Not Found"}`, nil).GetAsyncMergeResult(42, "missing")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestMergeMethodFromEnum(t *testing.T) {
|
|
assert.Equal(t, MergeMethodMerge, mergeMethodFromEnum("MERGE"))
|
|
assert.Equal(t, MergeMethodSquash, mergeMethodFromEnum("SQUASH"))
|
|
assert.Equal(t, MergeMethodRebase, mergeMethodFromEnum("REBASE"))
|
|
assert.Equal(t, MergeMethodMerge, mergeMethodFromEnum("UNKNOWN"))
|
|
assert.Equal(t, MergeMethodMerge, mergeMethodFromEnum(""))
|
|
}
|
|
|
|
func TestRepoMergeConfig_AllowedMethods(t *testing.T) {
|
|
c := RepoMergeConfig{MergeAllowed: true, RebaseAllowed: true}
|
|
assert.Equal(t, []string{"merge", "rebase"}, c.AllowedMethods())
|
|
assert.True(t, c.Allows("merge"))
|
|
assert.False(t, c.Allows("squash"))
|
|
assert.True(t, c.Allows("rebase"))
|
|
|
|
empty := RepoMergeConfig{}
|
|
assert.Empty(t, empty.AllowedMethods())
|
|
}
|
|
|
|
func TestAsyncMergeResult_Status(t *testing.T) {
|
|
pending := &AsyncMergeResult{Status: AsyncMergeStatusPending}
|
|
assert.True(t, pending.IsPending())
|
|
assert.False(t, pending.IsMerged())
|
|
assert.False(t, pending.IsFailed())
|
|
|
|
merged := &AsyncMergeResult{Status: AsyncMergeStatusMerged}
|
|
assert.True(t, merged.IsMerged())
|
|
assert.False(t, merged.IsPending())
|
|
|
|
failed := &AsyncMergeResult{Status: AsyncMergeStatusFailed}
|
|
assert.True(t, failed.IsFailed())
|
|
assert.False(t, failed.IsMerged())
|
|
|
|
enqueued := &AsyncMergeResult{Status: AsyncMergeStatusEnqueued}
|
|
assert.True(t, enqueued.IsEnqueued())
|
|
assert.False(t, enqueued.IsMerged())
|
|
assert.False(t, enqueued.IsPending())
|
|
|
|
var nilRes *AsyncMergeResult
|
|
assert.False(t, nilRes.IsPending())
|
|
assert.False(t, nilRes.IsMerged())
|
|
assert.False(t, nilRes.IsEnqueued())
|
|
assert.False(t, nilRes.IsFailed())
|
|
}
|
|
|
|
// sanity check that the submit body omits merge_method when empty but always
|
|
// sends merge_action.
|
|
func TestMergeStackAsync_OmitsEmptyMethod(t *testing.T) {
|
|
var rec recordedRequest
|
|
_, err := testAsyncClient(t, http.StatusAccepted, `{"status":"pending","details":{"message":"m","uuid":"u","merge_method":"merge","merge_action":"default","expected_head_sha":"x"}}`, &rec).MergeStackAsync(1, "", MergeActionMergeQueue)
|
|
require.NoError(t, err)
|
|
|
|
var parsed map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(rec.body), &parsed))
|
|
_, hasMethod := parsed["merge_method"]
|
|
assert.False(t, hasMethod, "merge_method should be omitted when empty")
|
|
assert.Equal(t, "merge_queue", parsed["merge_action"], "the explicit merge_action is always sent")
|
|
}
|