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
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package github
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/cli/go-gh/v2/pkg/api"
|
|
graphql "github.com/cli/shurcooL-graphql"
|
|
)
|
|
|
|
// Merge method values accepted by the async merge REST API.
|
|
const (
|
|
MergeMethodMerge = "merge"
|
|
MergeMethodSquash = "squash"
|
|
MergeMethodRebase = "rebase"
|
|
)
|
|
|
|
// Merge action values for the async merge API's merge_action field. "default"
|
|
// lets the server choose between a direct merge and the base branch's merge
|
|
// queue; "direct_merge" and "merge_queue" force the respective path. Sending an
|
|
// explicit action makes the caller's merge-queue detection authoritative: the
|
|
// server rejects "merge_queue" on a branch with no queue rather than silently
|
|
// merging directly.
|
|
const (
|
|
MergeActionDefault = "default"
|
|
MergeActionDirectMerge = "direct_merge"
|
|
MergeActionMergeQueue = "merge_queue"
|
|
)
|
|
|
|
// ErrAsyncMergeUnavailable indicates the async merge API is not available for
|
|
// the repository (or the token lacks access). Surfaced on a 404 from the submit
|
|
// endpoint.
|
|
var ErrAsyncMergeUnavailable = errors.New("async stack merge is not available for this repository")
|
|
|
|
// RepoMergeConfig describes which merge methods a repository allows, along with
|
|
// the viewer's default (last-used) merge method.
|
|
type RepoMergeConfig struct {
|
|
MergeAllowed bool
|
|
SquashAllowed bool
|
|
RebaseAllowed bool
|
|
// DefaultMethod is the viewer's last-used merge method, or the repository
|
|
// default, as one of MergeMethodMerge/MergeMethodSquash/MergeMethodRebase.
|
|
DefaultMethod string
|
|
}
|
|
|
|
// AllowedMethods returns the enabled merge methods in display order
|
|
// (merge, squash, rebase).
|
|
func (c RepoMergeConfig) AllowedMethods() []string {
|
|
var methods []string
|
|
if c.MergeAllowed {
|
|
methods = append(methods, MergeMethodMerge)
|
|
}
|
|
if c.SquashAllowed {
|
|
methods = append(methods, MergeMethodSquash)
|
|
}
|
|
if c.RebaseAllowed {
|
|
methods = append(methods, MergeMethodRebase)
|
|
}
|
|
return methods
|
|
}
|
|
|
|
// Allows reports whether the given merge method is enabled for the repository.
|
|
func (c RepoMergeConfig) Allows(method string) bool {
|
|
switch method {
|
|
case MergeMethodMerge:
|
|
return c.MergeAllowed
|
|
case MergeMethodSquash:
|
|
return c.SquashAllowed
|
|
case MergeMethodRebase:
|
|
return c.RebaseAllowed
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AsyncMergeDetails is the polymorphic "details" object shared by the submit and
|
|
// poll responses. Fields are populated based on the current state: a pending
|
|
// request carries UUID/MergeMethod/MergeAction/ExpectedHeadSHA, a merged result
|
|
// carries SHA, and a failed/not-mergeable result carries only Message.
|
|
type AsyncMergeDetails struct {
|
|
Message string `json:"message"`
|
|
UUID string `json:"uuid"`
|
|
MergeMethod string `json:"merge_method"`
|
|
MergeAction string `json:"merge_action"`
|
|
ExpectedHeadSHA string `json:"expected_head_sha"`
|
|
SHA string `json:"sha"`
|
|
}
|
|
|
|
// AsyncMergeResult is the response body returned by both the submit and poll
|
|
// async merge endpoints. Status is one of the AsyncMergeStatus* values:
|
|
// "pending" (running in the background), "merged" (merged directly), "enqueued"
|
|
// (added to the base branch's merge queue), or "failed".
|
|
type AsyncMergeResult struct {
|
|
Status string `json:"status"`
|
|
Details AsyncMergeDetails `json:"details"`
|
|
}
|
|
|
|
// Async merge status values returned in the response's "status" field.
|
|
const (
|
|
AsyncMergeStatusPending = "pending"
|
|
AsyncMergeStatusMerged = "merged"
|
|
AsyncMergeStatusEnqueued = "enqueued"
|
|
AsyncMergeStatusFailed = "failed"
|
|
)
|
|
|
|
// IsMerged reports whether the merge completed successfully.
|
|
func (r *AsyncMergeResult) IsMerged() bool {
|
|
return r != nil && r.Status == AsyncMergeStatusMerged
|
|
}
|
|
|
|
// IsEnqueued reports whether the stack was added to the base branch's merge
|
|
// queue (it will merge once the queue processes it).
|
|
func (r *AsyncMergeResult) IsEnqueued() bool {
|
|
return r != nil && r.Status == AsyncMergeStatusEnqueued
|
|
}
|
|
|
|
// IsFailed reports whether the merge was attempted but did not complete.
|
|
func (r *AsyncMergeResult) IsFailed() bool {
|
|
return r != nil && r.Status == AsyncMergeStatusFailed
|
|
}
|
|
|
|
// IsPending reports whether the merge is still running in the background.
|
|
func (r *AsyncMergeResult) IsPending() bool {
|
|
return r != nil && r.Status == AsyncMergeStatusPending
|
|
}
|
|
|
|
// RepoMergeConfig fetches the repository's allowed merge methods and the
|
|
// viewer's default (last-used) merge method.
|
|
func (c *Client) RepoMergeConfig() (*RepoMergeConfig, error) {
|
|
var query struct {
|
|
Repository struct {
|
|
MergeCommitAllowed bool `graphql:"mergeCommitAllowed"`
|
|
SquashMergeAllowed bool `graphql:"squashMergeAllowed"`
|
|
RebaseMergeAllowed bool `graphql:"rebaseMergeAllowed"`
|
|
ViewerDefaultMergeMethod string `graphql:"viewerDefaultMergeMethod"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"owner": graphql.String(c.owner),
|
|
"name": graphql.String(c.repo),
|
|
}
|
|
|
|
if err := c.gql.Query("RepoMergeConfig", &query, variables); err != nil {
|
|
return nil, fmt.Errorf("querying repository merge config: %w", err)
|
|
}
|
|
|
|
r := query.Repository
|
|
return &RepoMergeConfig{
|
|
MergeAllowed: r.MergeCommitAllowed,
|
|
SquashAllowed: r.SquashMergeAllowed,
|
|
RebaseAllowed: r.RebaseMergeAllowed,
|
|
DefaultMethod: mergeMethodFromEnum(r.ViewerDefaultMergeMethod),
|
|
}, nil
|
|
}
|
|
|
|
// BaseBranchUsesMergeQueue reports whether the given base branch merges through a
|
|
// merge queue, detected via the branch's merge queue object or a MERGE_QUEUE
|
|
// repository rule. It is used only to tailor the merge wizard (skipping the
|
|
// merge-method step and switching to "enqueue" wording): the async stack merge
|
|
// itself always sends merge_action "default", which lets the server route the
|
|
// stack to the queue or a direct merge automatically.
|
|
func (c *Client) BaseBranchUsesMergeQueue(baseRef string) (bool, error) {
|
|
var query struct {
|
|
Repository struct {
|
|
MergeQueue *struct {
|
|
ID string `graphql:"id"`
|
|
} `graphql:"mergeQueue(branch: $branch)"`
|
|
Ref *struct {
|
|
Rules struct {
|
|
Nodes []struct {
|
|
Type string `graphql:"type"`
|
|
} `graphql:"nodes"`
|
|
} `graphql:"rules(first: 50)"`
|
|
} `graphql:"ref(qualifiedName: $qualified)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"owner": graphql.String(c.owner),
|
|
"name": graphql.String(c.repo),
|
|
"branch": graphql.String(baseRef),
|
|
"qualified": graphql.String("refs/heads/" + baseRef),
|
|
}
|
|
|
|
if err := c.gql.Query("BaseBranchMergeQueue", &query, variables); err != nil {
|
|
return false, fmt.Errorf("querying base branch merge queue: %w", err)
|
|
}
|
|
|
|
r := query.Repository
|
|
if r.MergeQueue != nil {
|
|
return true, nil
|
|
}
|
|
if r.Ref != nil {
|
|
for _, node := range r.Ref.Rules.Nodes {
|
|
if node.Type == "MERGE_QUEUE" {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// MergeStackAsync requests an asynchronous merge of the given pull request. For
|
|
// a stacked PR this merges all members of the stack up to and including
|
|
// prNumber. A blank method lets the server apply its default.
|
|
//
|
|
// mergeAction selects the routing: MergeActionDirectMerge or MergeActionMergeQueue
|
|
// force the respective path, and MergeActionDefault (also the fallback for an
|
|
// empty value) lets the server choose. Sending an explicit action makes the
|
|
// caller's merge-queue detection authoritative — the server rejects
|
|
// "merge_queue" on a branch with no queue instead of silently merging directly.
|
|
//
|
|
// On success the returned result is populated for the 200 (already merged) and
|
|
// 202 (enqueued for background processing) responses. A 404 returns
|
|
// ErrAsyncMergeUnavailable, a 409 (a request already exists) returns a clear
|
|
// "already exists" error, and any other non-2xx status is returned as-is.
|
|
func (c *Client) MergeStackAsync(prNumber int, method, mergeAction string) (*AsyncMergeResult, error) {
|
|
if mergeAction == "" {
|
|
mergeAction = MergeActionDefault
|
|
}
|
|
type reqBody struct {
|
|
MergeMethod string `json:"merge_method,omitempty"`
|
|
MergeAction string `json:"merge_action"`
|
|
}
|
|
|
|
body, err := json.Marshal(reqBody{MergeMethod: method, MergeAction: mergeAction})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling request: %w", err)
|
|
}
|
|
|
|
path := fmt.Sprintf("repos/%s/%s/pulls/%d/merge-async", c.owner, c.repo, prNumber)
|
|
var result AsyncMergeResult
|
|
if err := c.rest.Put(path, bytes.NewReader(body), &result); err != nil {
|
|
return nil, classifyAsyncMergeError(err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// GetAsyncMergeResult fetches the current result of a previously submitted async
|
|
// merge, identified by the UUID returned from MergeStackAsync. A valid lookup
|
|
// always returns 200, so the wrapped status/details reflect the merge's progress
|
|
// (pending, merged, enqueued, or failed).
|
|
func (c *Client) GetAsyncMergeResult(prNumber int, uuid string) (*AsyncMergeResult, error) {
|
|
path := fmt.Sprintf("repos/%s/%s/pulls/%d/merge-async/%s", c.owner, c.repo, prNumber, uuid)
|
|
var result AsyncMergeResult
|
|
if err := c.rest.Get(path, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// PRTitles fetches the titles for a set of pull request numbers in a single
|
|
// GraphQL query. Missing PRs are simply absent from the result. Best-effort:
|
|
// callers may ignore the error and proceed without titles.
|
|
func (c *Client) PRTitles(numbers []int) (map[int]string, error) {
|
|
titles := make(map[int]string, len(numbers))
|
|
if len(numbers) == 0 {
|
|
return titles, nil
|
|
}
|
|
|
|
// Batch to keep individual queries small for very large stacks.
|
|
const batchSize = 50
|
|
for start := 0; start < len(numbers); start += batchSize {
|
|
end := start + batchSize
|
|
if end > len(numbers) {
|
|
end = len(numbers)
|
|
}
|
|
|
|
var q strings.Builder
|
|
q.WriteString("query($owner:String!,$name:String!){repository(owner:$owner,name:$name){")
|
|
for i, n := range numbers[start:end] {
|
|
fmt.Fprintf(&q, "pr%d:pullRequest(number:%d){number title} ", i, n)
|
|
}
|
|
q.WriteString("}}")
|
|
|
|
var resp struct {
|
|
Repository map[string]struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
} `json:"repository"`
|
|
}
|
|
vars := map[string]interface{}{"owner": c.owner, "name": c.repo}
|
|
if err := c.gql.Do(q.String(), vars, &resp); err != nil {
|
|
return titles, fmt.Errorf("querying pull request titles: %w", err)
|
|
}
|
|
for _, pr := range resp.Repository {
|
|
if pr.Number != 0 {
|
|
titles[pr.Number] = pr.Title
|
|
}
|
|
}
|
|
}
|
|
return titles, nil
|
|
}
|
|
|
|
// classifyAsyncMergeError maps a go-gh REST error into a domain error. A 404
|
|
// means async merge isn't available for the repository or token; a 409 means a
|
|
// merge request already exists for this stack. Other errors pass through.
|
|
//
|
|
// Note: the go-gh REST client discards non-2xx response bodies, so the specific
|
|
// "details.message" from a 400 (not mergeable) and the existing UUID from a 409
|
|
// aren't recovered here. Those are rare — the in-range PRs are validated open,
|
|
// non-draft and non-merged before submitting, and real merge failures (e.g.
|
|
// conflicts) surface through the 200 poll body — so status-based handling is
|
|
// sufficient.
|
|
func classifyAsyncMergeError(err error) error {
|
|
var httpErr *api.HTTPError
|
|
if errors.As(err, &httpErr) {
|
|
switch httpErr.StatusCode {
|
|
case http.StatusNotFound:
|
|
return ErrAsyncMergeUnavailable
|
|
case http.StatusConflict:
|
|
return errors.New("a merge request already exists for this stack")
|
|
case http.StatusBadRequest:
|
|
return errors.New("the stack can no longer be merged as requested; refresh and try again")
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// mergeMethodFromEnum maps a GraphQL PullRequestMergeMethod enum value
|
|
// (MERGE/SQUASH/REBASE) to the lowercase REST API value. Unknown values fall
|
|
// back to MergeMethodMerge.
|
|
func mergeMethodFromEnum(enum string) string {
|
|
switch strings.ToUpper(enum) {
|
|
case "SQUASH":
|
|
return MergeMethodSquash
|
|
case "REBASE":
|
|
return MergeMethodRebase
|
|
default:
|
|
return MergeMethodMerge
|
|
}
|
|
}
|