Skip to content

Commit 448d92d

Browse files
committed
merge cmd
1 parent b1f0eab commit 448d92d

12 files changed

Lines changed: 2528 additions & 1 deletion

File tree

cmd/merge.go

Lines changed: 578 additions & 0 deletions
Large diffs are not rendered by default.

cmd/merge_test.go

Lines changed: 469 additions & 0 deletions
Large diffs are not rendered by default.

cmd/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ locally, then push to GitHub to create your stack of PRs.`,
112112
linkCmd.GroupID = "remote"
113113
root.AddCommand(linkCmd)
114114

115+
mergeCmd := MergeCmd(cfg)
116+
mergeCmd.GroupID = "remote"
117+
root.AddCommand(mergeCmd)
118+
115119
// Navigation commands
116120
switchCmd := SwitchCmd(cfg)
117121
switchCmd.GroupID = "nav"

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010

1111
func TestRootCmd_SubcommandRegistration(t *testing.T) {
1212
root := RootCmd()
13-
expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit"}
13+
expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit", "merge"}
1414

1515
registered := make(map[string]bool)
1616
for _, cmd := range root.Commands() {

internal/github/client_interface.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ type ClientOps interface {
1717
CreateStack(prNumbers []int) (*RemoteStack, error)
1818
AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, error)
1919
Unstack(stackNumber int) (*RemoteStack, bool, error)
20+
RepoMergeConfig() (*RepoMergeConfig, error)
21+
MergeStackAsync(prNumber int, method string) (*AsyncMergeResult, error)
22+
GetAsyncMergeResult(prNumber int, uuid string) (*AsyncMergeResult, error)
2023
}
2124

2225
// Compile-time check that Client satisfies ClientOps.

internal/github/merge_async.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package github
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"strings"
11+
12+
"github.com/cli/go-gh/v2/pkg/auth"
13+
graphql "github.com/cli/shurcooL-graphql"
14+
)
15+
16+
// Merge method values accepted by the async merge REST API.
17+
const (
18+
MergeMethodMerge = "merge"
19+
MergeMethodSquash = "squash"
20+
MergeMethodRebase = "rebase"
21+
)
22+
23+
// ErrAsyncMergeUnavailable indicates the async merge API is not available for
24+
// the repository (or the token lacks access). Surfaced on a 404 from the submit
25+
// endpoint.
26+
var ErrAsyncMergeUnavailable = errors.New("async stack merge is not available for this repository")
27+
28+
// RepoMergeConfig describes which merge methods a repository allows, along with
29+
// the viewer's default (last-used) merge method.
30+
type RepoMergeConfig struct {
31+
MergeAllowed bool
32+
SquashAllowed bool
33+
RebaseAllowed bool
34+
// DefaultMethod is the viewer's last-used merge method, or the repository
35+
// default, as one of MergeMethodMerge/MergeMethodSquash/MergeMethodRebase.
36+
DefaultMethod string
37+
}
38+
39+
// AllowedMethods returns the enabled merge methods in display order
40+
// (merge, squash, rebase).
41+
func (c RepoMergeConfig) AllowedMethods() []string {
42+
var methods []string
43+
if c.MergeAllowed {
44+
methods = append(methods, MergeMethodMerge)
45+
}
46+
if c.SquashAllowed {
47+
methods = append(methods, MergeMethodSquash)
48+
}
49+
if c.RebaseAllowed {
50+
methods = append(methods, MergeMethodRebase)
51+
}
52+
return methods
53+
}
54+
55+
// Allows reports whether the given merge method is enabled for the repository.
56+
func (c RepoMergeConfig) Allows(method string) bool {
57+
switch method {
58+
case MergeMethodMerge:
59+
return c.MergeAllowed
60+
case MergeMethodSquash:
61+
return c.SquashAllowed
62+
case MergeMethodRebase:
63+
return c.RebaseAllowed
64+
}
65+
return false
66+
}
67+
68+
// AsyncMergeDetails is the polymorphic "details" object shared by the submit and
69+
// poll responses. Fields are populated based on the current state: a queued
70+
// request carries UUID/MergeMethod/ExpectedHeadSHA, an already-merged result
71+
// carries SHA, and a failed/not-mergeable result carries only Message.
72+
type AsyncMergeDetails struct {
73+
Message string `json:"message"`
74+
UUID string `json:"uuid"`
75+
MergeMethod string `json:"merge_method"`
76+
ExpectedHeadSHA string `json:"expected_head_sha"`
77+
SHA string `json:"sha"`
78+
}
79+
80+
// AsyncMergeResult is the response body returned by both the submit and poll
81+
// async merge endpoints. StatusCode carries the HTTP status of the submit
82+
// response so callers can distinguish enqueued (202) from an existing request
83+
// (409) and an already-merged PR (200).
84+
type AsyncMergeResult struct {
85+
Queued bool `json:"queued"`
86+
Merged bool `json:"merged"`
87+
Details AsyncMergeDetails `json:"details"`
88+
StatusCode int `json:"-"`
89+
}
90+
91+
// InProgress reports whether the merge is still queued (running in the
92+
// background).
93+
func (r *AsyncMergeResult) InProgress() bool {
94+
return r != nil && r.Queued && !r.Merged
95+
}
96+
97+
// RepoMergeConfig fetches the repository's allowed merge methods and the
98+
// viewer's default (last-used) merge method.
99+
func (c *Client) RepoMergeConfig() (*RepoMergeConfig, error) {
100+
var query struct {
101+
Repository struct {
102+
MergeCommitAllowed bool `graphql:"mergeCommitAllowed"`
103+
SquashMergeAllowed bool `graphql:"squashMergeAllowed"`
104+
RebaseMergeAllowed bool `graphql:"rebaseMergeAllowed"`
105+
ViewerDefaultMergeMethod string `graphql:"viewerDefaultMergeMethod"`
106+
} `graphql:"repository(owner: $owner, name: $name)"`
107+
}
108+
109+
variables := map[string]interface{}{
110+
"owner": graphql.String(c.owner),
111+
"name": graphql.String(c.repo),
112+
}
113+
114+
if err := c.gql.Query("RepoMergeConfig", &query, variables); err != nil {
115+
return nil, fmt.Errorf("querying repository merge config: %w", err)
116+
}
117+
118+
r := query.Repository
119+
return &RepoMergeConfig{
120+
MergeAllowed: r.MergeCommitAllowed,
121+
SquashAllowed: r.SquashMergeAllowed,
122+
RebaseAllowed: r.RebaseMergeAllowed,
123+
DefaultMethod: mergeMethodFromEnum(r.ViewerDefaultMergeMethod),
124+
}, nil
125+
}
126+
127+
// MergeStackAsync requests an asynchronous merge of the given pull request. For
128+
// a stacked PR this merges all members of the stack up to and including
129+
// prNumber. A blank method lets the server apply its default.
130+
//
131+
// The returned result is populated for the 200 (already merged), 202 (enqueued)
132+
// 409 (a request already exists) and 400 (not mergeable) responses; the HTTP
133+
// status is recorded on StatusCode. A 404 returns ErrAsyncMergeUnavailable.
134+
func (c *Client) MergeStackAsync(prNumber int, method string) (*AsyncMergeResult, error) {
135+
type reqBody struct {
136+
MergeMethod string `json:"merge_method,omitempty"`
137+
}
138+
139+
body, err := json.Marshal(reqBody{MergeMethod: method})
140+
if err != nil {
141+
return nil, fmt.Errorf("marshaling request: %w", err)
142+
}
143+
144+
path := fmt.Sprintf("repos/%s/%s/pulls/%d/merge-async", c.owner, c.repo, prNumber)
145+
resp, err := c.doAsyncRequest(http.MethodPut, path, bytes.NewReader(body))
146+
if err != nil {
147+
return nil, err
148+
}
149+
defer func() { _ = resp.Body.Close() }()
150+
151+
switch resp.StatusCode {
152+
case http.StatusOK, http.StatusAccepted, http.StatusConflict, http.StatusBadRequest:
153+
return decodeAsyncMergeResult(resp)
154+
case http.StatusNotFound:
155+
return nil, ErrAsyncMergeUnavailable
156+
default:
157+
return nil, asyncMergeError(resp)
158+
}
159+
}
160+
161+
// GetAsyncMergeResult fetches the current result of a previously submitted async
162+
// merge, identified by the UUID returned from MergeStackAsync.
163+
func (c *Client) GetAsyncMergeResult(prNumber int, uuid string) (*AsyncMergeResult, error) {
164+
path := fmt.Sprintf("repos/%s/%s/pulls/%d/merge-async/%s", c.owner, c.repo, prNumber, uuid)
165+
resp, err := c.doAsyncRequest(http.MethodGet, path, nil)
166+
if err != nil {
167+
return nil, err
168+
}
169+
defer func() { _ = resp.Body.Close() }()
170+
171+
if resp.StatusCode == http.StatusOK {
172+
return decodeAsyncMergeResult(resp)
173+
}
174+
return nil, asyncMergeError(resp)
175+
}
176+
177+
// doAsyncRequest issues an authenticated request to the REST API and returns the
178+
// raw response without treating non-2xx statuses as errors, so the caller can
179+
// read the merge result body for 4xx responses (which carry the UUID/message).
180+
func (c *Client) doAsyncRequest(method, path string, body io.Reader) (*http.Response, error) {
181+
req, err := http.NewRequest(method, c.base+path, body)
182+
if err != nil {
183+
return nil, err
184+
}
185+
req.Header.Set("Accept", "application/vnd.github+json")
186+
if body != nil {
187+
req.Header.Set("Content-Type", "application/json")
188+
}
189+
return c.http.Do(req)
190+
}
191+
192+
func decodeAsyncMergeResult(resp *http.Response) (*AsyncMergeResult, error) {
193+
var r AsyncMergeResult
194+
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
195+
return nil, fmt.Errorf("decoding merge response: %w", err)
196+
}
197+
r.StatusCode = resp.StatusCode
198+
return &r, nil
199+
}
200+
201+
// asyncMergeError builds an error from an unexpected (403/422/5xx) response,
202+
// extracting the API message when present.
203+
func asyncMergeError(resp *http.Response) error {
204+
b, _ := io.ReadAll(resp.Body)
205+
var parsed struct {
206+
Message string `json:"message"`
207+
}
208+
_ = json.Unmarshal(b, &parsed)
209+
if parsed.Message != "" {
210+
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, parsed.Message)
211+
}
212+
if trimmed := strings.TrimSpace(string(b)); trimmed != "" {
213+
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, trimmed)
214+
}
215+
return fmt.Errorf("HTTP %d", resp.StatusCode)
216+
}
217+
218+
// mergeMethodFromEnum maps a GraphQL PullRequestMergeMethod enum value
219+
// (MERGE/SQUASH/REBASE) to the lowercase REST API value. Unknown values fall
220+
// back to MergeMethodMerge.
221+
func mergeMethodFromEnum(enum string) string {
222+
switch strings.ToUpper(enum) {
223+
case "SQUASH":
224+
return MergeMethodSquash
225+
case "REBASE":
226+
return MergeMethodRebase
227+
default:
228+
return MergeMethodMerge
229+
}
230+
}
231+
232+
// restBaseURL derives the REST API base URL for a host, mirroring go-gh's
233+
// internal restPrefix so raw requests target the same endpoint as the REST
234+
// client.
235+
func restBaseURL(host string) string {
236+
if host == "" {
237+
host = "github.com"
238+
}
239+
normalized := auth.NormalizeHostname(host)
240+
if auth.IsEnterprise(normalized) {
241+
return fmt.Sprintf("https://%s/api/v3/", normalized)
242+
}
243+
if strings.EqualFold(normalized, "github.localhost") {
244+
return fmt.Sprintf("http://api.%s/", normalized)
245+
}
246+
return fmt.Sprintf("https://api.%s/", normalized)
247+
}

0 commit comments

Comments
 (0)