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"]
|
||||
health = ["H"]
|
||||
edit = ["e"]
|
||||
auto_merge = ["a"]
|
||||
merge_now = ["M"]
|
||||
toggle_list = ["tab"]
|
||||
|
||||
[keybindings.threads]
|
||||
@@ -256,6 +258,8 @@ history and metadata.
|
||||
| `H` | Open application health and diagnostics |
|
||||
| `d` | Open the current pull request 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` |
|
||||
| `F` | Clear active thread filters |
|
||||
| `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
|
||||
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
|
||||
updated directly. The active line is highlighted without inserting a
|
||||
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
|
||||
|
||||
The application can reply to review threads, resolve or unresolve them, and
|
||||
update the PR title, target branch, and description. Comment reactions remain
|
||||
read-only. Other write operations remain disabled. The dashboard shows the
|
||||
capability gate, including why each action is unavailable. Read state
|
||||
The application can reply to review threads, resolve or unresolve them, update
|
||||
the PR title, target branch, and description, enable or disable auto-merge, and
|
||||
merge an eligible PR immediately. Comment reactions remain read-only. Other
|
||||
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
|
||||
fallback. Check contexts and annotations are paginated. GitHub features which
|
||||
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
|
||||
application.
|
||||
- Add draft/ready-for-review and close/reopen actions.
|
||||
- Consider merge, auto-merge, and merge-queue actions only after required
|
||||
checks, review decision, permissions, stale-head protection, and destructive
|
||||
confirmations are represented accurately.
|
||||
- Add explicit merge-method selection and merge-queue enqueue/dequeue actions.
|
||||
Auto-merge toggling and guarded immediate merge are implemented with
|
||||
stale-head protection and destructive confirmation.
|
||||
- Define consistent optimistic-update and rollback behavior for every mutation.
|
||||
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)
|
||||
}
|
||||
|
||||
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(
|
||||
ctx context.Context, owner, repo string,
|
||||
) ([]RepositoryBranch, error) {
|
||||
|
||||
163
github.go
163
github.go
@@ -30,6 +30,11 @@ type GitHubPullRequestWriteService interface {
|
||||
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 {
|
||||
ListBranches(context.Context, string, string) ([]RepositoryBranch, error)
|
||||
}
|
||||
@@ -429,7 +434,7 @@ func nullableCursor(cursor string) any {
|
||||
const detailsQuery = `
|
||||
query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
url
|
||||
url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed
|
||||
viewerPermission
|
||||
defaultBranchRef { name }
|
||||
rulesets(first: 100, includeParents: true, targets: [BRANCH]) {
|
||||
@@ -440,11 +445,13 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) {
|
||||
}
|
||||
}
|
||||
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
|
||||
autoMergeRequest { mergeMethod enabledAt enabledBy { login } }
|
||||
mergeQueueEntry { state position enqueuedAt estimatedTimeToMerge }
|
||||
baseRefName headRefName headRefOid
|
||||
viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge
|
||||
viewerCanUpdate viewerCanReact viewerCanSubscribe
|
||||
viewerCanEnableAutoMerge viewerCanDisableAutoMerge
|
||||
baseRef {
|
||||
target { ... on Commit { oid } }
|
||||
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 {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
@@ -759,14 +789,20 @@ type githubRuleset struct {
|
||||
}
|
||||
|
||||
type githubPullRequestDetails struct {
|
||||
ID, Title, URL, Body, Mergeable, MergeStateStatus string
|
||||
ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string
|
||||
Number, Additions, Deletions, ChangedFiles int
|
||||
IsDraft bool
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
Author *githubActor
|
||||
ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe, ViewerCanEnableAutoMerge bool
|
||||
BaseRef *struct {
|
||||
ID, Title, URL, Body, State, Mergeable, MergeStateStatus string
|
||||
ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string
|
||||
Number, Additions, Deletions, ChangedFiles int
|
||||
IsDraft, Merged bool
|
||||
CreatedAt, UpdatedAt, MergedAt time.Time
|
||||
Author *githubActor
|
||||
ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe bool
|
||||
ViewerCanEnableAutoMerge, ViewerCanDisableAutoMerge bool
|
||||
AutoMergeRequest *struct {
|
||||
MergeMethod string
|
||||
EnabledAt time.Time
|
||||
EnabledBy *githubActor
|
||||
}
|
||||
BaseRef *struct {
|
||||
Target *struct{ OID string }
|
||||
BranchProtectionRule *struct {
|
||||
RequiresApprovingReviews, RequiresStatusChecks bool
|
||||
@@ -1057,11 +1093,12 @@ func (c *GitHubClient) allCheckAnnotations(
|
||||
func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) {
|
||||
var data struct {
|
||||
Repository *struct {
|
||||
URL string
|
||||
ViewerPermission string `json:"viewerPermission"`
|
||||
DefaultBranchRef *struct{ Name string }
|
||||
Rulesets struct{ Nodes []githubRuleset }
|
||||
PullRequest *githubPullRequestDetails `json:"pullRequest"`
|
||||
URL string
|
||||
ViewerPermission string `json:"viewerPermission"`
|
||||
MergeCommitAllowed, SquashMergeAllowed, RebaseMergeAllowed bool
|
||||
DefaultBranchRef *struct{ Name string }
|
||||
Rulesets struct{ Nodes []githubRuleset }
|
||||
PullRequest *githubPullRequestDetails `json:"pullRequest"`
|
||||
} `json:"repository"`
|
||||
}
|
||||
if err := c.query(ctx, detailsQuery, map[string]any{"owner": owner, "name": name, "number": number}, &data); err != nil {
|
||||
@@ -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,
|
||||
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
||||
State: node.State, Merged: node.Merged, MergedAt: node.MergedAt,
|
||||
RepositoryURL: data.Repository.URL,
|
||||
Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles,
|
||||
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,
|
||||
CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact,
|
||||
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 {
|
||||
details.BaseOID = node.BaseRef.Target.OID
|
||||
}
|
||||
@@ -1460,6 +1515,82 @@ func (c *GitHubClient) UpdatePullRequest(
|
||||
}, 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 {
|
||||
item := ReviewThread{
|
||||
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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request graphQLRequest
|
||||
@@ -428,14 +489,18 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing
|
||||
|
||||
func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
||||
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,
|
||||
"createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z",
|
||||
"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED",
|
||||
"additions":12,"deletions":4,"changedFiles":3,
|
||||
"baseRefName":"main","headRefName":"fix","headRefOid":"abcdef0123456789",
|
||||
"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,
|
||||
"requiredApprovingReviewCount":2,"requiresStatusChecks":true,
|
||||
"requiresConversationResolution":true,"requiresCodeOwnerReviews":true}},
|
||||
@@ -493,6 +558,11 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) {
|
||||
!got.Requirements.RequiresConversation || !got.Requirements.RequiresCodeOwnerReview {
|
||||
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 ||
|
||||
got.Threads[0].DiffSide != "RIGHT" || got.Threads[0].IsTruncated {
|
||||
t.Fatalf("unexpected thread: %#v", got.Threads)
|
||||
|
||||
@@ -40,6 +40,8 @@ type ViewKeyBindings struct {
|
||||
Dashboard []string `toml:"dashboard"`
|
||||
Health []string `toml:"health"`
|
||||
Edit []string `toml:"edit"`
|
||||
AutoMerge []string `toml:"auto_merge"`
|
||||
MergeNow []string `toml:"merge_now"`
|
||||
ToggleList []string `toml:"toggle_list"`
|
||||
}
|
||||
|
||||
@@ -119,6 +121,7 @@ func defaultKeyBindings() KeyBindings {
|
||||
Views: ViewKeyBindings{
|
||||
Open: []string{"enter", "l"}, Dashboard: []string{"d"},
|
||||
Health: []string{"H"}, Edit: []string{"e"}, ToggleList: []string{"tab"},
|
||||
AutoMerge: []string{"a"}, MergeNow: []string{"M"},
|
||||
},
|
||||
Threads: ThreadKeyBindings{
|
||||
Search: []string{"/"}, ClearFilter: []string{"F"},
|
||||
@@ -294,6 +297,14 @@ func (k KeyBindings) canonicalMainKey(key string, current screen) string {
|
||||
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 {
|
||||
switch {
|
||||
case keyMatches(key, k.Navigation.Left):
|
||||
@@ -387,6 +398,7 @@ func validateKeyBindings(bindings KeyBindings) error {
|
||||
{"keybindings.views", map[string][]string{
|
||||
"open": bindings.Views.Open, "dashboard": bindings.Views.Dashboard,
|
||||
"health": bindings.Views.Health, "edit": bindings.Views.Edit,
|
||||
"auto_merge": bindings.Views.AutoMerge, "merge_now": bindings.Views.MergeNow,
|
||||
"toggle_list": bindings.Views.ToggleList,
|
||||
}},
|
||||
{"keybindings.threads", map[string][]string{
|
||||
@@ -478,6 +490,8 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
|
||||
if err := validateKeyContext("dashboard", append(screenCommon,
|
||||
contextBinding{"open", views.Open},
|
||||
contextBinding{"edit", views.Edit},
|
||||
contextBinding{"auto_merge", views.AutoMerge},
|
||||
contextBinding{"merge_now", views.MergeNow},
|
||||
)...); err != nil {
|
||||
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.
|
||||
var _ GitHubService = (*GitHubClient)(nil)
|
||||
var _ GitHubMergeService = (*GitHubClient)(nil)
|
||||
var _ GitHubMergeService = (*CachedGitHubService)(nil)
|
||||
var _ GitHubWriteService = (*GitHubClient)(nil)
|
||||
var _ GitHubWriteService = (*CachedGitHubService)(nil)
|
||||
var _ GitHubPullRequestWriteService = (*GitHubClient)(nil)
|
||||
|
||||
275
tui.go
275
tui.go
@@ -46,6 +46,10 @@ const (
|
||||
writePREdit
|
||||
writePREditConfirm
|
||||
writePREditBusy
|
||||
writeAutoMergeConfirm
|
||||
writeAutoMergeBusy
|
||||
writeMergeNowConfirm
|
||||
writeMergeNowBusy
|
||||
)
|
||||
|
||||
type threadResolvedMsg struct {
|
||||
@@ -65,6 +69,17 @@ type pullRequestUpdatedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type autoMergeUpdatedMsg struct {
|
||||
request *AutoMergeRequest
|
||||
enabled bool
|
||||
err error
|
||||
}
|
||||
|
||||
type pullRequestMergedMsg struct {
|
||||
result PullRequestMergeResult
|
||||
err error
|
||||
}
|
||||
|
||||
type prsLoadedMsg struct {
|
||||
prs []PullRequest
|
||||
err error
|
||||
@@ -128,6 +143,8 @@ type App struct {
|
||||
writeThreadID string
|
||||
replyDraft string
|
||||
resolveTarget bool
|
||||
autoMergeTarget bool
|
||||
mergeMethod string
|
||||
prEditField int
|
||||
prEditEditors [3]textEditor
|
||||
prEditOriginal PullRequestMetadata
|
||||
@@ -361,6 +378,110 @@ func (m *App) startResolveToggle() {
|
||||
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 {
|
||||
if m.loading {
|
||||
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":
|
||||
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:
|
||||
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) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
@@ -713,6 +872,37 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.replyDraft, m.writeThreadID = "", ""
|
||||
m.err = nil
|
||||
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:
|
||||
if msg.err != nil {
|
||||
m.writeMode = writePREdit
|
||||
@@ -863,6 +1053,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.screen == dashboardScreen {
|
||||
return m, m.startPREdit()
|
||||
}
|
||||
case "a":
|
||||
if m.screen == dashboardScreen {
|
||||
m.startAutoMergeToggle()
|
||||
}
|
||||
case "M":
|
||||
if m.screen == dashboardScreen {
|
||||
m.startMergeNow()
|
||||
}
|
||||
case "F":
|
||||
if m.screen == threadScreen {
|
||||
m.searchQuery = ""
|
||||
@@ -1574,6 +1772,46 @@ func (m App) viewWritePopup() string {
|
||||
action = "Unresolving"
|
||||
}
|
||||
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:
|
||||
lines = m.prEditConfirmationLines(width - 2)
|
||||
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.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"},
|
||||
{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.General.Back), backAction},
|
||||
{keyLabel(m.keybindings.General.Refresh), "Refresh now"},
|
||||
@@ -2244,6 +2484,7 @@ func (m App) dashboardLines() []string {
|
||||
dashboardMetadata("review", reviewAndMergeState(pr)),
|
||||
dashboardMetadata("checks", coloredState(pr.CheckState)),
|
||||
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,
|
||||
@@ -2540,7 +2781,7 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
||||
return []writeCapability{
|
||||
{name: "reply", reason: reason}, {name: "resolve", 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"
|
||||
@@ -2556,12 +2797,20 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
||||
}
|
||||
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{
|
||||
capability("reply", canReply, threadReason, true),
|
||||
capability(resolveName, canResolve, threadReason, true),
|
||||
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("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")
|
||||
}
|
||||
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, ", ")
|
||||
}
|
||||
@@ -3245,6 +3497,9 @@ func renderPane(lines []string, width, height int, active bool) string {
|
||||
}
|
||||
|
||||
func reviewAndMergeState(pr PRDetails) string {
|
||||
if pr.Merged {
|
||||
return okStyle.Render("merged")
|
||||
}
|
||||
switch pr.ReviewDecision {
|
||||
case "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 {
|
||||
if line, _, found := strings.Cut(value, "\n"); found {
|
||||
return line
|
||||
|
||||
87
tui_test.go
87
tui_test.go
@@ -33,6 +33,29 @@ type recordingPRService struct {
|
||||
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(
|
||||
_ context.Context,
|
||||
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) {
|
||||
service := &recordingPRService{}
|
||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||
|
||||
87
types.go
87
types.go
@@ -21,41 +21,46 @@ type PullRequest struct {
|
||||
|
||||
type PRDetails struct {
|
||||
PullRequest
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
BaseRef string
|
||||
HeadRef string
|
||||
Mergeable string
|
||||
MergeState string
|
||||
ConflictFiles []string
|
||||
ConflictFileError string
|
||||
Assignees []string
|
||||
Reviewers []Reviewer
|
||||
Labels []string
|
||||
Milestone string
|
||||
Additions int
|
||||
Deletions int
|
||||
ChangedFiles int
|
||||
CommitCount int
|
||||
CommentCount int
|
||||
HeadOID string
|
||||
BaseOID string
|
||||
RepositoryURL string
|
||||
CheckState string
|
||||
Checks []Check
|
||||
ReviewDecision string
|
||||
Conversation []PRComment
|
||||
Reviews []ReviewSummary
|
||||
Permissions ViewerPermissions
|
||||
Requirements MergeRequirements
|
||||
Threads []ReviewThread
|
||||
ThreadsTruncated bool
|
||||
Timeline []TimelineEvent
|
||||
DataIssues []DataIssue
|
||||
Rulesets []Ruleset
|
||||
MergeQueue *MergeQueue
|
||||
FromCache bool
|
||||
CachedAt time.Time
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
BaseRef string
|
||||
HeadRef string
|
||||
Mergeable string
|
||||
MergeState string
|
||||
State string
|
||||
Merged bool
|
||||
MergedAt time.Time
|
||||
AutoMerge *AutoMergeRequest
|
||||
AllowedMergeMethods []string
|
||||
ConflictFiles []string
|
||||
ConflictFileError string
|
||||
Assignees []string
|
||||
Reviewers []Reviewer
|
||||
Labels []string
|
||||
Milestone string
|
||||
Additions int
|
||||
Deletions int
|
||||
ChangedFiles int
|
||||
CommitCount int
|
||||
CommentCount int
|
||||
HeadOID string
|
||||
BaseOID string
|
||||
RepositoryURL string
|
||||
CheckState string
|
||||
Checks []Check
|
||||
ReviewDecision string
|
||||
Conversation []PRComment
|
||||
Reviews []ReviewSummary
|
||||
Permissions ViewerPermissions
|
||||
Requirements MergeRequirements
|
||||
Threads []ReviewThread
|
||||
ThreadsTruncated bool
|
||||
Timeline []TimelineEvent
|
||||
DataIssues []DataIssue
|
||||
Rulesets []Ruleset
|
||||
MergeQueue *MergeQueue
|
||||
FromCache bool
|
||||
CachedAt time.Time
|
||||
}
|
||||
|
||||
type DataIssue struct {
|
||||
@@ -82,6 +87,17 @@ type PullRequestMetadata struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AutoMergeRequest struct {
|
||||
MergeMethod string
|
||||
EnabledBy string
|
||||
EnabledAt time.Time
|
||||
}
|
||||
|
||||
type PullRequestMergeResult struct {
|
||||
Merged bool
|
||||
MergedAt time.Time
|
||||
}
|
||||
|
||||
type RepositoryBranch struct {
|
||||
Name string
|
||||
UpdatedAt time.Time
|
||||
@@ -158,6 +174,7 @@ type ViewerPermissions struct {
|
||||
CanReact bool
|
||||
CanSubscribe bool
|
||||
CanEnableMerge bool
|
||||
CanDisableMerge bool
|
||||
}
|
||||
|
||||
type MergeRequirements struct {
|
||||
|
||||
Reference in New Issue
Block a user