feat(github): add merge queue actions (#2755)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-17 23:28:59 +00:00
committed by GitHub
parent c38cec3044
commit ad21460007
49 changed files with 1184 additions and 216 deletions
+30 -2
View File
@@ -2,6 +2,7 @@ package github
import (
"context"
"fmt"
"time"
)
@@ -13,6 +14,33 @@ type RateLimitFetcher interface {
FetchRateLimit(ctx context.Context) error
}
type MergeOutcome string
const (
MergeOutcomeMerged MergeOutcome = "merged"
MergeOutcomeQueued MergeOutcome = "queued"
mergeStatusFailed = "failed"
)
type mergeAsyncResponse struct {
Status string `json:"status"`
UUID string `json:"uuid"`
Message string `json:"message"`
}
func normalizeMergeOutcome(status string) (MergeOutcome, error) {
switch status {
case string(MergeOutcomeMerged):
return MergeOutcomeMerged, nil
case "enqueued", "queued":
return MergeOutcomeQueued, nil
case mergeStatusFailed:
return "", fmt.Errorf("GitHub rejected merge: %s", status)
default:
return "", fmt.Errorf("unexpected GitHub merge status %q", status)
}
}
// Client defines the interface for interacting with the GitHub API.
type Client interface {
// IsAuthenticated checks if the client is authenticated with GitHub.
@@ -91,8 +119,8 @@ type Client interface {
// RequestReviewers requests reviews from GitHub users on a pull request.
RequestReviewers(ctx context.Context, owner, repo string, number int, reviewers []string) error
// MergePR merges a pull request. mergeMethod is one of "merge", "squash", "rebase".
MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error
// MergePR merges a pull request immediately or queues it according to GitHub policy.
MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error)
// ListRepoBranches lists branches for a repository.
ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error)
+4 -2
View File
@@ -759,7 +759,7 @@ func (c *Controller) httpMergePR(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "merge_method must be merge, squash, or rebase"})
return
}
principal, err := c.service.MergePRForWorkspace(
principal, outcome, err := c.service.MergePRForWorkspace(
ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx),
owner, repo, number, req.MergeMethod,
)
@@ -771,6 +771,8 @@ func (c *Controller) httpMergePR(ctx *gin.Context) {
var apiErr *GitHubAPIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusBadRequest:
status = http.StatusBadRequest
case http.StatusMethodNotAllowed, http.StatusConflict:
status = http.StatusConflict
case http.StatusUnauthorized:
@@ -784,7 +786,7 @@ func (c *Controller) httpMergePR(ctx *gin.Context) {
ctx.JSON(status, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"merged": true, "principal": principal})
ctx.JSON(http.StatusOK, gin.H{"status": outcome, "principal": principal})
}
func (c *Controller) httpListPRWatches(ctx *gin.Context) {
+41 -13
View File
@@ -52,7 +52,7 @@ func TestValidGitHubCallbackState(t *testing.T) {
type stubClient struct {
getPRFunc func(ctx context.Context, owner, repo string, number int) (*PR, error)
getIssueFunc func(ctx context.Context, owner, repo string, number int) (*Issue, error)
mergePRFn func(ctx context.Context, owner, repo string, number int, mergeMethod string) error
mergePRFn func(ctx context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error)
getRepoMergeMethodsFn func() (RepoMergeMethods, error)
requestReviewersFn func(ctx context.Context, owner, repo string, number int, reviewers []string) error
}
@@ -132,11 +132,11 @@ func (s *stubClient) RequestReviewers(ctx context.Context, owner, repo string, n
}
return nil
}
func (s *stubClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error {
func (s *stubClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error) {
if s.mergePRFn != nil {
return s.mergePRFn(ctx, owner, repo, number, mergeMethod)
}
return nil
return MergeOutcomeMerged, nil
}
func (s *stubClient) ListRepoBranches(context.Context, string, string) ([]RepoBranch, error) {
return nil, nil
@@ -1780,12 +1780,12 @@ func TestHttpMergePR_Success(t *testing.T) {
mergeMethod string
}
sc := &stubClient{
mergePRFn: func(_ context.Context, owner, repo string, number int, mergeMethod string) error {
mergePRFn: func(_ context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error) {
called.owner = owner
called.repo = repo
called.number = number
called.mergeMethod = mergeMethod
return nil
return MergeOutcomeMerged, nil
},
}
router, _ := setupControllerTest(sc)
@@ -1799,11 +1799,39 @@ func TestHttpMergePR_Success(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response struct {
Status MergeOutcome `json:"status"`
}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Status != MergeOutcomeMerged {
t.Errorf("status = %q, want merged", response.Status)
}
if called.owner != "acme" || called.repo != "widget" || called.number != 42 || called.mergeMethod != "squash" {
t.Errorf("unexpected MergePR args: %+v", called)
}
}
func TestHttpMergePR_Queued(t *testing.T) {
router, _ := setupControllerTest(&stubClient{
mergePRFn: func(context.Context, string, string, int, string) (MergeOutcome, error) {
return MergeOutcomeQueued, nil
},
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/github/prs/acme/widget/42/merge", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"status":"queued"`) {
t.Errorf("body = %s, want queued outcome", w.Body.String())
}
}
func TestHttpMergePR_InvalidMethod(t *testing.T) {
router, _ := setupControllerTest(&stubClient{})
@@ -1824,9 +1852,9 @@ func TestHttpMergePR_EmptyBody_ResolvesToAllowedMethod(t *testing.T) {
// should resolve to the first allowed method instead.
var gotMethod string
sc := &stubClient{
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) error {
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) (MergeOutcome, error) {
gotMethod = mergeMethod
return nil
return MergeOutcomeMerged, nil
},
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
// Squash-only repo (the case that surfaced this bug).
@@ -1853,9 +1881,9 @@ func TestHttpMergePR_ExplicitMethod_Passthrough(t *testing.T) {
var gotMethod string
var lookupCalls int
sc := &stubClient{
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) error {
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) (MergeOutcome, error) {
gotMethod = mergeMethod
return nil
return MergeOutcomeMerged, nil
},
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
lookupCalls++
@@ -1887,9 +1915,9 @@ func TestHttpMergePR_EmptyBody_LookupFails_FallsBackToGitHubDefault(t *testing.T
// than refusing to merge because of an unrelated lookup failure.
var gotMethod string
sc := &stubClient{
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) error {
mergePRFn: func(_ context.Context, _, _ string, _ int, mergeMethod string) (MergeOutcome, error) {
gotMethod = mergeMethod
return nil
return MergeOutcomeMerged, nil
},
getRepoMergeMethodsFn: func() (RepoMergeMethods, error) {
return RepoMergeMethods{}, fmt.Errorf("rate limited")
@@ -2402,8 +2430,8 @@ func TestHandleListAccessibleRepos_PreservesAPIErrorStatus(t *testing.T) {
func TestHttpMergePR_Conflict(t *testing.T) {
sc := &stubClient{
mergePRFn: func(context.Context, string, string, int, string) error {
return &GitHubAPIError{StatusCode: http.StatusMethodNotAllowed, Endpoint: "/merge", Body: "not mergeable"}
mergePRFn: func(context.Context, string, string, int, string) (MergeOutcome, error) {
return "", &GitHubAPIError{StatusCode: http.StatusMethodNotAllowed, Endpoint: "/merge", Body: "not mergeable"}
},
}
router, _ := setupControllerTest(sc)
+45 -9
View File
@@ -901,23 +901,55 @@ func (c *GHClient) RequestReviewers(ctx context.Context, owner, repo string, num
return nil
}
func (c *GHClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error {
endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/merge", owner, repo, number)
args := []string{"api", endpoint, "-X", "PUT"}
func (c *GHClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error) {
endpoint := fmt.Sprintf("repos/%s/%s/pulls/%d/merge-async", owner, repo, number)
args := []string{"api", endpoint, "-X", "PUT", "-f", "merge_action=default"}
if mergeMethod != "" {
args = append(args, "-f", "merge_method="+mergeMethod)
}
_, err := c.run(ctx, args...)
out, err := c.run(ctx, args...)
conflictBody := false
if err != nil {
// Surface status-based errors as GitHubAPIError so httpMergePR can
// translate 405 (not mergeable) / 409 (conflict) to HTTP 409 for
// gh CLI users too, matching the PAT path.
// translate merge rejections for gh CLI users, matching the PAT path.
if code, ok := ghMergeStatusCode(err); ok {
return &GitHubAPIError{StatusCode: code, Endpoint: endpoint, Body: err.Error()}
if code != http.StatusConflict {
return "", &GitHubAPIError{StatusCode: code, Endpoint: endpoint, Body: err.Error()}
}
out = err.Error()
conflictBody = true
} else {
return "", fmt.Errorf("merge PR #%d: %w", number, err)
}
return fmt.Errorf("merge PR #%d: %w", number, err)
}
return nil
var response mergeAsyncResponse
jsonBody := out
if start := strings.Index(jsonBody, "{"); start >= 0 {
if end := strings.LastIndex(jsonBody, "}"); end >= start {
jsonBody = jsonBody[start : end+1]
}
} else if conflictBody {
return "", &GitHubAPIError{StatusCode: http.StatusConflict, Endpoint: endpoint, Body: out}
}
if unmarshalErr := json.Unmarshal([]byte(jsonBody), &response); unmarshalErr != nil {
return "", fmt.Errorf("decode GitHub merge response: %w", unmarshalErr)
}
for response.Status == "pending" {
if response.UUID == "" {
return "", fmt.Errorf("GitHub merge response is pending without a UUID")
}
result, runErr := c.run(ctx, "api", endpoint+"/"+response.UUID)
if runErr != nil {
return "", fmt.Errorf("poll merge PR #%d: %w", number, runErr)
}
if unmarshalErr := json.Unmarshal([]byte(result), &response); unmarshalErr != nil {
return "", fmt.Errorf("decode GitHub merge response: %w", unmarshalErr)
}
}
if response.Status == mergeStatusFailed {
return "", fmt.Errorf("GitHub rejected merge: %s", response.Message)
}
return normalizeMergeOutcome(response.Status)
}
// ghMergeStatusCode extracts the HTTP status code from a gh CLI merge error.
@@ -935,6 +967,10 @@ func ghMergeStatusCode(err error) (int, bool) {
return http.StatusForbidden, true
}
s := err.Error()
if strings.Contains(s, "HTTP 400") || strings.Contains(s, "status: 400") ||
strings.Contains(s, "400 Bad Request") {
return http.StatusBadRequest, true
}
if strings.Contains(s, "HTTP 405") || strings.Contains(s, "status: 405") ||
strings.Contains(s, "405 Method Not Allowed") {
return http.StatusMethodNotAllowed, true
@@ -530,21 +530,23 @@ func TestGHClient_SubmitReview(t *testing.T) {
func TestGHClient_MergePR(t *testing.T) {
t.Run("sends the merge method", func(t *testing.T) {
calls := newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: `{"merged":true}`})
if err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, "squash"); err != nil {
calls := newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: `{"status":"merged"}`})
if _, err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, "squash"); err != nil {
t.Fatalf("MergePR: %v", err)
}
assertGHArgv(t, calls(t), 0, []string{
"api", "repos/acme/widget/pulls/42/merge", "-X", "PUT", "-f", "merge_method=squash",
"api", "repos/acme/widget/pulls/42/merge-async", "-X", "PUT",
"-f", "merge_action=default", "-f", "merge_method=squash",
})
})
t.Run("omits the merge method when unset", func(t *testing.T) {
calls := newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: `{"merged":true}`})
if err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, ""); err != nil {
calls := newFakeGH(t, ghResponse{Prefix: "api repos/", Stdout: `{"status":"merged"}`})
if _, err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, ""); err != nil {
t.Fatalf("MergePR: %v", err)
}
assertGHArgv(t, calls(t), 0, []string{
"api", "repos/acme/widget/pulls/42/merge", "-X", "PUT",
"api", "repos/acme/widget/pulls/42/merge-async", "-X", "PUT",
"-f", "merge_action=default",
})
})
}
@@ -557,14 +559,14 @@ func TestGHClient_MergePR_StatusIsRecoverable(t *testing.T) {
want int
}{
{"gh: HTTP 405: Method Not Allowed", http.StatusMethodNotAllowed},
{"gh: HTTP 409: Conflict", http.StatusConflict},
{"gh: HTTP 404: Not Found", http.StatusNotFound},
{"gh: HTTP 403: Forbidden", http.StatusForbidden},
{"gh: merge conflict (HTTP 409)", http.StatusConflict},
}
for _, tc := range cases {
t.Run(http.StatusText(tc.want), func(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: tc.stderr, Exit: 1})
err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, "merge")
_, err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, "merge")
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want a *GitHubAPIError", err)
@@ -572,16 +574,27 @@ func TestGHClient_MergePR_StatusIsRecoverable(t *testing.T) {
if apiErr.StatusCode != tc.want {
t.Errorf("status = %d, want %d", apiErr.StatusCode, tc.want)
}
if apiErr.Endpoint != "repos/acme/widget/pulls/42/merge" {
if apiErr.Endpoint != "repos/acme/widget/pulls/42/merge-async" {
t.Errorf("endpoint = %q", apiErr.Endpoint)
}
})
}
}
func TestGHClient_MergePR_AlreadyQueuedIsIdempotent(t *testing.T) {
newFakeGH(t,
ghResponse{Prefix: "api repos/acme/widget/pulls/42/merge-async/request-1", Stdout: `{"status":"enqueued","uuid":"request-1"}`},
ghResponse{Prefix: "api repos/", Stderr: `{"status":"pending","uuid":"request-1"} gh: HTTP 409`, Exit: 1},
)
outcome, err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, "squash")
if err != nil || outcome != MergeOutcomeQueued {
t.Fatalf("outcome = %q, err = %v, want queued", outcome, err)
}
}
func TestGHClient_MergePR_UnmappedStatusFallsBackToAPlainWrap(t *testing.T) {
newFakeGH(t, ghResponse{Prefix: "api repos/", Stderr: "gh: HTTP 500: server error", Exit: 1})
err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, "merge")
_, err := NewGHClient().MergePR(context.Background(), "acme", "widget", 42, "merge")
if err == nil || !strings.Contains(err.Error(), "merge PR #42") {
t.Fatalf("err = %v, want a plain wrap naming the PR", err)
}
@@ -69,6 +69,7 @@ func TestGhMergeStatusCode(t *testing.T) {
{"404 Not Found phrase", errors.New("404 Not Found"), true, 404},
{"status 404", errors.New("request failed (status: 404)"), true, 404},
{"HTTP 403", errors.New("HTTP 403: Forbidden"), true, 403},
{"HTTP 400", errors.New("HTTP 400: Bad Request"), true, 400},
{"HTTP 405", errors.New("gh: HTTP 405: Method Not Allowed"), true, 405},
{"status 405", errors.New("status: 405"), true, 405},
{"405 phrase", errors.New("405 Method Not Allowed"), true, 405},
+18 -2
View File
@@ -115,6 +115,7 @@ type MockClient struct {
submittedReviews []submittedReview
requestedReviews []requestedReviewers
mergedPRs []mergedPR
mergeOutcomes map[prKey]MergeOutcome
mergeMethods map[repoKey]RepoMergeMethods
repositoryDetails map[repoKey]*GitHubRepository
gists map[string]mockGist
@@ -167,6 +168,7 @@ func NewMockClient() *MockClient {
prCommitsFailures: make(map[prKey]int),
commitDetails: make(map[commitDetailKey]PRCommitDetail),
mergeMethods: make(map[repoKey]RepoMergeMethods),
mergeOutcomes: make(map[prKey]MergeOutcome),
repositoryDetails: make(map[repoKey]*GitHubRepository),
gists: make(map[string]mockGist),
repoFiles: make(map[repoKey][]repoFileEntry),
@@ -748,19 +750,32 @@ func (m *MockClient) SetRepoMergeMethods(owner, repo string, methods RepoMergeMe
m.mergeMethods[repoKey{owner, repo}] = methods
}
func (m *MockClient) MergePR(_ context.Context, owner, repo string, number int, mergeMethod string) error {
func (m *MockClient) MergePR(_ context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.mergedPRs = append(m.mergedPRs, mergedPR{
Owner: owner, Repo: repo, Number: number, MergeMethod: mergeMethod,
})
outcome := m.mergeOutcomes[prKey{owner, repo, number}]
if outcome == "" {
outcome = MergeOutcomeMerged
}
if outcome == MergeOutcomeQueued {
return outcome, nil
}
now := time.Now().UTC()
if pr, ok := m.prs[prKey{owner, repo, number}]; ok {
pr.State = "merged"
pr.MergedAt = &now
pr.Mergeable = false
}
return nil
return outcome, nil
}
func (m *MockClient) SetMergeOutcome(owner, repo string, number int, outcome MergeOutcome) {
m.mu.Lock()
defer m.mu.Unlock()
m.mergeOutcomes[prKey{owner, repo, number}] = outcome
}
func (m *MockClient) CreateGist(_ context.Context, in CreateGistInput) (*GistResponse, error) {
@@ -1034,6 +1049,7 @@ func (m *MockClient) Reset() {
m.submittedReviews = nil
m.requestedReviews = nil
m.mergedPRs = nil
m.mergeOutcomes = make(map[prKey]MergeOutcome)
m.mergeMethods = make(map[repoKey]RepoMergeMethods)
m.gists = make(map[string]mockGist)
m.deletedGists = nil
@@ -51,6 +51,7 @@ func (c *MockController) RegisterRoutes(router *gin.Engine) {
api.POST("/files", c.addPRFiles)
api.POST("/commits", c.addPRCommits)
api.PUT("/pr-commits-failures", c.setPRCommitsFailures)
api.PUT("/merge-outcomes", c.setMergeOutcome)
api.POST("/commit-details", c.addPRCommitDetail)
api.POST("/branches", c.addBranches)
api.POST("/repo-files", c.addRepoFiles)
@@ -518,6 +519,23 @@ func (c *MockController) setPRCommitsFailures(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"failures": req.Failures})
}
func (c *MockController) setMergeOutcome(ctx *gin.Context) {
var req struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Outcome MergeOutcome `json:"outcome"`
}
if err := ctx.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Owner) == "" ||
strings.TrimSpace(req.Repo) == "" || req.Number <= 0 ||
(req.Outcome != MergeOutcomeMerged && req.Outcome != MergeOutcomeQueued) {
respondInvalidPayload(ctx)
return
}
c.mock.SetMergeOutcome(req.Owner, req.Repo, req.Number, req.Outcome)
ctx.JSON(http.StatusOK, gin.H{"outcome": req.Outcome})
}
func (c *MockController) addPRCommitDetail(ctx *gin.Context) {
var req struct {
Owner string `json:"owner"`
+2 -2
View File
@@ -125,8 +125,8 @@ func (c *NoopClient) RequestReviewers(context.Context, string, string, int, []st
return ErrNoClient
}
func (c *NoopClient) MergePR(context.Context, string, string, int, string) error {
return ErrNoClient
func (c *NoopClient) MergePR(context.Context, string, string, int, string) (MergeOutcome, error) {
return "", ErrNoClient
}
func (c *NoopClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
+40 -27
View File
@@ -728,17 +728,36 @@ func (c *PATClient) RequestReviewers(ctx context.Context, owner, repo string, nu
return c.post(ctx, endpoint, jsonBody)
}
func (c *PATClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error {
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, number)
payload := map[string]string{}
func (c *PATClient) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error) {
endpoint := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge-async", owner, repo, number)
payload := map[string]string{"merge_action": "default"}
if mergeMethod != "" {
payload["merge_method"] = mergeMethod
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal merge payload: %w", err)
return "", fmt.Errorf("marshal merge payload: %w", err)
}
return c.put(ctx, endpoint, jsonBody)
var response mergeAsyncResponse
if err := c.putJSON(ctx, endpoint, jsonBody, &response); err != nil {
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusConflict ||
json.Unmarshal([]byte(apiErr.Body), &response) != nil || response.UUID == "" {
return "", err
}
}
for response.Status == "pending" {
if response.UUID == "" {
return "", fmt.Errorf("GitHub merge response is pending without a UUID")
}
if err := c.get(ctx, endpoint+"/"+response.UUID, &response); err != nil {
return "", err
}
}
if response.Status == mergeStatusFailed {
return "", fmt.Errorf("GitHub rejected merge: %s", response.Message)
}
return normalizeMergeOutcome(response.Status)
}
func (c *PATClient) CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error) {
@@ -848,8 +867,18 @@ func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) erro
// postJSON sends a POST and decodes the response body into result.
// 2xx with no body returns nil; 4xx/5xx returns a *GitHubAPIError.
func (c *PATClient) postJSON(ctx context.Context, endpoint string, body []byte, result interface{}) error {
return c.requestJSON(ctx, http.MethodPost, endpoint, body, result)
}
func (c *PATClient) requestJSON(
ctx context.Context,
method string,
endpoint string,
body []byte,
result interface{},
) error {
u := githubAPIBase + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body))
if err != nil {
return err
}
@@ -858,7 +887,7 @@ func (c *PATClient) postJSON(ctx context.Context, endpoint string, body []byte,
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request POST %s: %w", endpoint, err)
return fmt.Errorf("request %s %s: %w", method, endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
c.recordRateHeaders(resp, endpoint)
@@ -900,27 +929,11 @@ func (c *PATClient) delete(ctx context.Context, endpoint string) error {
}
func (c *PATClient) put(ctx context.Context, endpoint string, body []byte) error {
u := githubAPIBase + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(body))
if err != nil {
return err
}
c.setGitHubHeaders(req)
req.Header.Set("Content-Type", "application/json")
return c.putJSON(ctx, endpoint, body, nil)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request PUT %s: %w", endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
c.recordRateHeaders(resp, endpoint)
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody)
return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)}
}
return nil
func (c *PATClient) putJSON(ctx context.Context, endpoint string, body []byte, result interface{}) error {
return c.requestJSON(ctx, http.MethodPut, endpoint, body, result)
}
func (c *PATClient) get(ctx context.Context, endpoint string, result interface{}) error {
@@ -73,31 +73,31 @@ func TestPATClient_SubmitReview_ErrorIncludesStatusAndBody(t *testing.T) {
func TestPATClient_MergePR(t *testing.T) {
t.Run("sends the merge method", func(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusOK, `{"merged":true}`
return http.StatusOK, `{"status":"merged"}`
})
if err := c.MergePR(context.Background(), "acme", "widget", 42, "squash"); err != nil {
if _, err := c.MergePR(context.Background(), "acme", "widget", 42, "squash"); err != nil {
t.Fatalf("MergePR: %v", err)
}
got := (*requests)[0]
if got.Method != http.MethodPut {
t.Errorf("method = %q, want PUT", got.Method)
}
if got.Path != "/repos/acme/widget/pulls/42/merge" {
if got.Path != "/repos/acme/widget/pulls/42/merge-async" {
t.Errorf("path = %q", got.Path)
}
if got.Body != `{"merge_method":"squash"}` {
t.Errorf("body = %q, want the merge_method payload", got.Body)
if got.Body != `{"merge_action":"default","merge_method":"squash"}` {
t.Errorf("body = %q, want queue-aware merge payload", got.Body)
}
})
t.Run("omits the merge method when unset", func(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusOK, `{"merged":true}`
return http.StatusOK, `{"status":"merged"}`
})
if err := c.MergePR(context.Background(), "acme", "widget", 42, ""); err != nil {
if _, err := c.MergePR(context.Background(), "acme", "widget", 42, ""); err != nil {
t.Fatalf("MergePR: %v", err)
}
if (*requests)[0].Body != `{}` {
t.Errorf("body = %q, want an empty object so GitHub picks its default",
if (*requests)[0].Body != `{"merge_action":"default"}` {
t.Errorf("body = %q, want GitHub to choose direct or queued merge",
(*requests)[0].Body)
}
})
@@ -111,7 +111,7 @@ func TestPATClient_MergePR_StatusIsRecoverable(t *testing.T) {
c, _ := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return status, `{"message":"Pull Request is not mergeable"}`
})
err := c.MergePR(context.Background(), "acme", "widget", 42, "merge")
_, err := c.MergePR(context.Background(), "acme", "widget", 42, "merge")
var apiErr *GitHubAPIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want a *GitHubAPIError", err)
@@ -119,7 +119,7 @@ func TestPATClient_MergePR_StatusIsRecoverable(t *testing.T) {
if apiErr.StatusCode != status {
t.Errorf("status = %d, want %d", apiErr.StatusCode, status)
}
if apiErr.Endpoint != "/repos/acme/widget/pulls/42/merge" {
if apiErr.Endpoint != "/repos/acme/widget/pulls/42/merge-async" {
t.Errorf("endpoint = %q", apiErr.Endpoint)
}
if !strings.Contains(apiErr.Body, "not mergeable") {
@@ -129,6 +129,19 @@ func TestPATClient_MergePR_StatusIsRecoverable(t *testing.T) {
}
}
func TestPATClient_MergePR_AlreadyQueuedIsIdempotent(t *testing.T) {
c, _ := newRecordingPATServerFunc(t, func(r *http.Request) (int, string) {
if r.Method == http.MethodGet {
return http.StatusOK, `{"status":"enqueued","uuid":"request-1"}`
}
return http.StatusConflict, `{"status":"pending","uuid":"request-1"}`
})
outcome, err := c.MergePR(context.Background(), "acme", "widget", 42, "squash")
if err != nil || outcome != MergeOutcomeQueued {
t.Fatalf("outcome = %q, err = %v, want queued", outcome, err)
}
}
func TestPATClient_CreateGist(t *testing.T) {
c, requests := newRecordingPATServerFunc(t, func(*http.Request) (int, string) {
return http.StatusCreated, `{"id":"abc123","html_url":"https://gist.github.com/abc123",
@@ -197,7 +197,8 @@ func (s *Service) MergePRForAutomation(
if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil {
return err
}
return s.mergePRWithClient(
_, err = s.mergePRWithClient(
ctx, resolved.Client, resolved.CacheScope, owner, repo, number, mergeMethod,
)
return err
}
+9 -8
View File
@@ -124,9 +124,9 @@ func (s *Service) requestReviewersWithClient(
// repo allows. The caller is expected to refresh PR feedback after a
// successful merge — the background poller will catch the merged state on
// its next pass.
func (s *Service) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) error {
func (s *Service) MergePR(ctx context.Context, owner, repo string, number int, mergeMethod string) (MergeOutcome, error) {
if s.client == nil {
return ErrNoClient
return "", ErrNoClient
}
return s.mergePRWithClient(ctx, s.client, "legacy", owner, repo, number, mergeMethod)
}
@@ -141,20 +141,21 @@ func (s *Service) MergePRForWorkspace(
repo string,
number int,
mergeMethod string,
) (AuthPrincipal, error) {
) (AuthPrincipal, MergeOutcome, error) {
if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil {
return AuthPrincipal{}, err
return AuthPrincipal{}, "", err
}
resolved, err := s.resolvePersonalWriteClient(ctx, workspaceID, userID, owner, repo)
if err != nil {
return AuthPrincipal{}, err
return AuthPrincipal{}, "", err
}
if err := requireGitHubCapability(resolved, CapabilityPullRequestWrite); err != nil {
return AuthPrincipal{}, err
return AuthPrincipal{}, "", err
}
return resolved.Principal, s.mergePRWithClient(
outcome, err := s.mergePRWithClient(
ctx, resolved.Client, resolved.CacheScope, owner, repo, number, mergeMethod,
)
return resolved.Principal, outcome, err
}
func (s *Service) mergePRWithClient(
@@ -165,7 +166,7 @@ func (s *Service) mergePRWithClient(
repo string,
number int,
mergeMethod string,
) error {
) (MergeOutcome, error) {
if mergeMethod == "" {
// Resolve to an allowed method up-front so we don't rely on GitHub's
// "default to merge" behavior, which 405s on repos that disallow
@@ -0,0 +1,118 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import type { AppState } from "@/lib/state/store";
import type { TaskPR } from "@/lib/types/github";
import { PRMergeButton } from "./pr-merge-button";
const mergePRMock = vi.hoisted(() => vi.fn());
const MERGE_BUTTON_TEST_ID = "pr-merge-button";
vi.mock("@/lib/api/domains/github-api", () => ({ mergePR: mergePRMock }));
vi.mock("@/hooks/domains/github/use-github-status", () => ({
useGitHubStatus: () => ({ status: null }),
}));
vi.mock("@/hooks/domains/github/use-repo-merge-methods", () => ({
useRepoMergeMethods: () => null,
}));
const taskPR = {
id: "pr-1",
task_id: "task-1",
owner: "acme",
repo: "site",
pr_number: 42,
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
review_count: 2,
required_reviews: 2,
pending_review_count: 0,
} as TaskPR;
function renderButton(onMerged = vi.fn()) {
const initialState = { workspaces: { activeId: "workspace-1" } } as Partial<AppState>;
render(
<StateProvider initialState={initialState}>
<ToastProvider>
<PRMergeButton taskPR={taskPR} onMerged={onMerged} />
</ToastProvider>
</StateProvider>,
);
return onMerged;
}
beforeEach(() => mergePRMock.mockReset());
afterEach(() => cleanup());
describe("PRMergeButton", () => {
it.each([
["merged", "PR merged"],
["queued", "PR added to merge queue"],
] as const)("reports a %s terminal outcome", async (status, message) => {
mergePRMock.mockResolvedValue({ status });
const onMerged = renderButton();
fireEvent.click(screen.getByTestId(MERGE_BUTTON_TEST_ID));
expect(await screen.findByText(message)).not.toBeNull();
expect(onMerged).toHaveBeenCalledOnce();
expect(screen.queryByTestId(MERGE_BUTTON_TEST_ID)).toBeNull();
});
it("remains retryable after a rejected merge", async () => {
mergePRMock.mockRejectedValueOnce(new Error("merge rejected")).mockResolvedValue({
status: "queued",
});
renderButton();
fireEvent.click(screen.getByTestId(MERGE_BUTTON_TEST_ID));
expect(await screen.findByText("merge rejected")).not.toBeNull();
fireEvent.click(screen.getByTestId(MERGE_BUTTON_TEST_ID));
await waitFor(() => expect(mergePRMock).toHaveBeenCalledTimes(2));
});
it("suppresses duplicate clicks while the request is pending", async () => {
let resolve!: (value: { status: "queued" }) => void;
mergePRMock.mockReturnValue(new Promise((done) => (resolve = done)));
renderButton();
const button = screen.getByTestId(MERGE_BUTTON_TEST_ID);
fireEvent.click(button);
fireEvent.click(button);
expect(mergePRMock).toHaveBeenCalledOnce();
resolve({ status: "queued" });
expect(await screen.findByText("PR added to merge queue")).not.toBeNull();
});
it("does not apply a delayed acceptance to a different pull request", async () => {
let resolve!: (value: { status: "queued" }) => void;
mergePRMock.mockReturnValue(new Promise((done) => (resolve = done)));
const initialState = { workspaces: { activeId: "workspace-1" } } as Partial<AppState>;
const view = render(
<StateProvider initialState={initialState}>
<ToastProvider>
<PRMergeButton taskPR={taskPR} />
</ToastProvider>
</StateProvider>,
);
fireEvent.click(screen.getByTestId(MERGE_BUTTON_TEST_ID));
const nextPR = { ...taskPR, id: "pr-2", pr_number: 43 };
view.rerender(
<StateProvider initialState={initialState}>
<ToastProvider>
<PRMergeButton taskPR={nextPR} />
</ToastProvider>
</StateProvider>,
);
resolve({ status: "queued" });
await screen.findByText("PR added to merge queue");
expect(screen.getByTestId(MERGE_BUTTON_TEST_ID)).not.toBeNull();
});
});
+38 -21
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { IconChevronDown, IconGitMerge } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
@@ -16,7 +16,7 @@ import { useRepoMergeMethods } from "@/hooks/domains/github/use-repo-merge-metho
import { mergePR } from "@/lib/api/domains/github-api";
import { getGitHubMutationActor } from "@/lib/github-auth";
import type { MergeMethod, TaskPR } from "@/lib/types/github";
import { isPRReadyToMerge } from "./pr-task-icon";
import { canAttemptPRMerge } from "./pr-task-icon";
import { useTranslation } from "react-i18next";
function MutationActor({ actor }: { actor: string | null }) {
@@ -89,25 +89,20 @@ export function PRMergeButton({
const { t } = useTranslation();
const { toast } = useToast();
const [merging, setMerging] = useState(false);
// After a successful merge we stay hidden until the store catches up to
// state="merged" — otherwise the button briefly re-enables during the async
// refresh window and a double-click would hit the GitHub API again.
const [merged, setMerged] = useState(false);
// After GitHub accepts a direct or queued merge, stay hidden until refreshed
// provider state catches up so a repeat click cannot submit a second request.
const [acceptedFor, setAcceptedFor] = useState<string | null>(null);
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const methods = useRepoMergeMethods(workspaceId, taskPR.owner, taskPR.repo);
const { status } = useGitHubStatus(workspaceId);
const mutationActor = getGitHubMutationActor(status);
// If the same component instance ever renders a different PR (e.g. the user
// switches the active task while the panel/popover stays mounted), the
// sticky `merged` flag from a previous merge would hide the button for an
// unrelated, still-mergeable PR. Reset it whenever the underlying PR id
// changes.
useEffect(() => {
setMerged(false);
}, [taskPR.id]);
// Reset after switching PRs or after GitHub reports an observable lifecycle
// or mergeability transition, including a PR ejected from a merge queue.
const signature = `${taskPR.id}:${taskPR.state}:${taskPR.mergeable_state}`;
const accepted = acceptedFor === signature;
if (merged || !isPRReadyToMerge(taskPR)) return null;
if (accepted || !canAttemptPRMerge(taskPR)) return null;
// `methods` may be null on first render, on lookup failure, or after the
// 5-minute cache window. We still render the button — clicking with no
// method routes through the backend's GetRepoMergeMethods resolver, so
@@ -119,11 +114,23 @@ export function PRMergeButton({
const runMerge = async (method?: MergeMethod) => {
if (!workspaceId) return;
const submittedSignature = signature;
setMerging(true);
try {
await mergePR(workspaceId, taskPR.owner, taskPR.repo, taskPR.pr_number, method);
setMerged(true);
toast({ description: t("github:prMerged"), variant: "success" });
const result = await mergePR(
workspaceId,
taskPR.owner,
taskPR.repo,
taskPR.pr_number,
method,
);
setAcceptedFor(submittedSignature);
toast({
description: t(
result.status === "queued" ? "github:prAddedToMergeQueue" : "github:prMerged",
),
variant: "success",
});
onMerged?.();
} catch (err) {
toast({
@@ -144,11 +151,21 @@ export function PRMergeButton({
return (
<MergeButtonShell
compact={compact}
label={merging ? t("github:merging") : t(mergeLabelKey(primary))}
label={
merging
? t("github:merging")
: t(
taskPR.mergeable_state === "blocked"
? "github:mergeMethodDefault"
: mergeLabelKey(primary),
)
}
disabled={merging}
actor={mutationActor}
onPrimaryClick={handlePrimary}
extraMethods={allowed.filter((m) => m !== primary)}
extraMethods={
taskPR.mergeable_state === "blocked" ? [] : allowed.filter((m) => m !== primary)
}
onPickMethod={(m) => void runMerge(m)}
/>
);
@@ -197,7 +214,7 @@ function MergeButtonShell({
<Button
data-testid="pr-merge-button"
size="sm"
className={`cursor-pointer gap-1.5 border-0 bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 ${showDropdown ? "rounded-r-none" : ""}`}
className={`min-h-11 cursor-pointer gap-1.5 border-0 bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500 ${showDropdown ? "rounded-r-none" : ""}`}
onClick={onPrimaryClick}
disabled={disabled}
>
@@ -85,17 +85,15 @@ describe("PRMergeabilityRow", () => {
expect(container.textContent).toContain("Blocked by branch protection");
});
it("shows normal branch protection as a neutral waiting state", () => {
const { container, queryByTestId } = renderRow(
it("keeps an otherwise green blocked PR neutral until GitHub accepts it", () => {
const { queryByTestId } = renderRow(
makePR({
mergeable_state: "blocked",
checks_state: "success",
review_state: "approved",
}),
);
expect(queryByTestId("pr-blocked-note")).toBeNull();
expect(queryByTestId("pr-branch-protection-wait-note")).not.toBeNull();
expect(container.textContent).toContain("Waiting on branch protection");
});
it("stays silent for a blocked PR that is only awaiting a requested review", () => {
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
canAttemptPRMerge,
aggregatePRStatusColor,
getPRAggregateStatusColor,
areAllOpenPRsReadyToMerge,
@@ -123,7 +124,7 @@ describe("isPRReadyToMerge", () => {
).toBe(false);
});
it("is false when mergeable_state is blocked", () => {
it("does not claim readiness for GitHub's overloaded blocked state", () => {
expect(
isPRReadyToMerge(
makePR({
@@ -166,6 +167,21 @@ describe("isPRReadyToMerge", () => {
);
});
describe("canAttemptPRMerge", () => {
it("allows GitHub to decide whether a locally green blocked PR enters a queue", () => {
expect(
canAttemptPRMerge(
makePR({
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
}),
),
).toBe(true);
});
});
describe("isPRReadyToMerge — aggregate counts", () => {
it("does not treat aggregate all-green counts as merge-ready without success state", () => {
expect(
@@ -261,9 +277,7 @@ describe("getPRStatusColor", () => {
expect(getPRStatusColor(pr)).toBe(EMERALD_400);
});
it("returns muted for approved+success but mergeable_state blocked (branch protection)", () => {
// Branch protection is a normal repository-rule wait after CI passes, not
// a warning state.
it("keeps an approved blocked PR neutral until GitHub accepts it", () => {
const pr = makePR({
state: "open",
review_state: "approved",
@@ -66,6 +66,7 @@ export function isPRReadyToMerge(pr: TaskPR): boolean {
if (pr.required_reviews != null && pr.review_count < pr.required_reviews) {
return false;
}
if (pr.pending_review_count > 0) return false;
if (pr.review_state === "approved") return true;
// No review process: no requested reviewers and no submitted reviews. GitHub
// sets mergeable_state=clean when branch protection is satisfied, so this
@@ -73,6 +74,14 @@ export function isPRReadyToMerge(pr: TaskPR): boolean {
return pr.review_state === "" && pr.pending_review_count === 0;
}
// GitHub overloads `blocked` for merge queues and unrelated repository rules.
// Keep readiness clean-only, but allow a neutral merge attempt so GitHub can
// authoritatively accept the PR into a queue or return the actual blocker.
export function canAttemptPRMerge(pr: TaskPR): boolean {
if (pr.mergeable_state !== "clean" && pr.mergeable_state !== "blocked") return false;
return isPRReadyToMerge({ ...pr, mergeable_state: "clean" });
}
export function isPRDraft(pr: TaskPR): boolean {
return pr.state === "open" && pr.mergeable_state === "draft";
}
@@ -96,6 +105,7 @@ export function isPRAwaitingReview(pr: TaskPR): boolean {
export function isPRWaitingOnBranchProtection(pr: TaskPR): boolean {
if (pr.state !== "open") return false;
if (pr.mergeable_state !== "blocked") return false;
if (isPRReadyToMerge(pr)) return false;
if (!hasPRChecksPassedForDisplay(pr)) return false;
if (pr.review_state === "changes_requested") return false;
return !isPRAwaitingReview(pr);
+14
View File
@@ -1387,6 +1387,20 @@ export class ApiClient {
await this.seedMockGitHubRepositoryAccess(prs);
}
async mockGitHubSetMergeOutcome(
owner: string,
repo: string,
number: number,
outcome: "merged" | "queued",
): Promise<void> {
await this.request("PUT", "/api/v1/github/mock/merge-outcomes", {
owner,
repo,
number,
outcome,
});
}
async mockGitHubAddIssues(issues: MockIssue[]): Promise<void> {
await this.request("POST", "/api/v1/github/mock/issues", { issues });
await this.seedMockGitHubRepositoryAccess(issues);
@@ -0,0 +1,88 @@
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
import { SessionPage } from "../../pages/session-page";
test("adds a GitHub PR to the merge queue from mobile Review", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
await testPage.setViewportSize({ width: 393, height: 852 });
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("maya-chen");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Ship resilient deployment controls",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await apiClient.mockGitHubAddRepos("northstar-labs", [
{
full_name: "northstar-labs/relay-console",
owner: "northstar-labs",
name: "relay-console",
},
]);
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "northstar-labs",
repo: "relay-console",
pr_number: 843,
pr_url: "https://github.com/northstar-labs/relay-console/pull/843",
pr_title: "Make deployment rollbacks deterministic",
head_branch: "feat/resilient-rollbacks",
base_branch: "main",
author_login: "maya-chen",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
review_count: 2,
pending_review_count: 0,
required_reviews: 2,
checks_total: 3,
checks_passing: 3,
});
await apiClient.mockGitHubSetMergeOutcome("northstar-labs", "relay-console", 843, "queued");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.reload();
await session.waitForLoad();
await testPage.getByRole("button", { name: "Review", exact: true }).tap();
const panel = testPage.getByTestId("mobile-review-panel");
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "northstar-labs",
repo: "relay-console",
pr_number: 843,
pr_url: "https://github.com/northstar-labs/relay-console/pull/843",
pr_title: "Make deployment rollbacks deterministic",
head_branch: "feat/resilient-rollbacks",
base_branch: "main",
author_login: "maya-chen",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
review_count: 2,
pending_review_count: 0,
required_reviews: 2,
checks_total: 3,
checks_passing: 3,
});
const merge = panel.getByRole("button", { name: "Merge PR" });
await expect(merge).toBeVisible({ timeout: 15_000 });
expect((await requireBox(merge, "mobile merge queue action")).height).toBeGreaterThanOrEqual(44);
await assertNoDocumentHorizontalOverflow(testPage);
await merge.tap();
await expect(testPage.getByText("PR added to merge queue", { exact: true })).toBeVisible();
});
@@ -0,0 +1,79 @@
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
const OWNER = "northstar-labs";
const REPO = "relay-console";
const PR_NUMBER = 842;
test("adds an eligible GitHub PR to its merge queue", async ({ testPage, apiClient, seedData }) => {
test.setTimeout(120_000);
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("maya-chen");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Ship resilient deployment controls",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await seedQueuedPR(apiClient, task.id);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.reload();
await session.waitForLoad();
await seedQueuedPR(apiClient, task.id);
await session.hoverPRTopbar();
const popover = session.prTopbarPopover();
const compactMerge = popover.getByRole("button", { name: "Merge PR" });
await expect(compactMerge).toBeVisible({ timeout: 15_000 });
await session.prDetailTab().click();
await seedQueuedPR(apiClient, task.id);
const detail = testPage.getByTestId("change-request-detail");
const merge = detail.getByRole("button", { name: "Merge PR" });
await expect(merge).toBeVisible({ timeout: 15_000 });
const [response] = await Promise.all([
testPage.waitForResponse((candidate) => candidate.url().includes(`/${PR_NUMBER}/merge`)),
merge.click(),
]);
const responseBody = await response.json();
expect(response.ok(), JSON.stringify(responseBody)).toBe(true);
expect(responseBody).toMatchObject({ status: "queued" });
await expect(testPage.getByText("PR added to merge queue", { exact: true })).toBeVisible();
await expect(detail.getByTestId("pr-merge-button")).toHaveCount(0);
});
async function seedQueuedPR(apiClient: ApiClient, taskId: string) {
await apiClient.mockGitHubAddRepos(OWNER, [
{ full_name: `${OWNER}/${REPO}`, owner: OWNER, name: REPO },
]);
await apiClient.mockGitHubAssociateTaskPR({
task_id: taskId,
owner: OWNER,
repo: REPO,
pr_number: PR_NUMBER,
pr_url: `https://github.com/${OWNER}/${REPO}/pull/${PR_NUMBER}`,
pr_title: "Make deployment rollbacks deterministic",
head_branch: "feat/resilient-rollbacks",
base_branch: "main",
author_login: "maya-chen",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
review_count: 2,
pending_review_count: 0,
required_reviews: 2,
checks_total: 3,
checks_passing: 3,
});
await apiClient.mockGitHubSetMergeOutcome(OWNER, REPO, PR_NUMBER, "queued");
}
@@ -52,12 +52,9 @@ import * as githubAuthApi from "./github-auth-api";
const workspaceID = "ws/1";
const appRegistrationID = "registration/one";
const publicKandevURL = "https://kandev.example";
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise<Response>>();
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal("fetch", fetchSpy);
@@ -0,0 +1,28 @@
import { afterEach, expect, it, vi } from "vitest";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
}));
import { mergePR } from "./github-api";
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns the queue-aware merge outcome", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ status: "queued" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(mergePR("workspace-1", "acme", "site", 42, "squash")).resolves.toEqual({
status: "queued",
});
});
+1 -1
View File
@@ -73,7 +73,7 @@ export async function mergePR(
number: number,
mergeMethod?: MergeMethod,
) {
return fetchJson<{ merged: boolean }>(
return fetchJson<{ status: "merged" | "queued" }>(
`/api/v1/github/prs/${owner}/${repo}/${number}/merge?${workspaceQuery(workspaceId)}`,
{
init: {
+1
View File
@@ -377,6 +377,7 @@
"prClosedWithoutMerging": "PR closed without merging",
"prEvents": "PR events",
"reviewFollowUp": "Review follow-up",
"prAddedToMergeQueue": "PR added to merge queue",
"prMerged": "PR merged",
"prTaskStatusCi": "CI",
"prTaskStatusMerge": "Merge",
+1
View File
@@ -377,6 +377,7 @@
"prClosedWithoutMerging": "ƤŔ ćĺōśēď ŵĩţĥōũţ ḿēŕĝĩńĝ",
"prEvents": "ƤŔ ēvēńţś",
"reviewFollowUp": "Ŕēvĩēŵ ƒōĺĺōŵ-ũƥ",
"prAddedToMergeQueue": "ƤŔ àďďēď ţō ḿēŕĝē qũēũē",
"prMerged": "ƤŔ ḿēŕĝēď",
"prTaskStatusCi": "ĆĨ",
"prTaskStatusMerge": "Ḿēŕĝē",
+1
View File
@@ -370,6 +370,7 @@
"prApproved": "PR aprovado",
"prClosedWithoutMerging": "PR fechado sem merge",
"prEvents": "Eventos de PR",
"prAddedToMergeQueue": "PR adicionado à fila de merge",
"prMerged": "PR integrado",
"prepareAppOnGithub": "Preparar a App no GitHub",
"presetIconBug": "Bug",
+1
View File
@@ -377,6 +377,7 @@
"prClosedWithoutMerging": "PR 已关闭且未合并",
"prEvents": "PR 事件",
"reviewFollowUp": "审查跟进",
"prAddedToMergeQueue": "PR 已添加到合并队列",
"prMerged": "PR 已合并",
"prTaskStatusCi": "CI",
"prTaskStatusMerge": "合并",
+29 -29
View File
@@ -30,6 +30,7 @@
"capabilityProbeFailed": "探測失敗。請查看代理程式日誌了解詳情。",
"capabilityProbing": "正在檢查代理程式能力…",
"changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級映射。",
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
"checkingAuthentication": "正在檢查身份驗證",
"checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身份驗證…",
"checkingLatestRuntimeVersion": "正在檢查最新執行時版本…",
@@ -50,13 +51,18 @@
"conflictTasksTitle": "任務:",
"conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}}</0> 層級",
"conflictTierMappingsTitle": "工作區層級映射:",
"conflictUtilityAgentsTitle": "輔助代理程式(將保留失效綁定):",
"conflictWatchersTitle": "監控(將被停用):",
"conflictUtilityAgentsTitle": "輔助代理程式(將保留失效綁定):",
"disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個綁定。",
"disableProfileAnyway": "停用設定",
"copied": "已複製",
"copyInstallCommand": "複製安裝命令",
"copyToClipboard": "複製到剪貼板",
"create": "建立",
"createAgentProfileTitle": "建立 {{name}} 設定",
"installAgents": "安裝代理程式",
"profileCount_one": "{{count}} 個設定",
"profileCount_other": "{{count}} 個設定",
"createProfileIntro": "為此代理程式建立新設定。",
"creating": "正在建立…",
"defaultProfile": "預設代理程式設定檔",
@@ -73,30 +79,27 @@
"details": "詳情",
"detectedAt": "檢測路徑:{{path}}",
"disableProfile": "停用代理程式設定檔",
"disableProfileAnyway": "停用設定",
"disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個綁定。",
"disableSetting": "停用 {{name}}",
"displayName": "顯示名稱",
"displayNameRequired": "顯示名稱為必填項",
"done": "完成",
"duplicate": "複製",
"duplicateProfileNamed": "複製 {{name}}",
"duplicateProfileSuccess": "代理程式設定檔已複製",
"duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"enableMcp": "啓用 MCP",
"enableMcpDescription": "允許此設定在工作階段中使用 MCP 伺服器。",
"enableProfile": "啓用代理程式設定檔",
"enableSetting": "啓用 {{name}}",
"enabled": "已啓用",
"enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。",
"enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。",
"everyProfileNeedsNameAndModel": "每個設定都需要名稱和模型。",
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
"exampleValue": "例如 {{example}}",
"failedToCreateAgent": "建立代理程式失敗",
"failedToDeleteProfile": "刪除設定失敗",
"failedToDuplicateProfile": "複製代理程式設定檔失敗",
"failedToFetchCapabilities": "擷取能力失敗",
"modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
"modelOptionsResolutionFailed": "無法載入模型選項。",
"modelSettings": "模型設定",
"resolvingModelOptions": "正在載入模型選項…",
"retryModelOptions": "重試",
"failedToLoadCommandPreview": "載入命令預覽失敗",
"failedToLoadMcpConfig": "載入 MCP 設定失敗",
"failedToRefresh": "刷新失敗:{{error}}",
@@ -111,7 +114,6 @@
"hostTerminal": "主機終端",
"hostTerminalDescription": "在 Kandev 主機上執行 shell。可用於手動安裝或設定代理程式。",
"install": "安裝",
"installAgents": "安裝代理程式",
"installQueued": "已排隊…",
"installationNotDetected": "未檢測到安裝",
"installed": "已安裝",
@@ -136,15 +138,12 @@
"mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
"modelOptionsResolutionFailed": "無法載入模型選項。",
"modelRequiredForAllProfiles": "所有設定都需要模型。",
"modelSettings": "模型設定",
"newProfile": "新設定",
"noAuthHintLogin": "點擊「開啟終端」登入,然後刷新。",
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點擊「刷新」。",
"noAuthHintShell": "點擊「開啟終端」啓動 shell,執行代理程式的登入命令,然後刷新。",
"noAuthLoginRequired": "未驗證;需要登入",
"newProfile": "新設定",
"noCliFlagsConfigured": "未設定 CLI 旗標。請在下方新增。",
"noCommandPreview": "無可用命令預覽。",
"noInstalledAgentsDetected": "未檢測到已安裝的代理程式。請在下方安裝,然後點擊「重新掃描」。",
@@ -157,8 +156,6 @@
"popularServers": "常用伺服器",
"probingCapabilities": "正在探測代理程式能力… 通常需要幾秒鐘。",
"profileActions": "設定操作",
"profileCount_one": "{{count}} 個設定",
"profileCount_other": "{{count}} 個設定",
"profileInUseIntro": "此設定目前正在使用。刪除後將影響以下內容:",
"profileName": "設定名稱",
"profileNameRequired": "設定名稱為必填項。",
@@ -175,9 +172,7 @@
"removeThisProfile": "移除此設定",
"requestFailed": "請求失敗",
"rescan": "重新掃描",
"resolvingModelOptions": "正在載入模型選項…",
"retry": "重試",
"retryModelOptions": "重試",
"retryUpdate": "重試更新",
"retryVersionCheck": "重試版本檢查",
"reviewUpdateBeforeApplying": "在 Kandev 更改此主機執行時之前,請先審閱更新。",
@@ -187,6 +182,16 @@
"runtimeUpdateSessionsNote": "活動工作階段會繼續執行;僅後續探測與啓動會使用刷新後的執行時。",
"runtimeUpdatedRefreshFailed": "執行時已更新,但無法刷新能力:{{error}}",
"runtimeUpdatedSuccess": "執行時更新成功。",
"activeRuntimeVersion": "活動版本:{{version}}",
"activeRuntimeVersionMarker": "活動",
"latestRuntimeVersion": "最新",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "要激活的版本",
"updateRuntime": "更新執行時",
"rollBackRuntime": "回滾執行時",
"repairRuntime": "修復執行時",
"upToDateRuntime": "已是最新",
"scanningForInstalledAgents": "正在掃描已安裝的代理程式…",
"searchModels": "搜尋模型…",
"searchModes": "搜尋模式…",
@@ -219,18 +224,13 @@
"watcherKindGithubReview": "GitHub PR 審查",
"watcherKindJira": "Jira",
"watcherKindLinear": "Linear",
"activeRuntimeVersion": "活動版本:{{version}}",
"activeRuntimeVersionMarker": "活動",
"latestRuntimeVersion": "最新",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "要激活的版本",
"updateRuntime": "更新執行時",
"rollBackRuntime": "回滾執行時",
"repairRuntime": "修復執行時",
"upToDateRuntime": "已是最新",
"collapseAgentProfiles": "摺疊 {{name}} 設定",
"expandAgentProfiles": "展開 {{name}} 設定",
"duplicate": "複製",
"duplicateProfileNamed": "複製 {{name}}",
"duplicateProfileSuccess": "代理程式設定檔已複製",
"duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"failedToDuplicateProfile": "複製代理程式設定檔失敗",
"noProfilesYetShort": "暫無設定",
"createProfile": "建立設定",
"creatingProfile": "正在建立…",
+1 -1
View File
@@ -66,6 +66,7 @@
"noRepositoriesAvailableInWorkspace": "此工作區中沒有可用儲存庫",
"noRepository": "無儲存庫",
"optional": "(可選)",
"pinQueuedMessages": "固定訊息佇列",
"providerQuotaModel": "模型:{{model}}",
"providerQuotaProviderFallback": "提供者",
"providerQuotaReset": "容量將於 {{resetAt}} 恢復。",
@@ -140,7 +141,6 @@
"transientRetryProviderModel": "{{provider}} · {{model}}",
"transientRetryProvider": "服務商:{{provider}}",
"transientRetryModel": "模型:{{model}}",
"pinQueuedMessages": "固定訊息佇列",
"unpinQueuedMessages": "取消固定訊息佇列",
"emptyTurnKnownCommand": "`/{{command}}` 已執行,但沒有產生任何輸出。{{hint}}",
"emptyTurnNoOutput": "代理程式已結束,但沒有產生任何輸出。",
+1
View File
@@ -377,6 +377,7 @@
"prClosedWithoutMerging": "PR 已關閉且未合併",
"prEvents": "PR 事件",
"reviewFollowUp": "審查跟進",
"prAddedToMergeQueue": "PR 已新增到合併佇列",
"prMerged": "PR 已合併",
"prTaskStatusCi": "CI",
"prTaskStatusMerge": "合併",
+5 -5
View File
@@ -27,6 +27,9 @@
"copyingConfigTo": "正在將 {{label}} 設定複製到 {{targetName}}…",
"createAsDraft": "建立為草稿",
"createdSuccessfully": "{{longName}} 建立成功",
"createdLinkNeedsAttention": "{{shortName}} 已建立;任務連結需要處理",
"associationRetryViaTaskMenu": "{{message}}。請在任務菜單中使用「連結」重試。",
"taskAssociationCouldNotBeSaved": "無法儲存任務關聯。",
"createFailed": "建立 {{shortName}} 失敗",
"createLongName": "建立 {{longName}}",
"createPr": "建立 PR",
@@ -75,6 +78,7 @@
"rebase": "變基",
"rebaseOntoBranchBehind": "變基到 {{branch}}(落後 {{behind}})",
"reconnectProvider": "重新連接 {{name}}",
"registeredProvider": "已註冊提供者",
"repository": "儲存庫",
"retryShortName": "重試 {{shortName}}",
"saveCurrentQuery": "儲存目前查詢",
@@ -87,11 +91,6 @@
"stageAllChangesBeforeCommitting": "提交前暫存所有更改",
"targetWorkspace": "目標工作區",
"unknownError": "未知錯誤",
"waitingForTheNextBackendHealth": "正在等待下一次後端健康檢查…",
"createdLinkNeedsAttention": "{{shortName}} 已建立;任務連結需要處理",
"associationRetryViaTaskMenu": "{{message}}。請在任務菜單中使用「連結」重試。",
"taskAssociationCouldNotBeSaved": "無法儲存任務關聯。",
"registeredProvider": "已註冊提供者",
"failedToUnlinkPullRequest": "無法取消關聯拉取要求",
"pullRequestUnlinked": "已取消關聯拉取要求",
"linkedProviderPullRequests_one": "已關聯 {{count}} 個 {{provider}} 拉取要求",
@@ -104,6 +103,7 @@
"queryName": "名稱",
"queryNamePlaceholder": "例如:需要我的審查",
"saveQuery": "儲存查詢",
"waitingForTheNextBackendHealth": "正在等待下一次後端健康檢查…",
"cursorPaginationSummary_one": "第 {{page}} 頁 · {{count}} 個結果",
"cursorPaginationSummary_other": "第 {{page}} 頁 · {{count}} 個結果",
"actionFromReviewer": "{{name}} {{action}}",
+3 -3
View File
@@ -63,7 +63,10 @@
"pipeline": "流水線",
"previewPanel": "預覽面板",
"previousStep": "上一步",
"queuedCount_one": "{{count}} 個排隊任務",
"queuedCount_other": "{{count}} 個排隊任務",
"queuedForStep": "已排隊等待 {{step}}",
"queuedSection": "排隊中",
"repository": "儲存庫",
"searchPlaceholder": "搜尋…",
"searchSection": "搜尋",
@@ -92,9 +95,6 @@
"workflowStepFallback": "工作流程步驟 {{stepId}}",
"columns": "列",
"columnsForWorkflow": "{{name}} 的列",
"queuedCount_one": "{{count}} 個排隊任務",
"queuedCount_other": "{{count}} 個排隊任務",
"queuedSection": "排隊中",
"needsReassignment": "需要重新分配",
"blockedByCount_one": "被 {{count}} 項阻塞",
"blockedByCount_other": "被 {{count}} 項阻塞",
+1 -1
View File
@@ -70,6 +70,7 @@
"projects": "項目",
"queuedPromptCount_one": "{{count}} 條排隊提示",
"queuedPromptCount_other": "{{count}} 條排隊提示",
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
"quickChat": "快速聊天",
"quickTerminal": "快速終端",
"quickChatTerminals": "終端",
@@ -107,7 +108,6 @@
"whatsNew": "新功能",
"work": "工作",
"workspaceFallback": "工作區",
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
"taskPreferences": "側邊欄任務偏好設定",
"views": "側邊欄視圖",
"failedToLoadArchivedTasks": "載入已歸檔任務失敗",
+7 -7
View File
@@ -460,7 +460,6 @@
"failedToPublishShare": "發佈分享失敗。",
"failedToRead": "讀取 {{name}} 失敗",
"failedToRenameBranch": "重命名分支失敗",
"failedToRenameItem": "重命名項目失敗",
"failedToRenameTask": "重命名任務失敗",
"failedToRestorePlan": "恢復計劃失敗",
"failedToResumeSession": "無法恢復工作階段",
@@ -933,6 +932,9 @@
"promptFieldLabel": "提示詞",
"providerPrivateContextThatKandevDid": "Kandev 未記錄的提供者私有上下文可能不會保留。已開啟的終端、開發伺服器及其他工作區程序會停止,包括任務編輯器伺服器。",
"providerUnavailable": "提供者不可用",
"providerAheadPushDisabled": "提供者分支較新。無法執行推送。",
"providerAheadPullRequiresUpstream": "此檢出必須設定上游後才能拉取。",
"divergedActionsUnavailable": "貢獻歷史已分叉。請從 Changes 操作中選擇一個版本。",
"proxy": "代理伺服器",
"publishARedactedSnapshotOfThis": "將此已完成任務的脱敏快照發布為你帳戶上的私密 GitHub Gist。",
"publishToGithubGist": "發佈到 GitHub Gist",
@@ -1081,6 +1083,8 @@
"reviewTheseChangesBeforeAddingSources": "新增來源前請審閱這些變更。在操作完成前任務必須保持空閒。",
"reviewed": "已審閱",
"reviewers": "審閱人",
"reviewsCount_one": "審閱({{count}})",
"reviewsCount_other": "審閱({{count}})",
"revoke": "撤銷",
"revokeShare": "撤銷分享",
"revoking": "正在撤銷…",
@@ -1242,6 +1246,7 @@
"start2": "開始",
"startAgent": "啓動代理程式",
"startAgent2": "啓動代理程式",
"composerStartAgentHint": "傳送訊息將自動啓動代理程式。",
"startDevServerPreview": "啓動開發伺服器預覽",
"startFreshSession": "開始全新工作階段",
"startPlanMode": "啓動計劃模式",
@@ -1512,12 +1517,6 @@
"autopilotInfoLabel": "了解自動駕駛模式",
"autopilotSidebarDescription": "自動駕駛任務",
"autopilotChatDescription": "自動駕駛已啓用。代理程式只會向父任務詢問關鍵決策。",
"providerAheadPushDisabled": "提供者分支較新。無法執行推送。",
"providerAheadPullRequiresUpstream": "此檢出必須設定上游後才能拉取。",
"divergedActionsUnavailable": "貢獻歷史已分叉。請從 Changes 操作中選擇一個版本。",
"reviewsCount_one": "審閱({{count}})",
"reviewsCount_other": "審閱({{count}})",
"composerStartAgentHint": "傳送訊息將自動啓動代理程式。",
"duplicateRepository": "儲存庫重複",
"duplicateRepositoryDescription": "{{repository}} 被新增了多次。請刪除重複行。",
"repositoryStillBeingVerified": "仍在驗證儲存庫",
@@ -1531,6 +1530,7 @@
"failedToDownloadFile": "下載檔案失敗",
"failedToFetchPlan": "擷取計劃失敗",
"failedToLoadPlanRevisions": "載入修訂版本失敗",
"failedToRenameItem": "重命名項目失敗",
"failedToRevertPlan": "還原計劃失敗",
"failedToSavePlan": "儲存計劃失敗",
"failedToStartTaskPlanMode": "以計劃模式啓動任務失敗",
+2 -2
View File
@@ -143,6 +143,8 @@
"promptSourceStepPromptWithTaskDescription": "重新進入「{{stepName}}」會傳送已渲染的步驟提示詞(含任務描述)。",
"promptSourceTaskDescription": "「{{stepName}}」沒有步驟提示詞,因此重新進入時會傳送任務描述。",
"pullFrom": "拉取自",
"pullFromGuidanceWithFeeder": "可選的自動饋送入口。目標佇列中的任務優先獲得准入;有容量時,Kandev 再從 {{feeder}} 拉取符合條件的工作。直接移動和自動轉換會在目標步驟排隊,不需要饋送步驟。新任務在目標步驟已滿時使用此饋送步驟。",
"pullFromGuidanceWithoutFeeder": "未選擇饋送步驟。目標步驟已滿時,直接移動和自動轉換會在此目標佇列中排隊。新任務也會留在此佇列中,直到有可用容量。",
"readOnlyManagedBySync": "只讀;由工作流程同步管理",
"readOnlyManagedByImproveKandev": "只讀;由 Improve Kandev 管理",
"remove": "移除",
@@ -260,8 +262,6 @@
"workflowsSectionDescription": "建立帶自動流轉的自主流水線,或由你手動移動任務的手動工作流程",
"workflowSteps": "工作流程步驟",
"workflowSyncCompleted": "工作流程同步完成",
"pullFromGuidanceWithFeeder": "可選的自動饋送入口。目標佇列中的任務優先獲得准入;有容量時,Kandev 再從 {{feeder}} 拉取符合條件的工作。直接移動和自動轉換會在目標步驟排隊,不需要饋送步驟。新任務在目標步驟已滿時使用此饋送步驟。",
"pullFromGuidanceWithoutFeeder": "未選擇饋送步驟。目標步驟已滿時,直接移動和自動轉換會在此目標佇列中排隊。新任務也會留在此佇列中,直到有可用容量。",
"loadingSteps": "正在載入步驟…",
"noStepsInThisWorkflow": "該工作流程沒有步驟",
"selectAWorkflowFirst": "請先選擇一個工作流程"
+29 -29
View File
@@ -30,6 +30,7 @@
"capabilityProbeFailed": "探測失敗。請檢視代理程式日誌了解詳情。",
"capabilityProbing": "正在檢查代理程式能力…",
"changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級對映。",
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
"checkingAuthentication": "正在檢查身分驗證",
"checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身分驗證…",
"checkingLatestRuntimeVersion": "正在檢查最新執行時版本…",
@@ -50,13 +51,18 @@
"conflictTasksTitle": "任務:",
"conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}}</0> 層級",
"conflictTierMappingsTitle": "工作區層級對映:",
"conflictUtilityAgentsTitle": "輔助代理程式(將保留失效繫結):",
"conflictWatchersTitle": "監控(將被停用):",
"conflictUtilityAgentsTitle": "輔助代理程式(將保留失效繫結):",
"disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個繫結。",
"disableProfileAnyway": "停用設定",
"copied": "已複製",
"copyInstallCommand": "複製安裝命令",
"copyToClipboard": "複製到剪貼簿",
"create": "建立",
"createAgentProfileTitle": "建立 {{name}} 設定",
"installAgents": "安裝代理程式",
"profileCount_one": "{{count}} 個設定",
"profileCount_other": "{{count}} 個設定",
"createProfileIntro": "為此代理程式建立新設定。",
"creating": "正在建立…",
"defaultProfile": "預設代理程式設定檔",
@@ -73,30 +79,27 @@
"details": "詳情",
"detectedAt": "檢測路徑:{{path}}",
"disableProfile": "停用代理程式設定檔",
"disableProfileAnyway": "停用設定",
"disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個繫結。",
"disableSetting": "停用 {{name}}",
"displayName": "顯示名稱",
"displayNameRequired": "顯示名稱為必填項",
"done": "完成",
"duplicate": "複製",
"duplicateProfileNamed": "複製 {{name}}",
"duplicateProfileSuccess": "代理程式設定檔已複製",
"duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"enableMcp": "啟用 MCP",
"enableMcpDescription": "允許此設定在工作階段中使用 MCP 伺服器。",
"enableProfile": "啟用代理程式設定檔",
"enableSetting": "啟用 {{name}}",
"enabled": "已啟用",
"enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。",
"enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。",
"everyProfileNeedsNameAndModel": "每個設定都需要名稱和模型。",
"everythingInstalled": "所有可用代理程式和工具均已安裝。",
"exampleValue": "例如 {{example}}",
"failedToCreateAgent": "建立代理程式失敗",
"failedToDeleteProfile": "刪除設定失敗",
"failedToDuplicateProfile": "複製代理程式設定檔失敗",
"failedToFetchCapabilities": "擷取能力失敗",
"modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
"modelOptionsResolutionFailed": "無法載入模型選項。",
"modelSettings": "模型設定",
"resolvingModelOptions": "正在載入模型選項…",
"retryModelOptions": "重試",
"failedToLoadCommandPreview": "載入命令預覽失敗",
"failedToLoadMcpConfig": "載入 MCP 設定失敗",
"failedToRefresh": "重新整理失敗:{{error}}",
@@ -111,7 +114,6 @@
"hostTerminal": "主機終端",
"hostTerminalDescription": "在 Kandev 主機上執行 shell。可用於手動安裝或設定代理程式。",
"install": "安裝",
"installAgents": "安裝代理程式",
"installQueued": "已排隊…",
"installationNotDetected": "未檢測到安裝",
"installed": "已安裝",
@@ -136,15 +138,12 @@
"mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
"modelOptionsResolutionFailed": "無法載入模型選項。",
"modelRequiredForAllProfiles": "所有設定都需要模型。",
"modelSettings": "模型設定",
"newProfile": "新設定",
"noAuthHintLogin": "點選「開啟終端」登入,然後重新整理。",
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點選「重新整理」。",
"noAuthHintShell": "點選「開啟終端」啟動 shell,執行代理程式的登入命令,然後重新整理。",
"noAuthLoginRequired": "未驗證;需要登入",
"newProfile": "新設定",
"noCliFlagsConfigured": "未設定 CLI 旗標。請在下方新增。",
"noCommandPreview": "無可用命令預覽。",
"noInstalledAgentsDetected": "未檢測到已安裝的代理程式。請在下方安裝,然後點選「重新掃描」。",
@@ -157,8 +156,6 @@
"popularServers": "常用伺服器",
"probingCapabilities": "正在探測代理程式能力… 通常需要幾秒鐘。",
"profileActions": "設定操作",
"profileCount_one": "{{count}} 個設定",
"profileCount_other": "{{count}} 個設定",
"profileInUseIntro": "此設定目前正在使用。刪除後將影響以下內容:",
"profileName": "設定名稱",
"profileNameRequired": "設定名稱為必填項。",
@@ -175,9 +172,7 @@
"removeThisProfile": "移除此設定",
"requestFailed": "請求失敗",
"rescan": "重新掃描",
"resolvingModelOptions": "正在載入模型選項…",
"retry": "重試",
"retryModelOptions": "重試",
"retryUpdate": "重試更新",
"retryVersionCheck": "重試版本檢查",
"reviewUpdateBeforeApplying": "在 Kandev 更改此主機執行時之前,請先審閱更新。",
@@ -187,6 +182,16 @@
"runtimeUpdateSessionsNote": "活動工作階段會繼續執行;僅後續探測與啟動會使用重新整理後的執行時。",
"runtimeUpdatedRefreshFailed": "執行時已更新,但無法重新整理能力:{{error}}",
"runtimeUpdatedSuccess": "執行時更新成功。",
"activeRuntimeVersion": "活動版本:{{version}}",
"activeRuntimeVersionMarker": "活動",
"latestRuntimeVersion": "最新",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "要啟用的版本",
"updateRuntime": "更新執行時",
"rollBackRuntime": "回滾執行時",
"repairRuntime": "修復執行時",
"upToDateRuntime": "已是最新",
"scanningForInstalledAgents": "正在掃描已安裝的代理程式…",
"searchModels": "搜尋模型…",
"searchModes": "搜尋模式…",
@@ -219,18 +224,13 @@
"watcherKindGithubReview": "GitHub PR 審查",
"watcherKindJira": "Jira",
"watcherKindLinear": "Linear",
"activeRuntimeVersion": "活動版本:{{version}}",
"activeRuntimeVersionMarker": "活動",
"latestRuntimeVersion": "最新",
"runtimeVersionMarkerGroup": "({{markers}})",
"runtimeVersionOption": "{{version}} {{markers}}",
"selectRuntimeVersion": "要啟用的版本",
"updateRuntime": "更新執行時",
"rollBackRuntime": "回滾執行時",
"repairRuntime": "修復執行時",
"upToDateRuntime": "已是最新",
"collapseAgentProfiles": "摺疊 {{name}} 設定",
"expandAgentProfiles": "展開 {{name}} 設定",
"duplicate": "複製",
"duplicateProfileNamed": "複製 {{name}}",
"duplicateProfileSuccess": "代理程式設定檔已複製",
"duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"failedToDuplicateProfile": "複製代理程式設定檔失敗",
"noProfilesYetShort": "暫無設定",
"createProfile": "建立設定",
"creatingProfile": "正在建立…",
+1 -1
View File
@@ -66,6 +66,7 @@
"noRepositoriesAvailableInWorkspace": "此工作區中沒有可用儲存庫",
"noRepository": "無儲存庫",
"optional": "(可選)",
"pinQueuedMessages": "固定訊息佇列",
"providerQuotaModel": "模型:{{model}}",
"providerQuotaProviderFallback": "提供者",
"providerQuotaReset": "容量將於 {{resetAt}} 恢復。",
@@ -140,7 +141,6 @@
"transientRetryProviderModel": "{{provider}} · {{model}}",
"transientRetryProvider": "服務商:{{provider}}",
"transientRetryModel": "模型:{{model}}",
"pinQueuedMessages": "固定訊息佇列",
"unpinQueuedMessages": "取消固定訊息佇列",
"emptyTurnKnownCommand": "`/{{command}}` 已執行,但沒有產生任何輸出。{{hint}}",
"emptyTurnNoOutput": "代理程式已結束,但沒有產生任何輸出。",
+1
View File
@@ -377,6 +377,7 @@
"prClosedWithoutMerging": "PR 已關閉且未合併",
"prEvents": "PR 事件",
"reviewFollowUp": "審查跟進",
"prAddedToMergeQueue": "PR 已新增到合併佇列",
"prMerged": "PR 已合併",
"prTaskStatusCi": "CI",
"prTaskStatusMerge": "合併",
+5 -5
View File
@@ -27,6 +27,9 @@
"copyingConfigTo": "正在將 {{label}} 設定複製到 {{targetName}}…",
"createAsDraft": "建立為草稿",
"createdSuccessfully": "{{longName}} 建立成功",
"createdLinkNeedsAttention": "{{shortName}} 已建立;任務連結需要處理",
"associationRetryViaTaskMenu": "{{message}}。請在任務選單中使用「連結」重試。",
"taskAssociationCouldNotBeSaved": "無法儲存任務關聯。",
"createFailed": "建立 {{shortName}} 失敗",
"createLongName": "建立 {{longName}}",
"createPr": "建立 PR",
@@ -75,6 +78,7 @@
"rebase": "變基",
"rebaseOntoBranchBehind": "變基到 {{branch}}(落後 {{behind}})",
"reconnectProvider": "重新連線 {{name}}",
"registeredProvider": "已註冊提供者",
"repository": "儲存庫",
"retryShortName": "重試 {{shortName}}",
"saveCurrentQuery": "儲存目前查詢",
@@ -87,11 +91,6 @@
"stageAllChangesBeforeCommitting": "提交前暫存所有更改",
"targetWorkspace": "目標工作區",
"unknownError": "未知錯誤",
"waitingForTheNextBackendHealth": "正在等待下一次後端健康檢查…",
"createdLinkNeedsAttention": "{{shortName}} 已建立;任務連結需要處理",
"associationRetryViaTaskMenu": "{{message}}。請在任務選單中使用「連結」重試。",
"taskAssociationCouldNotBeSaved": "無法儲存任務關聯。",
"registeredProvider": "已註冊提供者",
"failedToUnlinkPullRequest": "無法取消關聯提取要求",
"pullRequestUnlinked": "已取消關聯提取要求",
"linkedProviderPullRequests_one": "已關聯 {{count}} 個 {{provider}} 提取要求",
@@ -104,6 +103,7 @@
"queryName": "名稱",
"queryNamePlaceholder": "例如:需要我的審查",
"saveQuery": "儲存查詢",
"waitingForTheNextBackendHealth": "正在等待下一次後端健康檢查…",
"cursorPaginationSummary_one": "第 {{page}} 頁 · {{count}} 個結果",
"cursorPaginationSummary_other": "第 {{page}} 頁 · {{count}} 個結果",
"actionFromReviewer": "{{name}} {{action}}",
+3 -3
View File
@@ -63,7 +63,10 @@
"pipeline": "流水線",
"previewPanel": "預覽面板",
"previousStep": "上一步",
"queuedCount_one": "{{count}} 個排隊任務",
"queuedCount_other": "{{count}} 個排隊任務",
"queuedForStep": "已排隊等待 {{step}}",
"queuedSection": "排隊中",
"repository": "儲存庫",
"searchPlaceholder": "搜尋…",
"searchSection": "搜尋",
@@ -92,9 +95,6 @@
"workflowStepFallback": "工作流程步驟 {{stepId}}",
"columns": "列",
"columnsForWorkflow": "{{name}} 的列",
"queuedCount_one": "{{count}} 個排隊任務",
"queuedCount_other": "{{count}} 個排隊任務",
"queuedSection": "排隊中",
"needsReassignment": "需要重新分配",
"blockedByCount_one": "被 {{count}} 項阻塞",
"blockedByCount_other": "被 {{count}} 項阻塞",
+1 -1
View File
@@ -70,6 +70,7 @@
"projects": "專案",
"queuedPromptCount_one": "{{count}} 條排隊提示",
"queuedPromptCount_other": "{{count}} 條排隊提示",
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
"quickChat": "快速聊天",
"quickTerminal": "快速終端",
"quickChatTerminals": "終端",
@@ -107,7 +108,6 @@
"whatsNew": "新功能",
"work": "工作",
"workspaceFallback": "工作區",
"wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項",
"taskPreferences": "側邊欄任務偏好設定",
"views": "側邊欄檢視",
"failedToLoadArchivedTasks": "載入已歸檔任務失敗",
+7 -7
View File
@@ -460,7 +460,6 @@
"failedToPublishShare": "釋出分享失敗。",
"failedToRead": "讀取 {{name}} 失敗",
"failedToRenameBranch": "重新命名分支失敗",
"failedToRenameItem": "重新命名專案失敗",
"failedToRenameTask": "重新命名任務失敗",
"failedToRestorePlan": "恢復計劃失敗",
"failedToResumeSession": "無法恢復工作階段",
@@ -933,6 +932,9 @@
"promptFieldLabel": "提示詞",
"providerPrivateContextThatKandevDid": "Kandev 未記錄的提供者私有上下文可能不會保留。已開啟的終端、開發伺服器及其他工作區處理程序會停止,包括任務編輯器伺服器。",
"providerUnavailable": "提供者不可用",
"providerAheadPushDisabled": "提供者分支較新。無法執行推送。",
"providerAheadPullRequiresUpstream": "此檢出必須設定上游後才能拉取。",
"divergedActionsUnavailable": "貢獻歷史已分叉。請從 Changes 操作中選擇一個版本。",
"proxy": "代理伺服器",
"publishARedactedSnapshotOfThis": "將此已完成任務的脫敏快照發布為你帳戶上的私密 GitHub Gist。",
"publishToGithubGist": "釋出到 GitHub Gist",
@@ -1081,6 +1083,8 @@
"reviewTheseChangesBeforeAddingSources": "新增來源前請審閱這些變更。在操作完成前任務必須保持空閒。",
"reviewed": "已審閱",
"reviewers": "審閱人",
"reviewsCount_one": "審閱({{count}})",
"reviewsCount_other": "審閱({{count}})",
"revoke": "撤銷",
"revokeShare": "撤銷分享",
"revoking": "正在撤銷…",
@@ -1242,6 +1246,7 @@
"start2": "開始",
"startAgent": "啟動代理程式",
"startAgent2": "啟動代理程式",
"composerStartAgentHint": "傳送訊息將自動啟動代理程式。",
"startDevServerPreview": "啟動開發伺服器預覽",
"startFreshSession": "開始全新工作階段",
"startPlanMode": "啟動計劃模式",
@@ -1512,12 +1517,6 @@
"autopilotInfoLabel": "了解自動駕駛模式",
"autopilotSidebarDescription": "自動駕駛任務",
"autopilotChatDescription": "自動駕駛已啟用。代理程式只會向父任務詢問關鍵決策。",
"providerAheadPushDisabled": "提供者分支較新。無法執行推送。",
"providerAheadPullRequiresUpstream": "此檢出必須設定上游後才能拉取。",
"divergedActionsUnavailable": "貢獻歷史已分叉。請從 Changes 操作中選擇一個版本。",
"reviewsCount_one": "審閱({{count}})",
"reviewsCount_other": "審閱({{count}})",
"composerStartAgentHint": "傳送訊息將自動啟動代理程式。",
"duplicateRepository": "儲存庫重複",
"duplicateRepositoryDescription": "{{repository}} 被新增了多次。請刪除重複行。",
"repositoryStillBeingVerified": "仍在驗證儲存庫",
@@ -1531,6 +1530,7 @@
"failedToDownloadFile": "下載檔案失敗",
"failedToFetchPlan": "擷取計劃失敗",
"failedToLoadPlanRevisions": "載入修訂版本失敗",
"failedToRenameItem": "重新命名專案失敗",
"failedToRevertPlan": "還原計劃失敗",
"failedToSavePlan": "儲存計劃失敗",
"failedToStartTaskPlanMode": "以計劃模式啟動任務失敗",
+2 -2
View File
@@ -143,6 +143,8 @@
"promptSourceStepPromptWithTaskDescription": "重新進入「{{stepName}}」會傳送已渲染的步驟提示詞(含任務描述)。",
"promptSourceTaskDescription": "「{{stepName}}」沒有步驟提示詞,因此重新進入時會傳送任務描述。",
"pullFrom": "拉取自",
"pullFromGuidanceWithFeeder": "可選的自動饋送入口。目標佇列中的任務優先獲得准入;有容量時,Kandev 再從 {{feeder}} 拉取符合條件的工作。直接移動和自動轉換會在目標步驟排隊,不需要饋送步驟。新任務在目標步驟已滿時使用此饋送步驟。",
"pullFromGuidanceWithoutFeeder": "未選擇饋送步驟。目標步驟已滿時,直接移動和自動轉換會在此目標佇列中排隊。新任務也會留在此佇列中,直到有可用容量。",
"readOnlyManagedBySync": "只讀;由工作流程同步管理",
"readOnlyManagedByImproveKandev": "只讀;由 Improve Kandev 管理",
"remove": "移除",
@@ -260,8 +262,6 @@
"workflowsSectionDescription": "建立帶自動流轉的自主流水線,或由你手動移動任務的手動工作流程",
"workflowSteps": "工作流程步驟",
"workflowSyncCompleted": "工作流程同步完成",
"pullFromGuidanceWithFeeder": "可選的自動饋送入口。目標佇列中的任務優先獲得准入;有容量時,Kandev 再從 {{feeder}} 拉取符合條件的工作。直接移動和自動轉換會在目標步驟排隊,不需要饋送步驟。新任務在目標步驟已滿時使用此饋送步驟。",
"pullFromGuidanceWithoutFeeder": "未選擇饋送步驟。目標步驟已滿時,直接移動和自動轉換會在此目標佇列中排隊。新任務也會留在此佇列中,直到有可用容量。",
"loadingSteps": "正在載入步驟…",
"noStepsInThisWorkflow": "該工作流程沒有步驟",
"selectAWorkflowFirst": "請先選擇一個工作流程"
+133
View File
@@ -0,0 +1,133 @@
---
spec: docs/specs/github-pr-merge-queue/spec.md
created: 2026-08-17
status: complete
---
# Implementation Plan: GitHub PR Merge Queue
## Overview
Extend the existing GitHub merge boundary to use GitHub's asynchronous,
queue-aware merge request and return a typed merged-or-queued outcome. Then
reuse that outcome in the existing PR merge button across the desktop detail,
compact status, and mobile Review surfaces, with targeted unit and E2E proof.
## Backend
### Provider client contract
- Update `apps/backend/internal/github/client.go` so the merge operation returns
a typed outcome rather than only an error.
- Update `apps/backend/internal/github/gh_client.go` and
`apps/backend/internal/github/pat_client.go` to call
`PUT /repos/:owner/:repo/pulls/:number/merge-async` with
`merge_action=default`, preserve the selected `merge_method`, and normalize
GitHub's accepted, already-queued, and already-merged response shapes.
- Update `apps/backend/internal/github/mock_client.go` and
`apps/backend/internal/github/noop_client.go` for the same contract so local
and E2E behavior remains deterministic.
### Service and HTTP response
- Update `apps/backend/internal/github/service_pr.go` to propagate the typed
outcome while retaining workspace scope checks, personal-write credential
routing, merge-method fallback, and cache invalidation.
- Update `apps/backend/internal/github/controller.go` to return
`{"status":"merged"}` or `{"status":"queued"}` and retain the existing
operational-auth and provider-error mapping.
## Frontend
### Queue-aware merge action
- Update `apps/web/lib/api/domains/github-pr-api.ts` and the re-exporting API
module to type the merged-or-queued response.
- Update `apps/web/components/github/pr-task-icon.tsx` with a tested eligibility
predicate that permits a queue-required branch only after explicit successful
checks and satisfied reviews, while continuing to reject drafts, conflicts,
behind branches, changes requested, and incomplete review/check gates.
- Update `apps/web/components/github/pr-merge-button.tsx` to render for direct or
queued eligibility, show the outcome-specific notification, suppress repeat
submission after acceptance, and refresh the PR state.
- Add the new action and outcome copy to all locale catalogs under
`apps/web/src/locales/*/github.json`; generate the Traditional Chinese pair
through the repository script rather than translating them independently.
### Mobile design contract
- Desktop outcome: the existing PR detail header and compact status surface
expose one merge action whose result says merged or queued.
- Mobile entry point: the task's existing Review bottom-navigation destination.
- Nearest shipped exemplar: the existing full-height mobile Review surface and
`PRMergeButton`; retain its single internal scroll owner and shared PR state.
- Information hierarchy: PR status and blockers remain above the primary merge
action; the action is the terminal operation for an eligible PR.
- Presentation: retain direct navigation to the full-height Review surface
because PR review is dense primary content, not a temporary picker.
- Geometry: keep the existing dynamic-height/safe-area behavior, avoid document
horizontal overflow, and preserve a touch target at least 44px high on phone.
- Shared logic: eligibility, mutation, result handling, and refresh remain
shared; only the existing responsive composition differs.
## Tests
- **What:** PAT and `gh` clients send `merge-async`, `merge_action=default`, and
the chosen merge method, and normalize direct, queued, and idempotent results.
**Files:** `apps/backend/internal/github/pat_client_writes_test.go`,
`apps/backend/internal/github/gh_client_commands_test.go`. **How:** focused
HTTP/command capture tests with representative GitHub responses.
- **What:** service preserves method resolution and cache invalidation while
returning the provider outcome. **File:**
`apps/backend/internal/github/service_pr_test.go`. **How:** table-driven unit
tests with the stub client.
- **What:** HTTP validation, outcome JSON, auth routing, and provider errors.
**File:** `apps/backend/internal/github/controller_test.go`. **How:** handler
tests for merged, queued, malformed method, and rejection paths.
- **What:** frontend API response typing and queue eligibility boundaries.
**Files:** `apps/web/lib/api/domains/github-api.test.ts`,
`apps/web/components/github/pr-task-icon.test.ts`. **How:** request capture and
table-driven predicate tests.
- **What:** button notifications, accepted-state suppression, retry behavior,
and refresh callback. **File:**
`apps/web/components/github/pr-merge-button.test.tsx`. **How:** component tests
with mocked API outcomes.
## E2E Tests
- **Scenario:** eligible direct merge reports merged. **File:**
`apps/web/e2e/tests/pr/pr-merge-queue.spec.ts`. **What to verify:** the desktop
PR surface exposes the action, submits it, and shows the merged notification.
- **Scenario:** queue-required merge reports queued and does not resubmit.
**File:** `apps/web/e2e/tests/pr/pr-merge-queue.spec.ts`. **What to verify:**
the desktop action remains available for the queue-required state and the
accepted queue notification is shown once.
- **Scenario:** phone Review surface provides the same queued outcome. **File:**
`apps/web/e2e/tests/pr/mobile-pr-merge-queue.spec.ts`. **What to verify:**
Review navigation, touch activation, queued notification, a touch-sized
action, and no document horizontal overflow.
## Verification Results
- Backend: `make -C apps/backend test ARGS='./internal/github/...'` passed; the
Make target executed the full backend `go test -tags fts5 ./...` suite.
- Frontend: 96 focused Vitest tests passed; `pnpm run i18n:check` and
`pnpm run typecheck` passed.
- Desktop E2E: `pnpm e2e:run --no-build tests/pr/pr-merge-queue.spec.ts`
passed (1 test).
- Mobile E2E: `pnpm e2e:run --no-build --project mobile-chrome
tests/pr/mobile-pr-merge-queue.spec.ts` passed (1 test).
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [task-01-backend-queue-aware-merge](task-01-backend-queue-aware-merge.md)
Wave 2:
- [x] [task-02-frontend-merge-outcomes](task-02-frontend-merge-outcomes.md)
Wave 3:
- [x] [task-03-desktop-mobile-e2e](task-03-desktop-mobile-e2e.md)
Execution is sequential in the primary conversation unless the user explicitly
authorizes subagents.
@@ -0,0 +1,55 @@
---
id: "01-backend-queue-aware-merge"
title: "Backend queue-aware merge"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/github-pr-merge-queue/spec.md"
---
# Task 01: Backend Queue-Aware Merge
## Inputs
- `docs/specs/github-pr-merge-queue/spec.md`
- `docs/plans/github-pr-merge-queue/plan.md`
- `apps/backend/AGENTS.md`
## Likely Files
- `apps/backend/internal/github/client.go`
- `apps/backend/internal/github/gh_client.go`
- `apps/backend/internal/github/pat_client.go`
- `apps/backend/internal/github/mock_client.go`
- `apps/backend/internal/github/noop_client.go`
- `apps/backend/internal/github/service_pr.go`
- `apps/backend/internal/github/controller.go`
- Their focused `*_test.go` files
## Acceptance
- GitHub merge requests use the asynchronous endpoint with
`merge_action=default` and preserve allowed merge-method selection.
- Provider responses normalize to explicit `merged` or `queued` outcomes,
including already-queued idempotency, and unknown results fail closed.
- The workspace-scoped HTTP endpoint returns the normalized outcome while
retaining auth routing, cache invalidation, validation, and error mapping.
## Verification
```bash
make -C apps/backend test ARGS='./internal/github/...'
```
## Dependencies And Risks
- No task dependency.
- GitHub's asynchronous API response shapes and HTTP statuses must be captured
from the official contract rather than inferred from the synchronous API.
- Both PAT and `gh` CLI transports must remain behaviorally identical.
## Results
- `make -C apps/backend test ARGS='./internal/github/...'` passed (the Make
target executed the full backend `go test -tags fts5 ./...` suite).
@@ -0,0 +1,65 @@
---
id: "02-frontend-merge-outcomes"
title: "Frontend merge outcomes"
status: done
wave: 2
depends_on: ["01-backend-queue-aware-merge"]
plan: "plan.md"
spec: "../../specs/github-pr-merge-queue/spec.md"
---
# Task 02: Frontend Merge Outcomes
## Inputs
- `docs/specs/github-pr-merge-queue/spec.md`
- `docs/plans/github-pr-merge-queue/plan.md`
- `apps/web/AGENTS.md`
- `.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md`
## Likely Files
- `apps/web/lib/api/domains/github-pr-api.ts`
- `apps/web/lib/api/domains/github-api.test.ts`
- `apps/web/components/github/pr-task-icon.tsx`
- `apps/web/components/github/pr-task-icon.test.ts`
- `apps/web/components/github/pr-merge-button.tsx`
- `apps/web/components/github/pr-merge-button.test.tsx`
- `apps/web/src/locales/*/github.json`
## Acceptance
- The existing merge action appears for direct-merge and queue-required PRs
only after explicit successful checks and satisfied review requirements.
- Accepted outcomes show distinct localized merged or queued feedback, refresh
PR state, and prevent duplicate submission; rejected requests remain retryable.
- Desktop, compact, and mobile Review surfaces reuse the same mutation and
eligibility behavior with accessible, touch-usable controls.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- lib/api/domains/github-api.test.ts components/github/pr-task-icon.test.ts components/github/pr-merge-button.test.tsx && cd web && pnpm run i18n:check && pnpm run typecheck
```
## Dependencies And Risks
- Depends on Task 01's response contract.
- `mergeable_state=blocked` is overloaded; the frontend predicate must not turn
failed protection, missing review, or changes-requested states into queue
actions.
- Locale key parity is build-gated across all supported languages.
## Results
- `pnpm --filter @kandev/web test -- --run lib/api/domains/github-api.test.ts components/github/pr-task-icon.test.ts` passed: 96 tests.
- `pnpm run i18n:check` passed with all supported catalogs complete.
- `pnpm run typecheck` passed.
- Review remediation keeps GitHub's overloaded `blocked` state visually neutral
and labels the pre-submit action `Merge PR`; only the accepted response claims
that GitHub added the PR to its merge queue.
- Queue feedback is driven by GitHub's terminal `enqueued` result, not
asynchronous `pending` acceptance.
- Focused terminal-outcome coverage passes for merged, queued, rejected, and
duplicate-click paths. The full frontend suite passed 12,046 tests across
1,455 files, with four skips.
@@ -0,0 +1,59 @@
---
id: "03-desktop-mobile-e2e"
title: "Desktop and mobile merge E2E"
status: done
wave: 3
depends_on: ["01-backend-queue-aware-merge", "02-frontend-merge-outcomes"]
plan: "plan.md"
spec: "../../specs/github-pr-merge-queue/spec.md"
---
# Task 03: Desktop And Mobile Merge E2E
## Inputs
- `docs/specs/github-pr-merge-queue/spec.md`
- `docs/plans/github-pr-merge-queue/plan.md`
- `.agents/skills/e2e/SKILL.md`
- `.agents/skills/mobile-parity/SKILL.md`
## Likely Files
- `apps/web/e2e/tests/pr/pr-merge-queue.spec.ts`
- `apps/web/e2e/tests/pr/mobile-pr-merge-queue.spec.ts`
- `apps/web/e2e/helpers/api-client.ts` or GitHub mock fixtures if response
configuration needs a new seed helper
## Acceptance
- Desktop E2E proves merge-queue acceptance from the existing PR UI.
- Component coverage proves immediate merge feedback and duplicate-submission
suppression without duplicating those deterministic cases in Playwright.
- Mobile E2E reaches the same queued outcome through Review using touch and
verifies action size and absence of horizontal document overflow.
- The managed runner builds fresh backend/frontend artifacts and both focused
projects pass without retries masking failures.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run tests/pr/pr-merge-queue.spec.ts && pnpm e2e:run --project mobile-chrome tests/pr/mobile-pr-merge-queue.spec.ts
```
## Dependencies And Risks
- Depends on Tasks 01 and 02.
- The mock GitHub server must represent queue-required and accepted-queue
states without relying on real GitHub credentials.
- The mobile project only discovers `mobile-*.spec.ts` files; verify the
reported test count before recording success.
## Results
- Desktop managed E2E passed with one discovered test.
- Mobile Chrome managed E2E passed with one discovered test, including the
touch action, minimum target height, and horizontal-overflow assertions.
- `pnpm run typecheck` passed after the final E2E helper and spec changes.
- Isolated desktop and mobile screenshots were captured under
`/tmp/kandev-merge-queue-screenshots-20260817/`; capture-only test hooks were
removed and both specs passed again afterward.
+117
View File
@@ -0,0 +1,117 @@
---
status: building
created: 2026-08-17
owner: Kandev
---
# GitHub PR Merge Queue
## Why
Users can merge an eligible GitHub pull request from Kandev, but repositories
that require GitHub's merge queue leave the same pull request looking blocked
and force the user to leave Kandev. The merge action should respect the
repository's GitHub rules and complete through the appropriate direct or queued
path.
## What
- An open, approved pull request with successful required checks exposes its
merge action in the existing GitHub PR detail and compact status surfaces,
including when GitHub reports it blocked only because the base branch uses a
merge queue.
- Activating the action asks GitHub to choose the appropriate merge behavior:
merge immediately where permitted or add the pull request to the configured
merge queue.
- A direct merge reports that the pull request merged. An accepted queued merge
reports that the pull request was added to the merge queue and prevents
repeated submission while local PR state refreshes.
- The action remains unavailable for drafts, conflicts, changes requested,
failed or incomplete required checks, and unmet required reviews.
- GitHub remains authoritative for final eligibility. A rejected request leaves
the action retryable and shows GitHub's error without claiming that the pull
request merged or entered the queue.
- Desktop and mobile use the existing PR detail behavior. On phones, the action
remains available through the full-height Review surface with a touch-sized
target and no horizontal document overflow.
## API Surface
Kandev retains the existing endpoint and extends its success response:
```http
PUT /api/v1/github/prs/:owner/:repo/:number/merge?workspace_id=:workspaceId
Content-Type: application/json
{"merge_method":"squash"}
```
- `merge_method` remains optional and accepts `merge`, `squash`, or `rebase`.
- Kandev submits the request through GitHub's asynchronous merge API with
`merge_action=default`, allowing GitHub to select direct merge or merge queue.
- Success returns `200` with one of:
```json
{"status":"merged"}
{"status":"queued"}
```
- A `pending` response includes a UUID. Kandev polls that request, including
the UUID returned by an existing-request `409`, until GitHub reports
`merged`, `enqueued`, or `failed`.
- Only `enqueued` maps to `queued`. An already-merged pull request maps to
`merged`, while `failed` remains an error that the user can retry.
## Permissions
The action uses the active workspace's personal-write GitHub routing and
requires the same GitHub content/pull-request permissions as the provider's
merge API. Kandev does not bypass repository rules or elevate the user.
## Failure Modes
- Missing GitHub credentials or required permissions return a non-success
response, surface a useful error, and leave the action retryable.
- GitHub validation, readiness, rate-limit, or transport failures do not change
local PR state and do not show a success notification.
- An unrecognized successful provider status fails closed rather than claiming
that the pull request merged or entered the queue.
- After an accepted request, Kandev invalidates cached PR feedback/status and
refreshes the linked pull request; the GitHub poller remains authoritative
for its eventual merged state.
## Scenarios
- **GIVEN** an approved open PR with successful required checks on a branch
without a required merge queue, **WHEN** the user activates the merge action,
**THEN** GitHub merges it and Kandev reports that the PR merged.
- **GIVEN** an approved open PR with successful required checks on a branch
that requires a merge queue, **WHEN** the user activates the merge action,
**THEN** GitHub accepts it into the queue and Kandev reports that the PR was
added to the merge queue.
- **GIVEN** a PR already in the merge queue, **WHEN** the merge request is
repeated, **THEN** Kandev treats GitHub's idempotent response as queued and
does not report a failure.
- **GIVEN** a draft, conflicted PR, failed checks, changes requested, or missing
required approvals, **WHEN** the PR surface renders, **THEN** no merge or
queue action is available.
- **GIVEN** GitHub rejects an otherwise eligible merge request, **WHEN** the
action completes, **THEN** Kandev shows the provider error and leaves the
action available for retry.
- **GIVEN** a phone-sized task view with an eligible queue-required PR, **WHEN**
the user opens Review and activates the action, **THEN** the queued outcome is
visible, the action is touch-usable, and the document has no horizontal
overflow.
## Out of Scope
- Displaying queue position, estimated merge time, or the full merge queue.
- Removing a pull request from the merge queue.
- Selecting between direct merge and merge queue when GitHub policy permits
both; GitHub's `default` behavior remains authoritative.
- Changing Kandev's independent CI auto-merge automation setting.
- GitLab merge-request behavior.
## Implementation Plan
[Implementation plan](../../plans/github-pr-merge-queue/plan.md)