From 3f0df3d37e8a1da1c4eb959034dc1681dec2ba75 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Mon, 7 Apr 2025 14:14:43 -0700 Subject: [PATCH 1/7] implement logic for checking CI status for a given commit and PR approvals --- internal/cmd/match_criteria.go | 208 +++++++++++++++++++++++++++++++++ internal/cmd/root.go | 17 ++- 2 files changed, 222 insertions(+), 3 deletions(-) diff --git a/internal/cmd/match_criteria.go b/internal/cmd/match_criteria.go index 5afe476..40eba32 100644 --- a/internal/cmd/match_criteria.go +++ b/internal/cmd/match_criteria.go @@ -1,8 +1,13 @@ package cmd import ( + "context" + "fmt" "regexp" "strings" + + "github.com/cli/go-gh/v2/pkg/api" + graphql "github.com/cli/shurcooL-graphql" ) // checks if a PR matches all filtering criteria @@ -113,3 +118,206 @@ func labelsMatchCriteria(prLabels []struct{ Name string }) bool { return true } + +// GraphQL response structure for PR status info +type prStatusResponse struct { + Data struct { + Repository struct { + PullRequest struct { + ReviewDecision string `json:"reviewDecision"` + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup *struct { + State string `json:"state"` + } `json:"statusCheckRollup"` + } `json:"commit"` + } `json:"nodes"` + } `json:"commits"` + } `json:"pullRequest"` + } `json:"repository"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors,omitempty"` +} + +// GetPRStatusInfo fetches both CI status and approval status using GitHub's GraphQL API +func GetPRStatusInfo(ctx context.Context, graphQlClient *api.GraphQLClient, owner, repo string, prNumber int) (*prStatusResponse, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + // Continue processing + } + + // Define a struct with embedded graphql query + var query struct { + Repository struct { + PullRequest struct { + ReviewDecision string + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup *struct { + State string + } + } + } + } `graphql:"commits(last: 1)"` + } `graphql:"pullRequest(number: $prNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + // Prepare GraphQL query variables + variables := map[string]interface{}{ + "owner": graphql.String(owner), + "repo": graphql.String(repo), + "prNumber": graphql.Int(prNumber), + } + + // Execute GraphQL query + err := graphQlClient.Query("PullRequestStatus", &query, variables) + if err != nil { + return nil, fmt.Errorf("GraphQL query failed: %w", err) + } + + // Convert to our response format + response := &prStatusResponse{} + response.Data.Repository.PullRequest.ReviewDecision = query.Repository.PullRequest.ReviewDecision + + if len(query.Repository.PullRequest.Commits.Nodes) > 0 { + response.Data.Repository.PullRequest.Commits.Nodes = make([]struct { + Commit struct { + StatusCheckRollup *struct { + State string `json:"state"` + } `json:"statusCheckRollup"` + } `json:"commit"` + }, len(query.Repository.PullRequest.Commits.Nodes)) + + for i, node := range query.Repository.PullRequest.Commits.Nodes { + if node.Commit.StatusCheckRollup != nil { + response.Data.Repository.PullRequest.Commits.Nodes[i].Commit.StatusCheckRollup = &struct { + State string `json:"state"` + }{ + State: node.Commit.StatusCheckRollup.State, + } + } + } + } + + return response, nil +} + +// HasPassingCI checks if a pull request has passing CI +func HasPassingCI(ctx context.Context, graphQlClient *api.GraphQLClient, owner, repo string, prNumber int) (bool, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + // Continue processing + } + + // Get PR status info using GraphQL + response, err := GetPRStatusInfo(ctx, graphQlClient, owner, repo, prNumber) + if err != nil { + return false, err + } + + // Get the commit status check info + commits := response.Data.Repository.PullRequest.Commits.Nodes + if len(commits) == 0 { + Logger.Debug("No commits found for PR", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber) + return false, nil + } + + // Get status check info + statusCheckRollup := commits[0].Commit.StatusCheckRollup + if statusCheckRollup == nil { + Logger.Debug("No status checks found for PR", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber) + return true, nil // If no checks defined, consider it passing + } + + // Check if status is SUCCESS + if statusCheckRollup.State != "SUCCESS" { + Logger.Debug("PR failed CI check", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber, "status", statusCheckRollup.State) + return false, nil + } + + return true, nil +} + +// HasApproval checks if a pull request has been approved +func HasApproval(ctx context.Context, graphQlClient *api.GraphQLClient, owner, repo string, prNumber int) (bool, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + // Continue processing + } + + // Get PR status info using GraphQL + response, err := GetPRStatusInfo(ctx, graphQlClient, owner, repo, prNumber) + if err != nil { + return false, err + } + + reviewDecision := response.Data.Repository.PullRequest.ReviewDecision + Logger.Debug("PR review decision", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber, "decision", reviewDecision) + + // Check the review decision + switch reviewDecision { + case "APPROVED": + return true, nil + case "": // When no reviews are required + Logger.Debug("PR has no required reviewers", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber) + return true, nil // If no reviews required, consider it approved + default: + // Any other decision (REVIEW_REQUIRED, CHANGES_REQUESTED, etc.) + Logger.Debug("PR not approved", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber, "decision", reviewDecision) + return false, nil + } +} + +// PrMeetsRequirements checks if a PR meets additional requirements beyond basic criteria +func PrMeetsRequirements(ctx context.Context, graphQlClient *api.GraphQLClient, owner, repo string, prNumber int) (bool, error) { + // If no additional requirements are specified, the PR meets requirements + if !requireCI && !mustBeApproved { + return true, nil + } + + // Check for context cancellation + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + // Continue processing + } + + // Check CI status if required + if requireCI { + passing, err := HasPassingCI(ctx, graphQlClient, owner, repo, prNumber) + if err != nil { + return false, err + } + if !passing { + return false, nil + } + } + + // Check approval status if required + if mustBeApproved { + approved, err := HasApproval(ctx, graphQlClient, owner, repo, prNumber) + if err != nil { + return false, err + } + if !approved { + return false, nil + } + } + + return true, nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 4c1e638..dd7419a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -163,6 +163,7 @@ func runCombine(cmd *cobra.Command, args []string) error { func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string) error { // Create GitHub API client restClient, err := api.DefaultRESTClient() + graphQlClient, err := api.DefaultGraphQLClient() if err != nil { return fmt.Errorf("failed to create REST client: %w", err) } @@ -180,7 +181,7 @@ func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string Logger.Debug("Processing repository", "repo", repo) // Process the repository - if err := processRepository(ctx, restClient, spinner, repo); err != nil { + if err := processRepository(ctx, restClient, graphQlClient, spinner, repo); err != nil { if ctx.Err() != nil { // If the context was cancelled, stop processing return ctx.Err() @@ -195,7 +196,7 @@ func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string } // processRepository handles a single repository's PRs -func processRepository(ctx context.Context, client *api.RESTClient, spinner *Spinner, repo string) error { +func processRepository(ctx context.Context, client *api.RESTClient, graphQlClient *api.GraphQLClient, spinner *Spinner, repo string) error { // Parse owner and repo name parts := strings.Split(repo, "/") if len(parts) != 2 { @@ -259,7 +260,17 @@ func processRepository(ctx context.Context, client *api.RESTClient, spinner *Spi continue } - // TODO: Implement CI/approval status checking + // Check if PR meets additional requirements (CI, approval) + meetsRequirements, err := PrMeetsRequirements(ctx, graphQlClient, owner, repoName, pull.Number) + if err != nil { + Logger.Warn("Failed to check PR requirements", "repo", repo, "pr", pull.Number, "error", err) + continue + } + + if !meetsRequirements { + // Skip this PR as it doesn't meet CI/approval requirements + continue + } matchedPRs = append(matchedPRs, struct { Number int From 1bea40f616a8f9f9cdf8bd780a77133e51e98352 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Mon, 7 Apr 2025 14:18:28 -0700 Subject: [PATCH 2/7] only make the graphql call once --- internal/cmd/match_criteria.go | 125 +++++++++++---------------------- 1 file changed, 42 insertions(+), 83 deletions(-) diff --git a/internal/cmd/match_criteria.go b/internal/cmd/match_criteria.go index 40eba32..4f67b1f 100644 --- a/internal/cmd/match_criteria.go +++ b/internal/cmd/match_criteria.go @@ -210,114 +210,73 @@ func GetPRStatusInfo(ctx context.Context, graphQlClient *api.GraphQLClient, owne return response, nil } -// HasPassingCI checks if a pull request has passing CI -func HasPassingCI(ctx context.Context, graphQlClient *api.GraphQLClient, owner, repo string, prNumber int) (bool, error) { - // Check for context cancellation - select { - case <-ctx.Done(): - return false, ctx.Err() - default: - // Continue processing +// PrMeetsRequirements checks if a PR meets additional requirements beyond basic criteria +func PrMeetsRequirements(ctx context.Context, graphQlClient *api.GraphQLClient, owner, repo string, prNumber int) (bool, error) { + // If no additional requirements are specified, the PR meets requirements + if !requireCI && !mustBeApproved { + return true, nil } - // Get PR status info using GraphQL + // Fetch PR status info once response, err := GetPRStatusInfo(ctx, graphQlClient, owner, repo, prNumber) if err != nil { return false, err } - // Get the commit status check info + // Check CI status if required + if requireCI { + passing := isCIPassing(response) + if !passing { + return false, nil + } + } + + // Check approval status if required + if mustBeApproved { + approved := isPRApproved(response) + if !approved { + return false, nil + } + } + + return true, nil +} + +// isCIPassing checks if the CI status is passing based on the response +func isCIPassing(response *prStatusResponse) bool { commits := response.Data.Repository.PullRequest.Commits.Nodes if len(commits) == 0 { - Logger.Debug("No commits found for PR", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber) - return false, nil + Logger.Debug("No commits found for PR") + return false } - // Get status check info statusCheckRollup := commits[0].Commit.StatusCheckRollup if statusCheckRollup == nil { - Logger.Debug("No status checks found for PR", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber) - return true, nil // If no checks defined, consider it passing + Logger.Debug("No status checks found for PR") + return true // If no checks defined, consider it passing } - // Check if status is SUCCESS if statusCheckRollup.State != "SUCCESS" { - Logger.Debug("PR failed CI check", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber, "status", statusCheckRollup.State) - return false, nil + Logger.Debug("PR failed CI check", "status", statusCheckRollup.State) + return false } - return true, nil + return true } -// HasApproval checks if a pull request has been approved -func HasApproval(ctx context.Context, graphQlClient *api.GraphQLClient, owner, repo string, prNumber int) (bool, error) { - // Check for context cancellation - select { - case <-ctx.Done(): - return false, ctx.Err() - default: - // Continue processing - } - - // Get PR status info using GraphQL - response, err := GetPRStatusInfo(ctx, graphQlClient, owner, repo, prNumber) - if err != nil { - return false, err - } - +// isPRApproved checks if the PR is approved based on the response +func isPRApproved(response *prStatusResponse) bool { reviewDecision := response.Data.Repository.PullRequest.ReviewDecision - Logger.Debug("PR review decision", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber, "decision", reviewDecision) + Logger.Debug("PR review decision", "decision", reviewDecision) - // Check the review decision switch reviewDecision { case "APPROVED": - return true, nil + return true case "": // When no reviews are required - Logger.Debug("PR has no required reviewers", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber) - return true, nil // If no reviews required, consider it approved - default: - // Any other decision (REVIEW_REQUIRED, CHANGES_REQUESTED, etc.) - Logger.Debug("PR not approved", "repo", fmt.Sprintf("%s/%s", owner, repo), "pr", prNumber, "decision", reviewDecision) - return false, nil - } -} - -// PrMeetsRequirements checks if a PR meets additional requirements beyond basic criteria -func PrMeetsRequirements(ctx context.Context, graphQlClient *api.GraphQLClient, owner, repo string, prNumber int) (bool, error) { - // If no additional requirements are specified, the PR meets requirements - if !requireCI && !mustBeApproved { - return true, nil - } - - // Check for context cancellation - select { - case <-ctx.Done(): - return false, ctx.Err() + Logger.Debug("PR has no required reviewers") + return true // If no reviews required, consider it approved default: - // Continue processing - } - - // Check CI status if required - if requireCI { - passing, err := HasPassingCI(ctx, graphQlClient, owner, repo, prNumber) - if err != nil { - return false, err - } - if !passing { - return false, nil - } - } - - // Check approval status if required - if mustBeApproved { - approved, err := HasApproval(ctx, graphQlClient, owner, repo, prNumber) - if err != nil { - return false, err - } - if !approved { - return false, nil - } + Logger.Debug("PR not approved", "decision", reviewDecision) + return false } - - return true, nil } From b7da51736155b6e47855179b5cabc68cd7c622fe Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Mon, 7 Apr 2025 14:52:42 -0700 Subject: [PATCH 3/7] add combine PRs logic --- internal/cmd/combine_prs.go | 205 ++++++++++++++++++++++++++++++++++++ internal/cmd/root.go | 51 +++++---- 2 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 internal/cmd/combine_prs.go diff --git a/internal/cmd/combine_prs.go b/internal/cmd/combine_prs.go new file mode 100644 index 0000000..e866a2a --- /dev/null +++ b/internal/cmd/combine_prs.go @@ -0,0 +1,205 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + + "github.com/cli/go-gh/v2/pkg/api" +) + +func CombinePRs(ctx context.Context, graphQlClient *api.GraphQLClient, restClient *api.RESTClient, owner, repo string, matchedPRs []struct { + Number int + Title string + Branch string + Base string + BaseSHA string +}) error { + // Define the combined branch name + combineBranchName := "combined-prs" + workingBranchName := combineBranchName + "-working" + + baseBranchSHA, err := getBranchSHA(ctx, restClient, owner, repo, baseBranch) + if err != nil { + return fmt.Errorf("failed to get SHA of main branch: %w", err) + } + + // Delete any pre-existing working branch + err = deleteBranch(ctx, restClient, owner, repo, workingBranchName) + if err != nil { + Logger.Debug("Working branch not found, continuing", "branch", workingBranchName) + } + + // Delete any pre-existing combined branch + err = deleteBranch(ctx, restClient, owner, repo, combineBranchName) + if err != nil { + Logger.Debug("Combined branch not found, continuing", "branch", combineBranchName) + } + + // Create the combined branch + err = createBranch(ctx, restClient, owner, repo, combineBranchName, baseBranchSHA) + if err != nil { + return fmt.Errorf("failed to create combined branch: %w", err) + } + + // Create the working branch + err = createBranch(ctx, restClient, owner, repo, workingBranchName, baseBranchSHA) + if err != nil { + return fmt.Errorf("failed to create working branch: %w", err) + } + + // Merge all PR branches into the working branch + var combinedPRs []string + var mergeFailedPRs []string + for _, pr := range matchedPRs { + err := mergeBranch(ctx, restClient, owner, repo, workingBranchName, pr.Branch) + if err != nil { + Logger.Warn("Failed to merge branch", "branch", pr.Branch, "error", err) + mergeFailedPRs = append(mergeFailedPRs, fmt.Sprintf("#%d", pr.Number)) + } else { + Logger.Info("Merged branch", "branch", pr.Branch) + combinedPRs = append(combinedPRs, fmt.Sprintf("#%d - %s", pr.Number, pr.Title)) + } + } + + // Update the combined branch to the latest commit of the working branch + err = updateRef(ctx, restClient, owner, repo, combineBranchName, workingBranchName) + if err != nil { + return fmt.Errorf("failed to update combined branch: %w", err) + } + + // Delete the temporary working branch + err = deleteBranch(ctx, restClient, owner, repo, workingBranchName) + if err != nil { + Logger.Warn("Failed to delete working branch", "branch", workingBranchName, "error", err) + } + + // Create the combined PR + prBody := generatePRBody(combinedPRs, mergeFailedPRs) + prTitle := "Combined PRs" + baseBranch := matchedPRs[0].Base // Use the base branch of the first PR + err = createPullRequest(ctx, restClient, owner, repo, prTitle, combineBranchName, baseBranch, prBody) + if err != nil { + return fmt.Errorf("failed to create combined PR: %w", err) + } + + return nil +} + +// Get the SHA of a given branch +func getBranchSHA(ctx context.Context, client *api.RESTClient, owner, repo, branch string) (string, error) { + var ref struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + endpoint := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, branch) + err := client.Get(endpoint, &ref) + if err != nil { + return "", fmt.Errorf("failed to get SHA of branch %s: %w", branch, err) + } + return ref.Object.SHA, nil +} + +// generatePRBody generates the body for the combined PR +func generatePRBody(combinedPRs, mergeFailedPRs []string) string { + body := "✅ The following pull requests have been successfully combined:\n" + for _, pr := range combinedPRs { + body += "- " + pr + "\n" + } + if len(mergeFailedPRs) > 0 { + body += "\n⚠️ The following pull requests could not be merged due to conflicts:\n" + for _, pr := range mergeFailedPRs { + body += "- " + pr + "\n" + } + } + return body +} + +// deleteBranch deletes a branch in the repository +func deleteBranch(ctx context.Context, client *api.RESTClient, owner, repo, branch string) error { + endpoint := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", owner, repo, branch) + return client.Delete(endpoint, nil) +} + +// createBranch creates a new branch in the repository +func createBranch(ctx context.Context, client *api.RESTClient, owner, repo, branch, sha string) error { + endpoint := fmt.Sprintf("repos/%s/%s/git/refs", owner, repo) + payload := map[string]string{ + "ref": "refs/heads/" + branch, + "sha": sha, + } + body, err := encodePayload(payload) + if err != nil { + return fmt.Errorf("failed to encode payload: %w", err) + } + return client.Post(endpoint, body, nil) +} + +// mergeBranch merges a branch into the base branch +func mergeBranch(ctx context.Context, client *api.RESTClient, owner, repo, base, head string) error { + endpoint := fmt.Sprintf("repos/%s/%s/merges", owner, repo) + payload := map[string]string{ + "base": base, + "head": head, + } + body, err := encodePayload(payload) + if err != nil { + return fmt.Errorf("failed to encode payload: %w", err) + } + return client.Post(endpoint, body, nil) +} + +// updateRef updates a branch to point to the latest commit of another branch +func updateRef(ctx context.Context, client *api.RESTClient, owner, repo, branch, sourceBranch string) error { + // Get the SHA of the source branch + var ref struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + endpoint := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, sourceBranch) + err := client.Get(endpoint, &ref) + if err != nil { + return fmt.Errorf("failed to get SHA of source branch: %w", err) + } + + // Update the branch to point to the new SHA + endpoint = fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", owner, repo, branch) + payload := map[string]interface{}{ + "sha": ref.Object.SHA, + "force": true, + } + body, err := encodePayload(payload) + if err != nil { + return fmt.Errorf("failed to encode payload: %w", err) + } + return client.Patch(endpoint, body, nil) +} + +// createPullRequest creates a new pull request +func createPullRequest(ctx context.Context, client *api.RESTClient, owner, repo, title, head, base, body string) error { + endpoint := fmt.Sprintf("repos/%s/%s/pulls", owner, repo) + payload := map[string]string{ + "title": title, + "head": head, + "base": base, + "body": body, + } + requestBody, err := encodePayload(payload) + if err != nil { + return fmt.Errorf("failed to encode payload: %w", err) + } + return client.Post(endpoint, requestBody, nil) +} + +// encodePayload encodes a payload as JSON and returns an io.Reader +func encodePayload(payload interface{}) (io.Reader, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index dd7419a..0878307 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -14,22 +14,24 @@ import ( ) var ( - branchPrefix string - branchSuffix string - branchRegex string - selectLabel string - selectLabels []string - addLabels []string - addAssignees []string - requireCI bool - mustBeApproved bool - autoclose bool - updateBranch bool - ignoreLabel string - ignoreLabels []string - reposFile string - minimum int - defaultOwner string + branchPrefix string + branchSuffix string + branchRegex string + selectLabel string + selectLabels []string + addLabels []string + addAssignees []string + requireCI bool + mustBeApproved bool + autoclose bool + updateBranch bool + ignoreLabel string + ignoreLabels []string + reposFile string + minimum int + defaultOwner string + doNotCombineFromScratch bool + baseBranch string ) // NewRootCmd creates the root command for the gh-combine CLI @@ -80,8 +82,10 @@ func NewRootCmd() *cobra.Command { gh combine octocat/hello-world --add-assignees octocat,hubot # Assign users to the new PR # Additional options - gh combine octocat/hello-world --autoclose # Close source PRs when combined PR is merged - gh combine octocat/hello-world --update-branch # Update the branch of the combined PR`, + gh combine octocat/hello-world --autoclose # Close source PRs when combined PR is merged + gh combine octocat/hello-world --base-branch main # Use a different base branch for the combined PR + gh combine octocat/hello-world --do-not-combine-from-scratch # Do not combine the PRs from scratch + gh combine octocat/hello-world --update-branch # Update the branch of the combined PR`, RunE: runCombine, } @@ -107,6 +111,8 @@ func NewRootCmd() *cobra.Command { rootCmd.Flags().BoolVar(&mustBeApproved, "require-approved", false, "Only include PRs that have been approved") rootCmd.Flags().BoolVar(&autoclose, "autoclose", false, "Close source PRs when combined PR is merged") rootCmd.Flags().BoolVar(&updateBranch, "update-branch", false, "Update the branch of the combined PR if possible") + rootCmd.Flags().BoolVar(&doNotCombineFromScratch, "do-not-combine-from-scratch", false, "Do not combine the PRs from scratch (clean)") + rootCmd.Flags().StringVar(&baseBranch, "base-branch", "main", "Base branch for the combined PR (default: main)") rootCmd.Flags().StringVar(&reposFile, "file", "", "File containing repository names, one per line") rootCmd.Flags().IntVar(&minimum, "minimum", 2, "Minimum number of PRs to combine") rootCmd.Flags().StringVar(&defaultOwner, "owner", "", "Default owner for repositories (if not specified in repo name or missing from file inputs)") @@ -294,5 +300,14 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien } Logger.Debug("Matched PRs", "repo", repo, "count", len(matchedPRs)) + + // If we get here, we have enough PRs to combine + + // Combine the PRs + err := CombinePRs(ctx, graphQlClient, client, owner, repoName, matchedPRs) + if err != nil { + return fmt.Errorf("failed to combine PRs: %w", err) + } + return nil } From 94fd274b77d39e45027db9b03752ac9e394a30f7 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Mon, 7 Apr 2025 14:53:54 -0700 Subject: [PATCH 4/7] vendor --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2ba1cab..92ab157 100644 --- a/go.mod +++ b/go.mod @@ -8,13 +8,13 @@ require github.com/briandowns/spinner v1.23.2 require ( github.com/cli/go-gh/v2 v2.12.0 + github.com/cli/shurcooL-graphql v0.0.4 github.com/spf13/cobra v1.9.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cli/safeexec v1.0.0 // indirect - github.com/cli/shurcooL-graphql v0.0.4 // indirect github.com/fatih/color v1.7.0 // indirect github.com/henvic/httpretty v0.0.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect From d45807bdd9bccfd00bda7abc0603ae0a157c2b72 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Mon, 7 Apr 2025 14:58:36 -0700 Subject: [PATCH 5/7] use baseBranch defined by cli args --- internal/cmd/combine_prs.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cmd/combine_prs.go b/internal/cmd/combine_prs.go index e866a2a..7295abc 100644 --- a/internal/cmd/combine_prs.go +++ b/internal/cmd/combine_prs.go @@ -79,7 +79,6 @@ func CombinePRs(ctx context.Context, graphQlClient *api.GraphQLClient, restClien // Create the combined PR prBody := generatePRBody(combinedPRs, mergeFailedPRs) prTitle := "Combined PRs" - baseBranch := matchedPRs[0].Base // Use the base branch of the first PR err = createPullRequest(ctx, restClient, owner, repo, prTitle, combineBranchName, baseBranch, prBody) if err != nil { return fmt.Errorf("failed to create combined PR: %w", err) From b6bba2e73bb629ca5f819b66061a6a1a30e5a14b Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Mon, 7 Apr 2025 15:03:23 -0700 Subject: [PATCH 6/7] remove `doNotCombineFromScratch` for now as it is confusing --- internal/cmd/root.go | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 0878307..b5b856f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -14,24 +14,23 @@ import ( ) var ( - branchPrefix string - branchSuffix string - branchRegex string - selectLabel string - selectLabels []string - addLabels []string - addAssignees []string - requireCI bool - mustBeApproved bool - autoclose bool - updateBranch bool - ignoreLabel string - ignoreLabels []string - reposFile string - minimum int - defaultOwner string - doNotCombineFromScratch bool - baseBranch string + branchPrefix string + branchSuffix string + branchRegex string + selectLabel string + selectLabels []string + addLabels []string + addAssignees []string + requireCI bool + mustBeApproved bool + autoclose bool + updateBranch bool + ignoreLabel string + ignoreLabels []string + reposFile string + minimum int + defaultOwner string + baseBranch string ) // NewRootCmd creates the root command for the gh-combine CLI @@ -84,7 +83,6 @@ func NewRootCmd() *cobra.Command { # Additional options gh combine octocat/hello-world --autoclose # Close source PRs when combined PR is merged gh combine octocat/hello-world --base-branch main # Use a different base branch for the combined PR - gh combine octocat/hello-world --do-not-combine-from-scratch # Do not combine the PRs from scratch gh combine octocat/hello-world --update-branch # Update the branch of the combined PR`, RunE: runCombine, } @@ -111,7 +109,6 @@ func NewRootCmd() *cobra.Command { rootCmd.Flags().BoolVar(&mustBeApproved, "require-approved", false, "Only include PRs that have been approved") rootCmd.Flags().BoolVar(&autoclose, "autoclose", false, "Close source PRs when combined PR is merged") rootCmd.Flags().BoolVar(&updateBranch, "update-branch", false, "Update the branch of the combined PR if possible") - rootCmd.Flags().BoolVar(&doNotCombineFromScratch, "do-not-combine-from-scratch", false, "Do not combine the PRs from scratch (clean)") rootCmd.Flags().StringVar(&baseBranch, "base-branch", "main", "Base branch for the combined PR (default: main)") rootCmd.Flags().StringVar(&reposFile, "file", "", "File containing repository names, one per line") rootCmd.Flags().IntVar(&minimum, "minimum", 2, "Minimum number of PRs to combine") From 094ca7a6013451296ac41b81c4fdbf24bd8700e9 Mon Sep 17 00:00:00 2001 From: GrantBirki Date: Mon, 7 Apr 2025 15:06:20 -0700 Subject: [PATCH 7/7] make `combineBranchName` and `workingBranchSuffix` cli args with defaults --- internal/cmd/combine_prs.go | 3 +-- internal/cmd/root.go | 46 +++++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/internal/cmd/combine_prs.go b/internal/cmd/combine_prs.go index 7295abc..c7c1619 100644 --- a/internal/cmd/combine_prs.go +++ b/internal/cmd/combine_prs.go @@ -18,8 +18,7 @@ func CombinePRs(ctx context.Context, graphQlClient *api.GraphQLClient, restClien BaseSHA string }) error { // Define the combined branch name - combineBranchName := "combined-prs" - workingBranchName := combineBranchName + "-working" + workingBranchName := combineBranchName + workingBranchSuffix baseBranchSHA, err := getBranchSHA(ctx, restClient, owner, repo, baseBranch) if err != nil { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b5b856f..1fbe244 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -14,23 +14,25 @@ import ( ) var ( - branchPrefix string - branchSuffix string - branchRegex string - selectLabel string - selectLabels []string - addLabels []string - addAssignees []string - requireCI bool - mustBeApproved bool - autoclose bool - updateBranch bool - ignoreLabel string - ignoreLabels []string - reposFile string - minimum int - defaultOwner string - baseBranch string + branchPrefix string + branchSuffix string + branchRegex string + selectLabel string + selectLabels []string + addLabels []string + addAssignees []string + requireCI bool + mustBeApproved bool + autoclose bool + updateBranch bool + ignoreLabel string + ignoreLabels []string + reposFile string + minimum int + defaultOwner string + baseBranch string + combineBranchName string + workingBranchSuffix string ) // NewRootCmd creates the root command for the gh-combine CLI @@ -81,9 +83,11 @@ func NewRootCmd() *cobra.Command { gh combine octocat/hello-world --add-assignees octocat,hubot # Assign users to the new PR # Additional options - gh combine octocat/hello-world --autoclose # Close source PRs when combined PR is merged - gh combine octocat/hello-world --base-branch main # Use a different base branch for the combined PR - gh combine octocat/hello-world --update-branch # Update the branch of the combined PR`, + gh combine octocat/hello-world --autoclose # Close source PRs when combined PR is merged + gh combine octocat/hello-world --base-branch main # Use a different base branch for the combined PR + gh combine octocat/hello-world --combine-branch-name combined-prs # Use a different name for the combined PR branch + gh combine octocat/hello-world --working-branch-suffix -working # Use a different suffix for the working branch + gh combine octocat/hello-world --update-branch # Update the branch of the combined PR`, RunE: runCombine, } @@ -110,6 +114,8 @@ func NewRootCmd() *cobra.Command { rootCmd.Flags().BoolVar(&autoclose, "autoclose", false, "Close source PRs when combined PR is merged") rootCmd.Flags().BoolVar(&updateBranch, "update-branch", false, "Update the branch of the combined PR if possible") rootCmd.Flags().StringVar(&baseBranch, "base-branch", "main", "Base branch for the combined PR (default: main)") + rootCmd.Flags().StringVar(&combineBranchName, "combine-branch-name", "combined-prs", "Name of the combined PR branch") + rootCmd.Flags().StringVar(&workingBranchSuffix, "working-branch-suffix", "-working", "Suffix of the working branch") rootCmd.Flags().StringVar(&reposFile, "file", "", "File containing repository names, one per line") rootCmd.Flags().IntVar(&minimum, "minimum", 2, "Minimum number of PRs to combine") rootCmd.Flags().StringVar(&defaultOwner, "owner", "", "Default owner for repositories (if not specified in repo name or missing from file inputs)")