Add auto-merge setting and merge now functionality
This commit is contained in:
22
README.md
22
README.md
@@ -126,6 +126,8 @@ open = ["enter", "l"]
|
|||||||
dashboard = ["d"]
|
dashboard = ["d"]
|
||||||
health = ["H"]
|
health = ["H"]
|
||||||
edit = ["e"]
|
edit = ["e"]
|
||||||
|
auto_merge = ["a"]
|
||||||
|
merge_now = ["M"]
|
||||||
toggle_list = ["tab"]
|
toggle_list = ["tab"]
|
||||||
|
|
||||||
[keybindings.threads]
|
[keybindings.threads]
|
||||||
@@ -256,6 +258,8 @@ history and metadata.
|
|||||||
| `H` | Open application health and diagnostics |
|
| `H` | Open application health and diagnostics |
|
||||||
| `d` | Open the current pull request dashboard |
|
| `d` | Open the current pull request dashboard |
|
||||||
| `e` | Edit the current PR title, target branch, and description from its dashboard |
|
| `e` | Edit the current PR title, target branch, and description from its dashboard |
|
||||||
|
| `a` | Enable or disable auto-merge from the dashboard |
|
||||||
|
| `M` | Merge now when GitHub reports that all represented requirements are satisfied |
|
||||||
| `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` |
|
| `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` |
|
||||||
| `F` | Clear active thread filters |
|
| `F` | Clear active thread filters |
|
||||||
| `n` / `N` | Next / previous thread with a new update |
|
| `n` / `N` | Next / previous thread with a new update |
|
||||||
@@ -288,6 +292,15 @@ cancels. Replies and resolution changes require an explicit `y` confirmation.
|
|||||||
Write keys remain disabled for cached snapshots, during refreshes, and whenever
|
Write keys remain disabled for cached snapshots, during refreshes, and whenever
|
||||||
GitHub does not grant the corresponding capability.
|
GitHub does not grant the corresponding capability.
|
||||||
|
|
||||||
|
Auto-merge and immediate merge actions are available from the dashboard and
|
||||||
|
always require confirmation. The selected method is the repository's first
|
||||||
|
available method in `squash`, `merge`, then `rebase` preference order. Both
|
||||||
|
mutations include the currently displayed head commit OID, so a force-push or
|
||||||
|
new commit prevents a stale merge. “Merge now” is gated for drafts, conflicts,
|
||||||
|
required reviews, required checks, unresolved required conversations, closed
|
||||||
|
PRs, and branches that require a merge queue; GitHub performs the final
|
||||||
|
permission and mergeability validation.
|
||||||
|
|
||||||
The dashboard editor works with raw Markdown so template checklists can be
|
The dashboard editor works with raw Markdown so template checklists can be
|
||||||
updated directly. The active line is highlighted without inserting a
|
updated directly. The active line is highlighted without inserting a
|
||||||
layout-changing block character. It opens with the description focused;
|
layout-changing block character. It opens with the description focused;
|
||||||
@@ -324,10 +337,11 @@ submission is blocked rather than overwriting the newer metadata.
|
|||||||
|
|
||||||
## Current scope
|
## Current scope
|
||||||
|
|
||||||
The application can reply to review threads, resolve or unresolve them, and
|
The application can reply to review threads, resolve or unresolve them, update
|
||||||
update the PR title, target branch, and description. Comment reactions remain
|
the PR title, target branch, and description, enable or disable auto-merge, and
|
||||||
read-only. Other write operations remain disabled. The dashboard shows the
|
merge an eligible PR immediately. Comment reactions remain read-only. Other
|
||||||
capability gate, including why each action is unavailable. Read state
|
write operations remain disabled. The dashboard shows the capability gate,
|
||||||
|
including why each action is unavailable. Read state
|
||||||
persists beside the configuration, and recent PR data is cached for offline
|
persists beside the configuration, and recent PR data is cached for offline
|
||||||
fallback. Check contexts and annotations are paginated. GitHub features which
|
fallback. Check contexts and annotations are paginated. GitHub features which
|
||||||
depend on server-side context, such as unfurling issue references or displaying
|
depend on server-side context, such as unfurling issue references or displaying
|
||||||
|
|||||||
6
TODO.md
6
TODO.md
@@ -86,9 +86,9 @@ editing are already implemented.
|
|||||||
multiple suggestions, conflicts, dirty Git/Jujutsu workspaces, and remote
|
multiple suggestions, conflicts, dirty Git/Jujutsu workspaces, and remote
|
||||||
application.
|
application.
|
||||||
- Add draft/ready-for-review and close/reopen actions.
|
- Add draft/ready-for-review and close/reopen actions.
|
||||||
- Consider merge, auto-merge, and merge-queue actions only after required
|
- Add explicit merge-method selection and merge-queue enqueue/dequeue actions.
|
||||||
checks, review decision, permissions, stale-head protection, and destructive
|
Auto-merge toggling and guarded immediate merge are implemented with
|
||||||
confirmations are represented accurately.
|
stale-head protection and destructive confirmation.
|
||||||
- Define consistent optimistic-update and rollback behavior for every mutation.
|
- Define consistent optimistic-update and rollback behavior for every mutation.
|
||||||
Preserve drafts and server responses when a post-mutation refresh fails.
|
Preserve drafts and server responses when a post-mutation refresh fails.
|
||||||
|
|
||||||
|
|||||||
22
cache.go
22
cache.go
@@ -164,6 +164,28 @@ func (c *CachedGitHubService) UpdatePullRequest(
|
|||||||
return writer.UpdatePullRequest(ctx, pullRequestID, update)
|
return writer.UpdatePullRequest(ctx, pullRequestID, update)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CachedGitHubService) SetPullRequestAutoMerge(
|
||||||
|
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool,
|
||||||
|
) (*AutoMergeRequest, error) {
|
||||||
|
writer, ok := c.remote.(GitHubMergeService)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("GitHub service does not support auto-merge")
|
||||||
|
}
|
||||||
|
return writer.SetPullRequestAutoMerge(
|
||||||
|
ctx, pullRequestID, expectedHeadOID, mergeMethod, enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedGitHubService) MergePullRequest(
|
||||||
|
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string,
|
||||||
|
) (PullRequestMergeResult, error) {
|
||||||
|
writer, ok := c.remote.(GitHubMergeService)
|
||||||
|
if !ok {
|
||||||
|
return PullRequestMergeResult{}, errors.New("GitHub service does not support merging")
|
||||||
|
}
|
||||||
|
return writer.MergePullRequest(ctx, pullRequestID, expectedHeadOID, mergeMethod)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CachedGitHubService) ListBranches(
|
func (c *CachedGitHubService) ListBranches(
|
||||||
ctx context.Context, owner, repo string,
|
ctx context.Context, owner, repo string,
|
||||||
) ([]RepositoryBranch, error) {
|
) ([]RepositoryBranch, error) {
|
||||||
|
|||||||
145
github.go
145
github.go
@@ -30,6 +30,11 @@ type GitHubPullRequestWriteService interface {
|
|||||||
UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error)
|
UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GitHubMergeService interface {
|
||||||
|
SetPullRequestAutoMerge(context.Context, string, string, string, bool) (*AutoMergeRequest, error)
|
||||||
|
MergePullRequest(context.Context, string, string, string) (PullRequestMergeResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
type GitHubBranchService interface {
|
type GitHubBranchService interface {
|
||||||
ListBranches(context.Context, string, string) ([]RepositoryBranch, error)
|
ListBranches(context.Context, string, string) ([]RepositoryBranch, error)
|
||||||
}
|
}
|
||||||
@@ -429,7 +434,7 @@ func nullableCursor(cursor string) any {
|
|||||||
const detailsQuery = `
|
const detailsQuery = `
|
||||||
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
||||||
repository(owner: $owner, name: $name) {
|
repository(owner: $owner, name: $name) {
|
||||||
url
|
url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed
|
||||||
viewerPermission
|
viewerPermission
|
||||||
defaultBranchRef { name }
|
defaultBranchRef { name }
|
||||||
rulesets(first: 100, includeParents: true, targets: [BRANCH]) {
|
rulesets(first: 100, includeParents: true, targets: [BRANCH]) {
|
||||||
@@ -440,11 +445,13 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pullRequest(number: $number) {
|
pullRequest(number: $number) {
|
||||||
id number title url body isDraft createdAt updatedAt
|
id number title url body isDraft createdAt updatedAt state merged mergedAt
|
||||||
mergeable mergeStateStatus reviewDecision
|
mergeable mergeStateStatus reviewDecision
|
||||||
|
autoMergeRequest { mergeMethod enabledAt enabledBy { login } }
|
||||||
mergeQueueEntry { state position enqueuedAt estimatedTimeToMerge }
|
mergeQueueEntry { state position enqueuedAt estimatedTimeToMerge }
|
||||||
baseRefName headRefName headRefOid
|
baseRefName headRefName headRefOid
|
||||||
viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge
|
viewerCanUpdate viewerCanReact viewerCanSubscribe
|
||||||
|
viewerCanEnableAutoMerge viewerCanDisableAutoMerge
|
||||||
baseRef {
|
baseRef {
|
||||||
target { ... on Commit { oid } }
|
target { ... on Commit { oid } }
|
||||||
branchProtectionRule {
|
branchProtectionRule {
|
||||||
@@ -631,6 +638,29 @@ mutation UpdatePullRequest($input: UpdatePullRequestInput!) {
|
|||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
const enableAutoMergeMutation = `
|
||||||
|
mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) {
|
||||||
|
enablePullRequestAutoMerge(input: $input) {
|
||||||
|
pullRequest {
|
||||||
|
autoMergeRequest { mergeMethod enabledAt enabledBy { login } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const disableAutoMergeMutation = `
|
||||||
|
mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) {
|
||||||
|
disablePullRequestAutoMerge(input: $input) {
|
||||||
|
pullRequest { id autoMergeRequest { mergeMethod } }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const mergePullRequestMutation = `
|
||||||
|
mutation MergePullRequest($input: MergePullRequestInput!) {
|
||||||
|
mergePullRequest(input: $input) {
|
||||||
|
pullRequest { merged mergedAt }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
type githubActor struct {
|
type githubActor struct {
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -759,13 +789,19 @@ type githubRuleset struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type githubPullRequestDetails struct {
|
type githubPullRequestDetails struct {
|
||||||
ID, Title, URL, Body, Mergeable, MergeStateStatus string
|
ID, Title, URL, Body, State, Mergeable, MergeStateStatus string
|
||||||
ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string
|
ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string
|
||||||
Number, Additions, Deletions, ChangedFiles int
|
Number, Additions, Deletions, ChangedFiles int
|
||||||
IsDraft bool
|
IsDraft, Merged bool
|
||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt, MergedAt time.Time
|
||||||
Author *githubActor
|
Author *githubActor
|
||||||
ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe, ViewerCanEnableAutoMerge bool
|
ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe bool
|
||||||
|
ViewerCanEnableAutoMerge, ViewerCanDisableAutoMerge bool
|
||||||
|
AutoMergeRequest *struct {
|
||||||
|
MergeMethod string
|
||||||
|
EnabledAt time.Time
|
||||||
|
EnabledBy *githubActor
|
||||||
|
}
|
||||||
BaseRef *struct {
|
BaseRef *struct {
|
||||||
Target *struct{ OID string }
|
Target *struct{ OID string }
|
||||||
BranchProtectionRule *struct {
|
BranchProtectionRule *struct {
|
||||||
@@ -1059,6 +1095,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
Repository *struct {
|
Repository *struct {
|
||||||
URL string
|
URL string
|
||||||
ViewerPermission string `json:"viewerPermission"`
|
ViewerPermission string `json:"viewerPermission"`
|
||||||
|
MergeCommitAllowed, SquashMergeAllowed, RebaseMergeAllowed bool
|
||||||
DefaultBranchRef *struct{ Name string }
|
DefaultBranchRef *struct{ Name string }
|
||||||
Rulesets struct{ Nodes []githubRuleset }
|
Rulesets struct{ Nodes []githubRuleset }
|
||||||
PullRequest *githubPullRequestDetails `json:"pullRequest"`
|
PullRequest *githubPullRequestDetails `json:"pullRequest"`
|
||||||
@@ -1138,6 +1175,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
},
|
},
|
||||||
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
||||||
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
||||||
|
State: node.State, Merged: node.Merged, MergedAt: node.MergedAt,
|
||||||
RepositoryURL: data.Repository.URL,
|
RepositoryURL: data.Repository.URL,
|
||||||
Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles,
|
Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles,
|
||||||
CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount,
|
CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount,
|
||||||
@@ -1146,8 +1184,25 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
|||||||
Repository: data.Repository.ViewerPermission,
|
Repository: data.Repository.ViewerPermission,
|
||||||
CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact,
|
CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact,
|
||||||
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
||||||
|
CanDisableMerge: node.ViewerCanDisableAutoMerge,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if data.Repository.SquashMergeAllowed {
|
||||||
|
details.AllowedMergeMethods = append(details.AllowedMergeMethods, "SQUASH")
|
||||||
|
}
|
||||||
|
if data.Repository.MergeCommitAllowed {
|
||||||
|
details.AllowedMergeMethods = append(details.AllowedMergeMethods, "MERGE")
|
||||||
|
}
|
||||||
|
if data.Repository.RebaseMergeAllowed {
|
||||||
|
details.AllowedMergeMethods = append(details.AllowedMergeMethods, "REBASE")
|
||||||
|
}
|
||||||
|
if node.AutoMergeRequest != nil {
|
||||||
|
details.AutoMerge = &AutoMergeRequest{
|
||||||
|
MergeMethod: node.AutoMergeRequest.MergeMethod,
|
||||||
|
EnabledAt: node.AutoMergeRequest.EnabledAt,
|
||||||
|
EnabledBy: actorLogin(node.AutoMergeRequest.EnabledBy),
|
||||||
|
}
|
||||||
|
}
|
||||||
if node.BaseRef != nil && node.BaseRef.Target != nil {
|
if node.BaseRef != nil && node.BaseRef.Target != nil {
|
||||||
details.BaseOID = node.BaseRef.Target.OID
|
details.BaseOID = node.BaseRef.Target.OID
|
||||||
}
|
}
|
||||||
@@ -1460,6 +1515,82 @@ func (c *GitHubClient) UpdatePullRequest(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) SetPullRequestAutoMerge(
|
||||||
|
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool,
|
||||||
|
) (*AutoMergeRequest, error) {
|
||||||
|
if !enabled {
|
||||||
|
var data struct {
|
||||||
|
DisablePullRequestAutoMerge *struct{ PullRequest *struct{ ID string } }
|
||||||
|
}
|
||||||
|
if err := c.query(ctx, disableAutoMergeMutation, map[string]any{
|
||||||
|
"input": map[string]any{"pullRequestId": pullRequestID},
|
||||||
|
}, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data.DisablePullRequestAutoMerge == nil ||
|
||||||
|
data.DisablePullRequestAutoMerge.PullRequest == nil {
|
||||||
|
return nil, errors.New("GitHub returned no pull request after disabling auto-merge")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
EnablePullRequestAutoMerge *struct {
|
||||||
|
PullRequest *struct {
|
||||||
|
AutoMergeRequest *struct {
|
||||||
|
MergeMethod string
|
||||||
|
EnabledAt time.Time
|
||||||
|
EnabledBy *githubActor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input := map[string]any{
|
||||||
|
"pullRequestId": pullRequestID, "expectedHeadOid": expectedHeadOID,
|
||||||
|
"mergeMethod": mergeMethod,
|
||||||
|
}
|
||||||
|
if err := c.query(ctx, enableAutoMergeMutation, map[string]any{"input": input}, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data.EnablePullRequestAutoMerge == nil ||
|
||||||
|
data.EnablePullRequestAutoMerge.PullRequest == nil ||
|
||||||
|
data.EnablePullRequestAutoMerge.PullRequest.AutoMergeRequest == nil {
|
||||||
|
return nil, errors.New("GitHub returned no auto-merge request")
|
||||||
|
}
|
||||||
|
node := data.EnablePullRequestAutoMerge.PullRequest.AutoMergeRequest
|
||||||
|
return &AutoMergeRequest{
|
||||||
|
MergeMethod: node.MergeMethod, EnabledAt: node.EnabledAt,
|
||||||
|
EnabledBy: actorLogin(node.EnabledBy),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GitHubClient) MergePullRequest(
|
||||||
|
ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string,
|
||||||
|
) (PullRequestMergeResult, error) {
|
||||||
|
var data struct {
|
||||||
|
MergePullRequest *struct {
|
||||||
|
PullRequest *struct {
|
||||||
|
Merged bool
|
||||||
|
MergedAt time.Time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.query(ctx, mergePullRequestMutation, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"pullRequestId": pullRequestID, "expectedHeadOid": expectedHeadOID,
|
||||||
|
"mergeMethod": mergeMethod,
|
||||||
|
},
|
||||||
|
}, &data); err != nil {
|
||||||
|
return PullRequestMergeResult{}, err
|
||||||
|
}
|
||||||
|
if data.MergePullRequest == nil || data.MergePullRequest.PullRequest == nil ||
|
||||||
|
!data.MergePullRequest.PullRequest.Merged {
|
||||||
|
return PullRequestMergeResult{}, errors.New("GitHub did not merge the pull request")
|
||||||
|
}
|
||||||
|
return PullRequestMergeResult{
|
||||||
|
Merged: true, MergedAt: data.MergePullRequest.PullRequest.MergedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func convertReviewThread(thread githubReviewThread) ReviewThread {
|
func convertReviewThread(thread githubReviewThread) ReviewThread {
|
||||||
item := ReviewThread{
|
item := ReviewThread{
|
||||||
ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide,
|
ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide,
|
||||||
|
|||||||
@@ -294,6 +294,67 @@ func TestUpdatePullRequestMutatesTitleBodyAndBaseBranch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeAndAutoMergeMutationsUseExpectedHeadOID(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests++
|
||||||
|
var request graphQLRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := request.Variables["input"].(map[string]any)
|
||||||
|
if input["pullRequestId"] != "pr-id" {
|
||||||
|
t.Fatalf("mutation input = %#v", input)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(request.Query, "EnablePullRequestAutoMerge"):
|
||||||
|
if input["expectedHeadOid"] != "head" || input["mergeMethod"] != "SQUASH" {
|
||||||
|
t.Fatalf("enable input = %#v", input)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"enablePullRequestAutoMerge":{"pullRequest":{
|
||||||
|
"autoMergeRequest":{"mergeMethod":"SQUASH","enabledAt":"2026-07-28T12:00:00Z",
|
||||||
|
"enabledBy":{"login":"me"}}
|
||||||
|
}}}}`))
|
||||||
|
case strings.Contains(request.Query, "DisablePullRequestAutoMerge"):
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"disablePullRequestAutoMerge":{
|
||||||
|
"pullRequest":{"id":"pr-id","autoMergeRequest":null}
|
||||||
|
}}}`))
|
||||||
|
case strings.Contains(request.Query, "MergePullRequest"):
|
||||||
|
if input["expectedHeadOid"] != "head" || input["mergeMethod"] != "SQUASH" {
|
||||||
|
t.Fatalf("merge input = %#v", input)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"mergePullRequest":{"pullRequest":{
|
||||||
|
"merged":true,"mergedAt":"2026-07-28T12:01:00Z"
|
||||||
|
}}}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected mutation:\n%s", request.Query)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewGitHubClient(server.URL, "secret")
|
||||||
|
auto, err := client.SetPullRequestAutoMerge(
|
||||||
|
context.Background(), "pr-id", "head", "SQUASH", true,
|
||||||
|
)
|
||||||
|
if err != nil || auto == nil || auto.MergeMethod != "SQUASH" || auto.EnabledBy != "me" {
|
||||||
|
t.Fatalf("enable result = %#v, error = %v", auto, err)
|
||||||
|
}
|
||||||
|
if _, err := client.SetPullRequestAutoMerge(
|
||||||
|
context.Background(), "pr-id", "head", "SQUASH", false,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
merged, err := client.MergePullRequest(
|
||||||
|
context.Background(), "pr-id", "head", "SQUASH",
|
||||||
|
)
|
||||||
|
if err != nil || !merged.Merged || merged.MergedAt.IsZero() {
|
||||||
|
t.Fatalf("merge result = %#v, error = %v", merged, err)
|
||||||
|
}
|
||||||
|
if requests != 3 {
|
||||||
|
t.Fatalf("mutation requests = %d, want 3", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) {
|
func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var request graphQLRequest
|
var request graphQLRequest
|
||||||
@@ -428,14 +489,18 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing
|
|||||||
|
|
||||||
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{
|
_, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE",
|
||||||
|
"squashMergeAllowed":true,"mergeCommitAllowed":false,"rebaseMergeAllowed":true,
|
||||||
|
"pullRequest":{
|
||||||
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
|
"id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false,
|
||||||
"createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
|
"createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
|
||||||
"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
|
"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
|
||||||
"additions":12,"deletions":4,"changedFiles":3,
|
"additions":12,"deletions":4,"changedFiles":3,
|
||||||
"baseRefName":"main","headRefName":"fix","headRefOid":"abcdef0123456789",
|
"baseRefName":"main","headRefName":"fix","headRefOid":"abcdef0123456789",
|
||||||
"viewerCanUpdate":true,"viewerCanReact":true,"viewerCanSubscribe":true,
|
"viewerCanUpdate":true,"viewerCanReact":true,"viewerCanSubscribe":true,
|
||||||
"viewerCanEnableAutoMerge":true,
|
"viewerCanEnableAutoMerge":false,"viewerCanDisableAutoMerge":true,
|
||||||
|
"autoMergeRequest":{"mergeMethod":"SQUASH","enabledAt":"2026-01-01T01:00:00Z",
|
||||||
|
"enabledBy":{"login":"zam"}},
|
||||||
"baseRef":{"branchProtectionRule":{"requiresApprovingReviews":true,
|
"baseRef":{"branchProtectionRule":{"requiresApprovingReviews":true,
|
||||||
"requiredApprovingReviewCount":2,"requiresStatusChecks":true,
|
"requiredApprovingReviewCount":2,"requiresStatusChecks":true,
|
||||||
"requiresConversationResolution":true,"requiresCodeOwnerReviews":true}},
|
"requiresConversationResolution":true,"requiresCodeOwnerReviews":true}},
|
||||||
@@ -493,6 +558,11 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
|||||||
!got.Requirements.RequiresConversation || !got.Requirements.RequiresCodeOwnerReview {
|
!got.Requirements.RequiresConversation || !got.Requirements.RequiresCodeOwnerReview {
|
||||||
t.Fatalf("unexpected read capabilities: %#v", got)
|
t.Fatalf("unexpected read capabilities: %#v", got)
|
||||||
}
|
}
|
||||||
|
if got.AutoMerge == nil || got.AutoMerge.MergeMethod != "SQUASH" ||
|
||||||
|
got.AutoMerge.EnabledBy != "zam" || !got.Permissions.CanDisableMerge ||
|
||||||
|
strings.Join(got.AllowedMergeMethods, ",") != "SQUASH,REBASE" {
|
||||||
|
t.Fatalf("unexpected merge metadata: %#v", got)
|
||||||
|
}
|
||||||
if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 ||
|
if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 ||
|
||||||
got.Threads[0].DiffSide != "RIGHT" || got.Threads[0].IsTruncated {
|
got.Threads[0].DiffSide != "RIGHT" || got.Threads[0].IsTruncated {
|
||||||
t.Fatalf("unexpected thread: %#v", got.Threads)
|
t.Fatalf("unexpected thread: %#v", got.Threads)
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ type ViewKeyBindings struct {
|
|||||||
Dashboard []string `toml:"dashboard"`
|
Dashboard []string `toml:"dashboard"`
|
||||||
Health []string `toml:"health"`
|
Health []string `toml:"health"`
|
||||||
Edit []string `toml:"edit"`
|
Edit []string `toml:"edit"`
|
||||||
|
AutoMerge []string `toml:"auto_merge"`
|
||||||
|
MergeNow []string `toml:"merge_now"`
|
||||||
ToggleList []string `toml:"toggle_list"`
|
ToggleList []string `toml:"toggle_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +121,7 @@ func defaultKeyBindings() KeyBindings {
|
|||||||
Views: ViewKeyBindings{
|
Views: ViewKeyBindings{
|
||||||
Open: []string{"enter", "l"}, Dashboard: []string{"d"},
|
Open: []string{"enter", "l"}, Dashboard: []string{"d"},
|
||||||
Health: []string{"H"}, Edit: []string{"e"}, ToggleList: []string{"tab"},
|
Health: []string{"H"}, Edit: []string{"e"}, ToggleList: []string{"tab"},
|
||||||
|
AutoMerge: []string{"a"}, MergeNow: []string{"M"},
|
||||||
},
|
},
|
||||||
Threads: ThreadKeyBindings{
|
Threads: ThreadKeyBindings{
|
||||||
Search: []string{"/"}, ClearFilter: []string{"F"},
|
Search: []string{"/"}, ClearFilter: []string{"F"},
|
||||||
@@ -294,6 +297,14 @@ func (k KeyBindings) canonicalMainKey(key string, current screen) string {
|
|||||||
return "enter"
|
return "enter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current == dashboardScreen {
|
||||||
|
switch {
|
||||||
|
case keyMatches(key, k.Views.AutoMerge):
|
||||||
|
return "a"
|
||||||
|
case keyMatches(key, k.Views.MergeNow):
|
||||||
|
return "M"
|
||||||
|
}
|
||||||
|
}
|
||||||
if current == threadScreen {
|
if current == threadScreen {
|
||||||
switch {
|
switch {
|
||||||
case keyMatches(key, k.Navigation.Left):
|
case keyMatches(key, k.Navigation.Left):
|
||||||
@@ -387,6 +398,7 @@ func validateKeyBindings(bindings KeyBindings) error {
|
|||||||
{"keybindings.views", map[string][]string{
|
{"keybindings.views", map[string][]string{
|
||||||
"open": bindings.Views.Open, "dashboard": bindings.Views.Dashboard,
|
"open": bindings.Views.Open, "dashboard": bindings.Views.Dashboard,
|
||||||
"health": bindings.Views.Health, "edit": bindings.Views.Edit,
|
"health": bindings.Views.Health, "edit": bindings.Views.Edit,
|
||||||
|
"auto_merge": bindings.Views.AutoMerge, "merge_now": bindings.Views.MergeNow,
|
||||||
"toggle_list": bindings.Views.ToggleList,
|
"toggle_list": bindings.Views.ToggleList,
|
||||||
}},
|
}},
|
||||||
{"keybindings.threads", map[string][]string{
|
{"keybindings.threads", map[string][]string{
|
||||||
@@ -478,6 +490,8 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
|
|||||||
if err := validateKeyContext("dashboard", append(screenCommon,
|
if err := validateKeyContext("dashboard", append(screenCommon,
|
||||||
contextBinding{"open", views.Open},
|
contextBinding{"open", views.Open},
|
||||||
contextBinding{"edit", views.Edit},
|
contextBinding{"edit", views.Edit},
|
||||||
|
contextBinding{"auto_merge", views.AutoMerge},
|
||||||
|
contextBinding{"merge_now", views.MergeNow},
|
||||||
)...); err != nil {
|
)...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -173,6 +173,8 @@ func exitf(format string, args ...any) {
|
|||||||
|
|
||||||
// Keep interface drift visible at compile time.
|
// Keep interface drift visible at compile time.
|
||||||
var _ GitHubService = (*GitHubClient)(nil)
|
var _ GitHubService = (*GitHubClient)(nil)
|
||||||
|
var _ GitHubMergeService = (*GitHubClient)(nil)
|
||||||
|
var _ GitHubMergeService = (*CachedGitHubService)(nil)
|
||||||
var _ GitHubWriteService = (*GitHubClient)(nil)
|
var _ GitHubWriteService = (*GitHubClient)(nil)
|
||||||
var _ GitHubWriteService = (*CachedGitHubService)(nil)
|
var _ GitHubWriteService = (*CachedGitHubService)(nil)
|
||||||
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
|
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
|
||||||
|
|||||||
275
tui.go
275
tui.go
@@ -46,6 +46,10 @@ const (
|
|||||||
writePREdit
|
writePREdit
|
||||||
writePREditConfirm
|
writePREditConfirm
|
||||||
writePREditBusy
|
writePREditBusy
|
||||||
|
writeAutoMergeConfirm
|
||||||
|
writeAutoMergeBusy
|
||||||
|
writeMergeNowConfirm
|
||||||
|
writeMergeNowBusy
|
||||||
)
|
)
|
||||||
|
|
||||||
type threadResolvedMsg struct {
|
type threadResolvedMsg struct {
|
||||||
@@ -65,6 +69,17 @@ type pullRequestUpdatedMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type autoMergeUpdatedMsg struct {
|
||||||
|
request *AutoMergeRequest
|
||||||
|
enabled bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type pullRequestMergedMsg struct {
|
||||||
|
result PullRequestMergeResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
type prsLoadedMsg struct {
|
type prsLoadedMsg struct {
|
||||||
prs []PullRequest
|
prs []PullRequest
|
||||||
err error
|
err error
|
||||||
@@ -128,6 +143,8 @@ type App struct {
|
|||||||
writeThreadID string
|
writeThreadID string
|
||||||
replyDraft string
|
replyDraft string
|
||||||
resolveTarget bool
|
resolveTarget bool
|
||||||
|
autoMergeTarget bool
|
||||||
|
mergeMethod string
|
||||||
prEditField int
|
prEditField int
|
||||||
prEditEditors [3]textEditor
|
prEditEditors [3]textEditor
|
||||||
prEditOriginal PullRequestMetadata
|
prEditOriginal PullRequestMetadata
|
||||||
@@ -361,6 +378,110 @@ func (m *App) startResolveToggle() {
|
|||||||
writeResolveConfirm, thread.ID, !thread.IsResolved, nil
|
writeResolveConfirm, thread.ID, !thread.IsResolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *App) startAutoMergeToggle() {
|
||||||
|
target := m.details.AutoMerge == nil
|
||||||
|
if reason := m.mergeActionUnavailable(target); reason != "" {
|
||||||
|
m.err = errors.New(reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.autoMergeTarget = target
|
||||||
|
m.mergeMethod = preferredMergeMethod(m.details)
|
||||||
|
m.writeMode, m.err = writeAutoMergeConfirm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *App) startMergeNow() {
|
||||||
|
if reason := m.mergeNowUnavailable(); reason != "" {
|
||||||
|
m.err = errors.New(reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mergeMethod = preferredMergeMethod(m.details)
|
||||||
|
m.writeMode, m.err = writeMergeNowConfirm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preferredMergeMethod(pr PRDetails) string {
|
||||||
|
for _, preferred := range []string{"SQUASH", "MERGE", "REBASE"} {
|
||||||
|
for _, allowed := range pr.AllowedMergeMethods {
|
||||||
|
if allowed == preferred {
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "MERGE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) mergeActionUnavailable(enabling bool) string {
|
||||||
|
if m.loading {
|
||||||
|
return "auto-merge unavailable while PR data is refreshing"
|
||||||
|
}
|
||||||
|
if m.details.FromCache {
|
||||||
|
return "auto-merge unavailable from an offline cached snapshot"
|
||||||
|
}
|
||||||
|
if _, ok := m.service.(GitHubMergeService); !ok {
|
||||||
|
return "configured GitHub service does not support auto-merge"
|
||||||
|
}
|
||||||
|
if m.details.Merged || m.details.State == "CLOSED" {
|
||||||
|
return "pull request is already closed"
|
||||||
|
}
|
||||||
|
if enabling {
|
||||||
|
if m.details.HeadOID == "" {
|
||||||
|
return "current pull request head commit is unavailable"
|
||||||
|
}
|
||||||
|
if !m.details.Permissions.CanEnableMerge {
|
||||||
|
return "GitHub did not grant permission to enable auto-merge"
|
||||||
|
}
|
||||||
|
if len(m.details.AllowedMergeMethods) == 0 {
|
||||||
|
return "repository does not expose an allowed merge method"
|
||||||
|
}
|
||||||
|
} else if !m.details.Permissions.CanDisableMerge {
|
||||||
|
return "GitHub did not grant permission to disable auto-merge"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) mergeNowUnavailable() string {
|
||||||
|
if reason := m.mergeActionUnavailable(true); reason != "" &&
|
||||||
|
!strings.Contains(reason, "enable auto-merge") {
|
||||||
|
return strings.ReplaceAll(reason, "auto-merge", "merge")
|
||||||
|
}
|
||||||
|
return mergeNowStateReason(m.details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeNowStateReason(pr PRDetails) string {
|
||||||
|
if pr.Merged {
|
||||||
|
return "pull request is already merged"
|
||||||
|
}
|
||||||
|
if pr.State == "CLOSED" {
|
||||||
|
return "pull request is closed"
|
||||||
|
}
|
||||||
|
if pr.IsDraft {
|
||||||
|
return "draft pull requests cannot be merged"
|
||||||
|
}
|
||||||
|
if pr.Requirements.RequiresMergeQueue {
|
||||||
|
return "this branch requires the merge queue"
|
||||||
|
}
|
||||||
|
if pr.Mergeable != "MERGEABLE" {
|
||||||
|
if pr.Mergeable == "CONFLICTING" {
|
||||||
|
return "pull request has merge conflicts"
|
||||||
|
}
|
||||||
|
return "GitHub has not determined that the pull request is mergeable"
|
||||||
|
}
|
||||||
|
if pr.Requirements.RequiresApprovals && pr.ReviewDecision != "APPROVED" {
|
||||||
|
return "required approving reviews are not complete"
|
||||||
|
}
|
||||||
|
if pr.Requirements.RequiresStatusChecks &&
|
||||||
|
pr.CheckState != "SUCCESS" && pr.CheckState != "EXPECTED" {
|
||||||
|
return "required status checks are not successful"
|
||||||
|
}
|
||||||
|
open, outdated, _ := threadStatusCounts(pr.Threads)
|
||||||
|
if pr.Requirements.RequiresConversation && open+outdated > 0 {
|
||||||
|
return "required review conversations are unresolved"
|
||||||
|
}
|
||||||
|
if len(pr.AllowedMergeMethods) == 0 {
|
||||||
|
return "repository does not expose an allowed merge method"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) writeActionUnavailable(action string, thread *ReviewThread) string {
|
func (m App) writeActionUnavailable(action string, thread *ReviewThread) string {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return "write action unavailable while PR data is refreshing"
|
return "write action unavailable while PR data is refreshing"
|
||||||
@@ -443,6 +564,22 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "n", "esc":
|
case "n", "esc":
|
||||||
m.writeMode, m.writeThreadID = writeNone, ""
|
m.writeMode, m.writeThreadID = writeNone, ""
|
||||||
}
|
}
|
||||||
|
case writeAutoMergeConfirm:
|
||||||
|
switch k {
|
||||||
|
case "y":
|
||||||
|
m.writeMode = writeAutoMergeBusy
|
||||||
|
return m, m.submitAutoMerge()
|
||||||
|
case "n", "esc":
|
||||||
|
m.writeMode = writeNone
|
||||||
|
}
|
||||||
|
case writeMergeNowConfirm:
|
||||||
|
switch k {
|
||||||
|
case "y":
|
||||||
|
m.writeMode = writeMergeNowBusy
|
||||||
|
return m, m.submitMergeNow()
|
||||||
|
case "n", "esc":
|
||||||
|
m.writeMode = writeNone
|
||||||
|
}
|
||||||
case writePREdit, writePREditConfirm:
|
case writePREdit, writePREditConfirm:
|
||||||
return m.updatePREditInput(key)
|
return m.updatePREditInput(key)
|
||||||
}
|
}
|
||||||
@@ -471,6 +608,28 @@ func (m App) submitResolution() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) submitAutoMerge() tea.Cmd {
|
||||||
|
writer := m.service.(GitHubMergeService)
|
||||||
|
id, head, method, enabled := m.details.ID, m.details.HeadOID, m.mergeMethod, m.autoMergeTarget
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
request, err := writer.SetPullRequestAutoMerge(ctx, id, head, method, enabled)
|
||||||
|
return autoMergeUpdatedMsg{request: request, enabled: enabled, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) submitMergeNow() tea.Cmd {
|
||||||
|
writer := m.service.(GitHubMergeService)
|
||||||
|
id, head, method := m.details.ID, m.details.HeadOID, m.mergeMethod
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
result, err := writer.MergePullRequest(ctx, id, head, method)
|
||||||
|
return pullRequestMergedMsg{result: result, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@@ -713,6 +872,37 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.replyDraft, m.writeThreadID = "", ""
|
m.replyDraft, m.writeThreadID = "", ""
|
||||||
m.err = nil
|
m.err = nil
|
||||||
m.lastRefresh = time.Now()
|
m.lastRefresh = time.Now()
|
||||||
|
case autoMergeUpdatedMsg:
|
||||||
|
m.writeMode = writeNone
|
||||||
|
if msg.err != nil {
|
||||||
|
m.err = fmt.Errorf("change auto-merge: %w", msg.err)
|
||||||
|
m.recordHealth("auto-merge", healthError, msg.err.Error())
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.err = nil
|
||||||
|
if msg.enabled {
|
||||||
|
m.details.AutoMerge = msg.request
|
||||||
|
m.details.Permissions.CanEnableMerge = false
|
||||||
|
m.details.Permissions.CanDisableMerge = true
|
||||||
|
} else {
|
||||||
|
m.details.AutoMerge = nil
|
||||||
|
m.details.Permissions.CanEnableMerge = true
|
||||||
|
m.details.Permissions.CanDisableMerge = false
|
||||||
|
}
|
||||||
|
m.lastRefresh = time.Now()
|
||||||
|
case pullRequestMergedMsg:
|
||||||
|
m.writeMode = writeNone
|
||||||
|
if msg.err != nil {
|
||||||
|
m.err = fmt.Errorf("merge pull request: %w", msg.err)
|
||||||
|
m.recordHealth("merge pull request", healthError, msg.err.Error())
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.err = nil
|
||||||
|
m.details.Merged = msg.result.Merged
|
||||||
|
m.details.MergedAt = msg.result.MergedAt
|
||||||
|
m.details.State = "MERGED"
|
||||||
|
m.details.AutoMerge = nil
|
||||||
|
m.lastRefresh = time.Now()
|
||||||
case pullRequestUpdatedMsg:
|
case pullRequestUpdatedMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.writeMode = writePREdit
|
m.writeMode = writePREdit
|
||||||
@@ -863,6 +1053,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.screen == dashboardScreen {
|
if m.screen == dashboardScreen {
|
||||||
return m, m.startPREdit()
|
return m, m.startPREdit()
|
||||||
}
|
}
|
||||||
|
case "a":
|
||||||
|
if m.screen == dashboardScreen {
|
||||||
|
m.startAutoMergeToggle()
|
||||||
|
}
|
||||||
|
case "M":
|
||||||
|
if m.screen == dashboardScreen {
|
||||||
|
m.startMergeNow()
|
||||||
|
}
|
||||||
case "F":
|
case "F":
|
||||||
if m.screen == threadScreen {
|
if m.screen == threadScreen {
|
||||||
m.searchQuery = ""
|
m.searchQuery = ""
|
||||||
@@ -1574,6 +1772,46 @@ func (m App) viewWritePopup() string {
|
|||||||
action = "Unresolving"
|
action = "Unresolving"
|
||||||
}
|
}
|
||||||
lines = []string{titleStyle.Render(action + " thread…"), "", dimStyle.Render(location)}
|
lines = []string{titleStyle.Render(action + " thread…"), "", dimStyle.Render(location)}
|
||||||
|
case writeAutoMergeConfirm:
|
||||||
|
action := "Enable"
|
||||||
|
detail := " using " + strings.ToLower(m.mergeMethod)
|
||||||
|
if !m.autoMergeTarget {
|
||||||
|
action, detail = "Disable", ""
|
||||||
|
}
|
||||||
|
lines = []string{
|
||||||
|
titleStyle.Render(fmt.Sprintf("%s auto-merge for %s #%d%s?",
|
||||||
|
action, m.details.RepoWithOwner, m.details.Number, detail)),
|
||||||
|
"",
|
||||||
|
warnStyle.Render(fmt.Sprintf(
|
||||||
|
"%s confirm • %s cancel",
|
||||||
|
primaryKeyLabel(m.keybindings.General.Confirm),
|
||||||
|
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
case writeAutoMergeBusy:
|
||||||
|
action := "Enabling"
|
||||||
|
if !m.autoMergeTarget {
|
||||||
|
action = "Disabling"
|
||||||
|
}
|
||||||
|
lines = []string{titleStyle.Render(action + " auto-merge…")}
|
||||||
|
case writeMergeNowConfirm:
|
||||||
|
lines = []string{
|
||||||
|
titleStyle.Render(fmt.Sprintf(
|
||||||
|
"Merge %s #%d into %s now using %s?",
|
||||||
|
m.details.RepoWithOwner, m.details.Number, m.details.BaseRef,
|
||||||
|
strings.ToLower(m.mergeMethod),
|
||||||
|
)),
|
||||||
|
"",
|
||||||
|
badStyle.Render("This action cannot be undone from gh-threads."),
|
||||||
|
"",
|
||||||
|
warnStyle.Render(fmt.Sprintf(
|
||||||
|
"%s merge now • %s cancel",
|
||||||
|
primaryKeyLabel(m.keybindings.General.Confirm),
|
||||||
|
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
case writeMergeNowBusy:
|
||||||
|
lines = []string{titleStyle.Render("Merging pull request…")}
|
||||||
case writePREditConfirm:
|
case writePREditConfirm:
|
||||||
lines = m.prEditConfirmationLines(width - 2)
|
lines = m.prEditConfirmationLines(width - 2)
|
||||||
case writePREditBusy:
|
case writePREditBusy:
|
||||||
@@ -1719,6 +1957,8 @@ func (m App) helpBindings() []helpBinding {
|
|||||||
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"},
|
{combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"},
|
||||||
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
|
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
|
||||||
{keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"},
|
{keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"},
|
||||||
|
{keyLabel(m.keybindings.Views.AutoMerge), "Enable or disable auto-merge"},
|
||||||
|
{keyLabel(m.keybindings.Views.MergeNow), "Merge the pull request now when all requirements are met"},
|
||||||
{keyLabel(m.keybindings.Views.Open), "Open review threads"},
|
{keyLabel(m.keybindings.Views.Open), "Open review threads"},
|
||||||
{keyLabel(m.keybindings.General.Back), backAction},
|
{keyLabel(m.keybindings.General.Back), backAction},
|
||||||
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
|
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
|
||||||
@@ -2244,6 +2484,7 @@ func (m App) dashboardLines() []string {
|
|||||||
dashboardMetadata("review", reviewAndMergeState(pr)),
|
dashboardMetadata("review", reviewAndMergeState(pr)),
|
||||||
dashboardMetadata("checks", coloredState(pr.CheckState)),
|
dashboardMetadata("checks", coloredState(pr.CheckState)),
|
||||||
dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")),
|
dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")),
|
||||||
|
dashboardMetadata("auto-merge", autoMergeStateText(pr)),
|
||||||
)
|
)
|
||||||
lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...)
|
lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...)
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
@@ -2540,7 +2781,7 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
|||||||
return []writeCapability{
|
return []writeCapability{
|
||||||
{name: "reply", reason: reason}, {name: "resolve", reason: reason},
|
{name: "reply", reason: reason}, {name: "resolve", reason: reason},
|
||||||
{name: "react", reason: reason}, {name: "update pull request", reason: reason},
|
{name: "react", reason: reason}, {name: "update pull request", reason: reason},
|
||||||
{name: "auto-merge", reason: reason},
|
{name: "auto-merge", reason: reason}, {name: "merge now", reason: reason},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
threadReason := "select a review thread"
|
threadReason := "select a review thread"
|
||||||
@@ -2556,12 +2797,20 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
|||||||
}
|
}
|
||||||
threadReason = "GitHub did not grant permission for this thread"
|
threadReason = "GitHub did not grant permission for this thread"
|
||||||
}
|
}
|
||||||
|
autoMergeAllowed := !pr.Merged && pr.State != "CLOSED" &&
|
||||||
|
(pr.Permissions.CanEnableMerge || pr.Permissions.CanDisableMerge)
|
||||||
|
autoMergeReason := "auto-merge is unavailable for this PR"
|
||||||
|
if pr.Merged || pr.State == "CLOSED" {
|
||||||
|
autoMergeReason = "pull request is already closed"
|
||||||
|
}
|
||||||
return []writeCapability{
|
return []writeCapability{
|
||||||
capability("reply", canReply, threadReason, true),
|
capability("reply", canReply, threadReason, true),
|
||||||
capability(resolveName, canResolve, threadReason, true),
|
capability(resolveName, canResolve, threadReason, true),
|
||||||
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
|
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
|
||||||
capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true),
|
capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true),
|
||||||
capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR", false),
|
capability("auto-merge", autoMergeAllowed, autoMergeReason, true),
|
||||||
|
capability("merge now", mergeNowStateReason(pr) == "",
|
||||||
|
firstNonEmpty(mergeNowStateReason(pr), "available"), true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2610,7 +2859,10 @@ func viewerPermissionsText(permissions ViewerPermissions) string {
|
|||||||
items = append(items, "reply")
|
items = append(items, "reply")
|
||||||
}
|
}
|
||||||
if permissions.CanEnableMerge {
|
if permissions.CanEnableMerge {
|
||||||
items = append(items, "auto-merge")
|
items = append(items, "enable auto-merge")
|
||||||
|
}
|
||||||
|
if permissions.CanDisableMerge {
|
||||||
|
items = append(items, "disable auto-merge")
|
||||||
}
|
}
|
||||||
return strings.Join(items, ", ")
|
return strings.Join(items, ", ")
|
||||||
}
|
}
|
||||||
@@ -3245,6 +3497,9 @@ func renderPane(lines []string, width, height int, active bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reviewAndMergeState(pr PRDetails) string {
|
func reviewAndMergeState(pr PRDetails) string {
|
||||||
|
if pr.Merged {
|
||||||
|
return okStyle.Render("merged")
|
||||||
|
}
|
||||||
switch pr.ReviewDecision {
|
switch pr.ReviewDecision {
|
||||||
case "APPROVED":
|
case "APPROVED":
|
||||||
review := okStyle.Render("review: approved")
|
review := okStyle.Render("review: approved")
|
||||||
@@ -3292,6 +3547,20 @@ func conflictStateText(pr PRDetails) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func autoMergeStateText(pr PRDetails) string {
|
||||||
|
if pr.Merged {
|
||||||
|
return okStyle.Render("merged")
|
||||||
|
}
|
||||||
|
if pr.AutoMerge == nil {
|
||||||
|
return dimStyle.Render("disabled")
|
||||||
|
}
|
||||||
|
text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod)
|
||||||
|
if pr.AutoMerge.EnabledBy != "" {
|
||||||
|
text += " by " + authorStyle(pr.AutoMerge.EnabledBy).Render("@"+pr.AutoMerge.EnabledBy)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
func firstLine(value string) string {
|
func firstLine(value string) string {
|
||||||
if line, _, found := strings.Cut(value, "\n"); found {
|
if line, _, found := strings.Cut(value, "\n"); found {
|
||||||
return line
|
return line
|
||||||
|
|||||||
87
tui_test.go
87
tui_test.go
@@ -33,6 +33,29 @@ type recordingPRService struct {
|
|||||||
branchErr error
|
branchErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type recordingMergeService struct {
|
||||||
|
recordingService
|
||||||
|
id, head, method string
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingMergeService) SetPullRequestAutoMerge(
|
||||||
|
_ context.Context, id, head, method string, enabled bool,
|
||||||
|
) (*AutoMergeRequest, error) {
|
||||||
|
s.id, s.head, s.method, s.enabled = id, head, method, enabled
|
||||||
|
if !enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &AutoMergeRequest{MergeMethod: method, EnabledBy: "me", EnabledAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingMergeService) MergePullRequest(
|
||||||
|
_ context.Context, id, head, method string,
|
||||||
|
) (PullRequestMergeResult, error) {
|
||||||
|
s.id, s.head, s.method = id, head, method
|
||||||
|
return PullRequestMergeResult{Merged: true, MergedAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *recordingPRService) UpdatePullRequest(
|
func (s *recordingPRService) UpdatePullRequest(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
id string,
|
id string,
|
||||||
@@ -552,6 +575,70 @@ func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDashboardCanToggleAutoMergeAndMergeReadyPR(t *testing.T) {
|
||||||
|
service := &recordingMergeService{}
|
||||||
|
m := NewApp(service, "o", "r", false, 50, time.Minute)
|
||||||
|
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{
|
||||||
|
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
|
||||||
|
Number: 1, Title: "Ready",
|
||||||
|
},
|
||||||
|
HeadOID: "head", BaseRef: "main", Mergeable: "MERGEABLE",
|
||||||
|
ReviewDecision: "APPROVED", CheckState: "SUCCESS",
|
||||||
|
AllowedMergeMethods: []string{"SQUASH", "MERGE"},
|
||||||
|
Permissions: ViewerPermissions{CanEnableMerge: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := m.Update(runeKey("a"))
|
||||||
|
m = updated.(App)
|
||||||
|
if m.writeMode != writeAutoMergeConfirm || m.mergeMethod != "SQUASH" {
|
||||||
|
t.Fatalf("auto-merge confirmation = mode %d method %q", m.writeMode, m.mergeMethod)
|
||||||
|
}
|
||||||
|
updated, command := m.Update(runeKey("y"))
|
||||||
|
m = updated.(App)
|
||||||
|
if command == nil || m.writeMode != writeAutoMergeBusy {
|
||||||
|
t.Fatalf("auto-merge submit = mode %d command %v", m.writeMode, command)
|
||||||
|
}
|
||||||
|
updated, _ = m.Update(command())
|
||||||
|
m = updated.(App)
|
||||||
|
if m.details.AutoMerge == nil || !service.enabled ||
|
||||||
|
service.id != "pr" || service.head != "head" || service.method != "SQUASH" {
|
||||||
|
t.Fatalf("auto-merge result = details %#v service %#v", m.details.AutoMerge, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ = m.Update(runeKey("M"))
|
||||||
|
m = updated.(App)
|
||||||
|
if m.writeMode != writeMergeNowConfirm {
|
||||||
|
t.Fatalf("merge confirmation mode = %d", m.writeMode)
|
||||||
|
}
|
||||||
|
updated, command = m.Update(runeKey("y"))
|
||||||
|
m = updated.(App)
|
||||||
|
updated, _ = m.Update(command())
|
||||||
|
m = updated.(App)
|
||||||
|
if !m.details.Merged || m.details.State != "MERGED" {
|
||||||
|
t.Fatalf("merged details = %#v", m.details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeNowIsGatedByCurrentRequirements(t *testing.T) {
|
||||||
|
service := &recordingMergeService{}
|
||||||
|
m := NewApp(service, "o", "r", false, 50, time.Minute)
|
||||||
|
m.screen, m.loading = dashboardScreen, false
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{ID: "pr"},
|
||||||
|
HeadOID: "head", Mergeable: "MERGEABLE", CheckState: "FAILURE",
|
||||||
|
ReviewDecision: "APPROVED", AllowedMergeMethods: []string{"SQUASH"},
|
||||||
|
Requirements: MergeRequirements{RequiresStatusChecks: true},
|
||||||
|
}
|
||||||
|
updated, command := m.Update(runeKey("M"))
|
||||||
|
m = updated.(App)
|
||||||
|
if command != nil || m.writeMode != writeNone || m.err == nil ||
|
||||||
|
!strings.Contains(m.err.Error(), "status checks") {
|
||||||
|
t.Fatalf("failed-check merge gate = mode %d command %v error %v", m.writeMode, command, m.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
|
func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
|
||||||
service := &recordingPRService{}
|
service := &recordingPRService{}
|
||||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||||
|
|||||||
17
types.go
17
types.go
@@ -27,6 +27,11 @@ type PRDetails struct {
|
|||||||
HeadRef string
|
HeadRef string
|
||||||
Mergeable string
|
Mergeable string
|
||||||
MergeState string
|
MergeState string
|
||||||
|
State string
|
||||||
|
Merged bool
|
||||||
|
MergedAt time.Time
|
||||||
|
AutoMerge *AutoMergeRequest
|
||||||
|
AllowedMergeMethods []string
|
||||||
ConflictFiles []string
|
ConflictFiles []string
|
||||||
ConflictFileError string
|
ConflictFileError string
|
||||||
Assignees []string
|
Assignees []string
|
||||||
@@ -82,6 +87,17 @@ type PullRequestMetadata struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutoMergeRequest struct {
|
||||||
|
MergeMethod string
|
||||||
|
EnabledBy string
|
||||||
|
EnabledAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PullRequestMergeResult struct {
|
||||||
|
Merged bool
|
||||||
|
MergedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type RepositoryBranch struct {
|
type RepositoryBranch struct {
|
||||||
Name string
|
Name string
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
@@ -158,6 +174,7 @@ type ViewerPermissions struct {
|
|||||||
CanReact bool
|
CanReact bool
|
||||||
CanSubscribe bool
|
CanSubscribe bool
|
||||||
CanEnableMerge bool
|
CanEnableMerge bool
|
||||||
|
CanDisableMerge bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type MergeRequirements struct {
|
type MergeRequirements struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user