Add auto-merge setting and merge now functionality

This commit is contained in:
2026-07-28 17:42:49 +02:00
parent 7511311297
commit 42dcceaf4b
10 changed files with 689 additions and 63 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View File

@@ -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,

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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
View File

@@ -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

View File

@@ -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)

View File

@@ -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 {