feat(github): add merge queue actions (#2755)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -377,6 +377,7 @@
|
||||
"prClosedWithoutMerging": "ƤŔ ćĺōśēď ŵĩţĥōũţ ḿēŕĝĩńĝ",
|
||||
"prEvents": "ƤŔ ēvēńţś",
|
||||
"reviewFollowUp": "Ŕēvĩēŵ ƒōĺĺōŵ-ũƥ",
|
||||
"prAddedToMergeQueue": "ƤŔ àďďēď ţō ḿēŕĝē qũēũē",
|
||||
"prMerged": "ƤŔ ḿēŕĝēď",
|
||||
"prTaskStatusCi": "ĆĨ",
|
||||
"prTaskStatusMerge": "Ḿēŕĝē",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -377,6 +377,7 @@
|
||||
"prClosedWithoutMerging": "PR 已关闭且未合并",
|
||||
"prEvents": "PR 事件",
|
||||
"reviewFollowUp": "审查跟进",
|
||||
"prAddedToMergeQueue": "PR 已添加到合并队列",
|
||||
"prMerged": "PR 已合并",
|
||||
"prTaskStatusCi": "CI",
|
||||
"prTaskStatusMerge": "合并",
|
||||
|
||||
@@ -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": "正在建立…",
|
||||
|
||||
@@ -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": "代理程式已結束,但沒有產生任何輸出。",
|
||||
|
||||
@@ -377,6 +377,7 @@
|
||||
"prClosedWithoutMerging": "PR 已關閉且未合併",
|
||||
"prEvents": "PR 事件",
|
||||
"reviewFollowUp": "審查跟進",
|
||||
"prAddedToMergeQueue": "PR 已新增到合併佇列",
|
||||
"prMerged": "PR 已合併",
|
||||
"prTaskStatusCi": "CI",
|
||||
"prTaskStatusMerge": "合併",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} 項阻塞",
|
||||
|
||||
@@ -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": "載入已歸檔任務失敗",
|
||||
|
||||
@@ -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": "以計劃模式啓動任務失敗",
|
||||
|
||||
@@ -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": "請先選擇一個工作流程"
|
||||
|
||||
@@ -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": "正在建立…",
|
||||
|
||||
@@ -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": "代理程式已結束,但沒有產生任何輸出。",
|
||||
|
||||
@@ -377,6 +377,7 @@
|
||||
"prClosedWithoutMerging": "PR 已關閉且未合併",
|
||||
"prEvents": "PR 事件",
|
||||
"reviewFollowUp": "審查跟進",
|
||||
"prAddedToMergeQueue": "PR 已新增到合併佇列",
|
||||
"prMerged": "PR 已合併",
|
||||
"prTaskStatusCi": "CI",
|
||||
"prTaskStatusMerge": "合併",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} 項阻塞",
|
||||
|
||||
@@ -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": "載入已歸檔任務失敗",
|
||||
|
||||
@@ -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": "以計劃模式啟動任務失敗",
|
||||
|
||||
@@ -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": "請先選擇一個工作流程"
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user