From b14e15d9abe3e10dd22ada83e1970e4789c7b2d2 Mon Sep 17 00:00:00 2001 From: nobe4 <nobe4@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:20:40 +0200 Subject: [PATCH 1/8] docs(root): indent the help properly --- internal/cmd/root.go | 94 ++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 1fbe244..538a1b1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -41,53 +41,53 @@ func NewRootCmd() *cobra.Command { Use: "combine owner/repo", Short: "Combine multiple pull requests into a single PR", Long: `Combine multiple pull requests that match specific criteria into a single PR. - Examples: - # Basic usage with a single repository (will default to "--branch-prefix dependabot/" and "--minimum 2") - gh combine octocat/hello-world - - # Multiple repositories (comma-separated) - gh combine octocat/repo1,octocat/repo2 - - # Multiple repositories (no commas) - gh combine octocat/repo1 octocat/repo2 - - # Using default owner for repositories - gh combine --owner octocat repo1 repo2 - - # Using default owner for only some repositories - gh combine --owner octocat repo1 octocat/repo2 - - # Using a file with repository names (one per line: owner/repo format) - gh combine --file repos.txt - - # Filter PRs by branch name - gh combine octocat/hello-world --branch-prefix dependabot/ # Only include PRs with the standard dependabot branch prefix - gh combine octocat/hello-world --branch-suffix -update - gh combine octocat/hello-world --branch-regex "dependabot/.*" - - # Filter PRs by labels - gh combine octocat/hello-world --label dependencies # PRs must have this single label - gh combine octocat/hello-world --labels security,dependencies # PRs must have ALL these labels - - # Exclude PRs by labels - gh combine octocat/hello-world --ignore-label wip # Ignore PRs with this label - gh combine octocat/hello-world --ignore-labels wip,draft # Ignore PRs with ANY of these labels - - # Set requirements for PRs to be combined - gh combine octocat/hello-world --require-ci # Only include PRs with passing CI - gh combine octocat/hello-world --require-approved # Only include approved PRs - gh combine octocat/hello-world --minimum 3 # Need at least 3 matching PRs - - # Add metadata to combined PR - gh combine octocat/hello-world --add-labels security,dependencies # Add these labels to the new PR - 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 --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`, + Examples: + # Basic usage with a single repository (will default to "--branch-prefix dependabot/" and "--minimum 2") + gh combine octocat/hello-world + + # Multiple repositories (comma-separated) + gh combine octocat/repo1,octocat/repo2 + + # Multiple repositories (no commas) + gh combine octocat/repo1 octocat/repo2 + + # Using default owner for repositories + gh combine --owner octocat repo1 repo2 + + # Using default owner for only some repositories + gh combine --owner octocat repo1 octocat/repo2 + + # Using a file with repository names (one per line: owner/repo format) + gh combine --file repos.txt + + # Filter PRs by branch name + gh combine octocat/hello-world --branch-prefix dependabot/ # Only include PRs with the standard dependabot branch prefix + gh combine octocat/hello-world --branch-suffix -update + gh combine octocat/hello-world --branch-regex "dependabot/.*" + + # Filter PRs by labels + gh combine octocat/hello-world --label dependencies # PRs must have this single label + gh combine octocat/hello-world --labels security,dependencies # PRs must have ALL these labels + + # Exclude PRs by labels + gh combine octocat/hello-world --ignore-label wip # Ignore PRs with this label + gh combine octocat/hello-world --ignore-labels wip,draft # Ignore PRs with ANY of these labels + + # Set requirements for PRs to be combined + gh combine octocat/hello-world --require-ci # Only include PRs with passing CI + gh combine octocat/hello-world --require-approved # Only include approved PRs + gh combine octocat/hello-world --minimum 3 # Need at least 3 matching PRs + + # Add metadata to combined PR + gh combine octocat/hello-world --add-labels security,dependencies # Add these labels to the new PR + 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 --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, } From 3095bc2a0f4a4cd522cb45423ac38d8188ae14eb Mon Sep 17 00:00:00 2001 From: nobe4 <nobe4@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:24:16 +0200 Subject: [PATCH 2/8] fix(root): use the example field that's dedicated --- internal/cmd/root.go | 96 ++++++++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 538a1b1..ffa9e46 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -40,54 +40,54 @@ func NewRootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "combine owner/repo", Short: "Combine multiple pull requests into a single PR", - Long: `Combine multiple pull requests that match specific criteria into a single PR. - Examples: - # Basic usage with a single repository (will default to "--branch-prefix dependabot/" and "--minimum 2") - gh combine octocat/hello-world - - # Multiple repositories (comma-separated) - gh combine octocat/repo1,octocat/repo2 - - # Multiple repositories (no commas) - gh combine octocat/repo1 octocat/repo2 - - # Using default owner for repositories - gh combine --owner octocat repo1 repo2 - - # Using default owner for only some repositories - gh combine --owner octocat repo1 octocat/repo2 - - # Using a file with repository names (one per line: owner/repo format) - gh combine --file repos.txt - - # Filter PRs by branch name - gh combine octocat/hello-world --branch-prefix dependabot/ # Only include PRs with the standard dependabot branch prefix - gh combine octocat/hello-world --branch-suffix -update - gh combine octocat/hello-world --branch-regex "dependabot/.*" - - # Filter PRs by labels - gh combine octocat/hello-world --label dependencies # PRs must have this single label - gh combine octocat/hello-world --labels security,dependencies # PRs must have ALL these labels - - # Exclude PRs by labels - gh combine octocat/hello-world --ignore-label wip # Ignore PRs with this label - gh combine octocat/hello-world --ignore-labels wip,draft # Ignore PRs with ANY of these labels - - # Set requirements for PRs to be combined - gh combine octocat/hello-world --require-ci # Only include PRs with passing CI - gh combine octocat/hello-world --require-approved # Only include approved PRs - gh combine octocat/hello-world --minimum 3 # Need at least 3 matching PRs - - # Add metadata to combined PR - gh combine octocat/hello-world --add-labels security,dependencies # Add these labels to the new PR - 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 --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`, + Long: `Combine multiple pull requests that match specific criteria into a single PR.`, + Example: ` + # Basic usage with a single repository (will default to "--branch-prefix dependabot/" and "--minimum 2") + gh combine octocat/hello-world + + # Multiple repositories (comma-separated) + gh combine octocat/repo1,octocat/repo2 + + # Multiple repositories (no commas) + gh combine octocat/repo1 octocat/repo2 + + # Using default owner for repositories + gh combine --owner octocat repo1 repo2 + + # Using default owner for only some repositories + gh combine --owner octocat repo1 octocat/repo2 + + # Using a file with repository names (one per line: owner/repo format) + gh combine --file repos.txt + + # Filter PRs by branch name + gh combine octocat/hello-world --branch-prefix dependabot/ # Only include PRs with the standard dependabot branch prefix + gh combine octocat/hello-world --branch-suffix -update + gh combine octocat/hello-world --branch-regex "dependabot/.*" + + # Filter PRs by labels + gh combine octocat/hello-world --label dependencies # PRs must have this single label + gh combine octocat/hello-world --labels security,dependencies # PRs must have ALL these labels + + # Exclude PRs by labels + gh combine octocat/hello-world --ignore-label wip # Ignore PRs with this label + gh combine octocat/hello-world --ignore-labels wip,draft # Ignore PRs with ANY of these labels + + # Set requirements for PRs to be combined + gh combine octocat/hello-world --require-ci # Only include PRs with passing CI + gh combine octocat/hello-world --require-approved # Only include approved PRs + gh combine octocat/hello-world --minimum 3 # Need at least 3 matching PRs + + # Add metadata to combined PR + gh combine octocat/hello-world --add-labels security,dependencies # Add these labels to the new PR + 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 --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, } From c3a6495d26f893c07ff971fc538b4e7f4270d1f5 Mon Sep 17 00:00:00 2001 From: nobe4 <nobe4@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:45:05 +0200 Subject: [PATCH 3/8] feat(input): add better error handling and tests --- internal/cmd/inputs.go | 77 ++++++++++++++++++++++--------------- internal/cmd/inputs_test.go | 63 ++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 32 deletions(-) diff --git a/internal/cmd/inputs.go b/internal/cmd/inputs.go index 05236f3..f46a4c1 100644 --- a/internal/cmd/inputs.go +++ b/internal/cmd/inputs.go @@ -3,55 +3,68 @@ package cmd import ( "errors" "fmt" + "slices" +) + +// @grant: whenever possible, use sentinel errors. This ensures that those +// values can be testsed with `errors.Is()` correctly. +var ( + errNoRepo = errors.New("must specify repositories or provide a file containing a list of repositories with --file") + errLabelConflict = errors.New("--ignore-label(s) and --label(s) cannot have the same value") + errLabelsConflict = errors.New("--label(s) contains a value which conflicts with --ignore-label(s)") + errIgnoreLabelsConflict = errors.New("--ignore-label(s) contains a value which conflicts with --label(s)") ) // validateInputs checks if the provided inputs are valid func ValidateInputs(args []string) error { + // @grant: using global variables is terrible for tests, so here I've + // injected them into the function for validation. + if err := ValidateLabels(selectLabel, selectLabels, ignoreLabel, ignoreLabels); err != nil { + return err + } + + // If no args and no file, we can't proceed + if len(args) == 0 && reposFile == "" { + return errNoRepo + } + + // Warn if no filtering options are provided at all + if branchPrefix == "" && branchSuffix == "" && branchRegex == "" && + ignoreLabel == "" && len(ignoreLabels) == 0 && selectLabel == "" && len(selectLabels) == 0 && + !requireCI && !mustBeApproved { + Logger.Warn("No filtering options specified. This will attempt to combine ALL open pull requests.") + } + + return nil +} + +// @grant: this function is 100% self contained, and does the same logic as +// before, which makes it really easier to test. +func ValidateLabels(selectLabel string, selectLabels []string, ignoreLabel string, ignoreLabels []string) error { // Check that ignore-label and select-label are not the same if ignoreLabel != "" && selectLabel != "" && ignoreLabel == selectLabel { - return errors.New("--ignore-label(s) and --label(s) cannot have the same value") + return errLabelConflict } + // @grant: slices.Index beats a `for` and an `if`. // Check for conflicts between ignoreLabels and selectLabel - if selectLabel != "" && len(ignoreLabels) > 0 { - for _, ignoreL := range ignoreLabels { - if ignoreL == selectLabel { - return fmt.Errorf("--ignore-labels contains %q which conflicts with --label %q", ignoreL, selectLabel) - } - } + if i := slices.Index(ignoreLabels, selectLabel); i != -1 { + return fmt.Errorf("%w: %q %q", errIgnoreLabelsConflict, ignoreLabels[i], selectLabel) } // Check for conflicts between ignoreLabel and selectLabels - if ignoreLabel != "" && len(selectLabels) > 0 { - for _, selectL := range selectLabels { - if selectL == ignoreLabel { - return fmt.Errorf("--label(s) contains %q which conflicts with --ignore-label %q", selectL, ignoreLabel) - } - } + if i := slices.Index(selectLabels, ignoreLabel); i != -1 { + return fmt.Errorf("%w: %q %q", errLabelsConflict, selectLabels[i], ignoreLabel) } + // @grant: no need to check for the length if we iterate directly, the + // performance gain is moot. // Check for conflicts between ignoreLabels and selectLabels - if len(ignoreLabels) > 0 && len(selectLabels) > 0 { - for _, ignoreL := range ignoreLabels { - for _, selectL := range selectLabels { - if ignoreL == selectL { - return fmt.Errorf("--ignore-labels contains %q which conflicts with --labels containing the same value", ignoreL) - } - } + for _, ignoreL := range ignoreLabels { + if i := slices.Index(selectLabels, ignoreL); i != -1 { + return fmt.Errorf("%w: %q %q", errIgnoreLabelsConflict, ignoreLabels[i], selectLabel) } } - // If no args and no file, we can't proceed - if len(args) == 0 && reposFile == "" { - return errors.New("must specify repositories or provide a file containing a list of repositories with --file") - } - - // Warn if no filtering options are provided at all - if branchPrefix == "" && branchSuffix == "" && branchRegex == "" && - ignoreLabel == "" && len(ignoreLabels) == 0 && selectLabel == "" && len(selectLabels) == 0 && - !requireCI && !mustBeApproved { - Logger.Warn("No filtering options specified. This will attempt to combine ALL open pull requests.") - } - return nil } diff --git a/internal/cmd/inputs_test.go b/internal/cmd/inputs_test.go index 6d5528b..9c4bd29 100644 --- a/internal/cmd/inputs_test.go +++ b/internal/cmd/inputs_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "errors" "log/slog" "os" "strings" @@ -19,6 +20,68 @@ func setupMockLogger() (*bytes.Buffer, func()) { } } +// @grant: this is a better pattern to write tests. +// In a nutshell, you make each function as self-contained as possible, create +// and iterate over a list of test cases and ensure the expected output is +// received. +func TestValidateLabels(t *testing.T) { + tests := []struct { + selectLabel string + selectLabels []string + ignoreLabel string + ignoreLabels []string + want error + }{ + { + // empty labels + want: nil, + }, + + { + selectLabel: "enhancement", + ignoreLabel: "bug", + want: nil, + }, + + { + selectLabel: "enhancement", + ignoreLabel: "enhancement", + want: errLabelConflict, + }, + + { + selectLabel: "enhancement", + ignoreLabels: []string{"bug", "enhancement"}, + want: errIgnoreLabelsConflict, + }, + + { + selectLabels: []string{"bug", "enhancement"}, + ignoreLabel: "enhancement", + want: errLabelsConflict, + }, + + { + selectLabels: []string{"bug", "enhancement"}, + ignoreLabels: []string{"request", "bug"}, + want: errIgnoreLabelsConflict, + }, + + { + selectLabels: []string{"bug", "enhancement"}, + ignoreLabels: []string{"request", "docs"}, + want: nil, + }, + } + + for _, test := range tests { + got := ValidateLabels(test.selectLabel, test.selectLabels, test.ignoreLabel, test.ignoreLabels) + if !errors.Is(got, test.want) { + t.Fatalf("want %v, got %v", test.want, got) + } + } +} + func TestValidateInputs_NoReposSpecified(t *testing.T) { // Save original values origReposFile := reposFile From 519b299ff3d89cc813da89f449c57edfc9a65ab5 Mon Sep 17 00:00:00 2001 From: nobe4 <nobe4@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:48:48 +0200 Subject: [PATCH 4/8] fix(root): small improvements - handle error correctly - remove unused parameter --- internal/cmd/root.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ffa9e46..246b31d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -172,9 +172,13 @@ 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() + if err != nil { + return fmt.Errorf("failed to get the default REST client: %w", err) + } + graphQlClient, err := api.DefaultGraphQLClient() if err != nil { - return fmt.Errorf("failed to create REST client: %w", err) + return fmt.Errorf("failed to create the default GraphQL Client : %w", err) } for _, repo := range repos { @@ -190,7 +194,7 @@ func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string Logger.Debug("Processing repository", "repo", repo) // Process the repository - if err := processRepository(ctx, restClient, graphQlClient, spinner, repo); err != nil { + if err := processRepository(ctx, restClient, graphQlClient, repo); err != nil { if ctx.Err() != nil { // If the context was cancelled, stop processing return ctx.Err() @@ -205,7 +209,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, graphQlClient *api.GraphQLClient, spinner *Spinner, repo string) error { +func processRepository(ctx context.Context, client *api.RESTClient, graphQlClient *api.GraphQLClient, repo string) error { // Parse owner and repo name parts := strings.Split(repo, "/") if len(parts) != 2 { From 49d53f65a8d7624031652eea4cafb3abc469e7da Mon Sep 17 00:00:00 2001 From: nobe4 <nobe4@users.noreply.github.com> Date: Tue, 8 Apr 2025 10:14:19 +0200 Subject: [PATCH 5/8] feat(github): pull github-related logic into a new module --- internal/cmd/combine_prs.go | 1 + internal/cmd/match_criteria.go | 2 + internal/cmd/root.go | 97 ++++++---------------------------- internal/github/github.go | 61 +++++++++++++++++++++ internal/github/repo.go | 21 ++++++++ 5 files changed, 100 insertions(+), 82 deletions(-) create mode 100644 internal/github/github.go create mode 100644 internal/github/repo.go diff --git a/internal/cmd/combine_prs.go b/internal/cmd/combine_prs.go index cf0fa5d..4c4ba4a 100644 --- a/internal/cmd/combine_prs.go +++ b/internal/cmd/combine_prs.go @@ -1,3 +1,4 @@ +// @grant: this whole code should be moved to the github package. package cmd import ( diff --git a/internal/cmd/match_criteria.go b/internal/cmd/match_criteria.go index 4f67b1f..5e00d36 100644 --- a/internal/cmd/match_criteria.go +++ b/internal/cmd/match_criteria.go @@ -119,6 +119,7 @@ func labelsMatchCriteria(prLabels []struct{ Name string }) bool { return true } +// @grant: this should be moved to the github package // GraphQL response structure for PR status info type prStatusResponse struct { Data struct { @@ -142,6 +143,7 @@ type prStatusResponse struct { } `json:"errors,omitempty"` } +// @grant: this should be moved to the github package // 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 diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 246b31d..285616f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -4,12 +4,11 @@ import ( "context" "errors" "fmt" - "strings" - - "github.com/cli/go-gh/v2/pkg/api" + "slices" "github.com/spf13/cobra" + "github.com/github/gh-combine/internal/github" "github.com/github/gh-combine/internal/version" ) @@ -170,15 +169,9 @@ func runCombine(cmd *cobra.Command, args []string) error { // executeCombineCommand performs the actual API calls and processing func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string) error { - // Create GitHub API client - restClient, err := api.DefaultRESTClient() - if err != nil { - return fmt.Errorf("failed to get the default REST client: %w", err) - } - - graphQlClient, err := api.DefaultGraphQLClient() + client, err := github.NewClient() if err != nil { - return fmt.Errorf("failed to create the default GraphQL Client : %w", err) + return fmt.Errorf("failed to create GitHub client: %w", err) } for _, repo := range repos { @@ -194,7 +187,7 @@ func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string Logger.Debug("Processing repository", "repo", repo) // Process the repository - if err := processRepository(ctx, restClient, graphQlClient, repo); err != nil { + if err := processRepository(ctx, client, graphQlClient, repo); err != nil { if ctx.Err() != nil { // If the context was cancelled, stop processing return ctx.Err() @@ -208,44 +201,10 @@ func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string return nil } -// processRepository handles a single repository's PRs -func processRepository(ctx context.Context, client *api.RESTClient, graphQlClient *api.GraphQLClient, repo string) error { - // Parse owner and repo name - parts := strings.Split(repo, "/") - if len(parts) != 2 { - return fmt.Errorf("invalid repository format: %s", repo) - } - - owner := parts[0] - repoName := parts[1] - - // Check for cancellation - select { - case <-ctx.Done(): - return ctx.Err() - default: - // Continue processing - } - - // Get open PRs for the repository - var pulls []struct { - Number int - Title string - Head struct { - Ref string - } - Base struct { - Ref string - SHA string - } - Labels []struct { - Name string - } - } - - endpoint := fmt.Sprintf("repos/%s/%s/pulls?state=open", owner, repoName) - if err := client.Get(endpoint, &pulls); err != nil { - return err +func processRepository(ctx context.Context, client github.Client, repo string) error { + openPRs, err := client.GetOpenPRs(ctx, repo) + if err != nil { + return fmt.Errorf("failed to get open PRs: %w", err) } // Check for cancellation again @@ -256,21 +215,11 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien // Continue processing } - // Filter PRs based on criteria - var matchedPRs []struct { - Number int - Title string - Branch string - Base string - BaseSHA string - } - - for _, pull := range pulls { - branch := pull.Head.Ref - + // @grant: filtering with DeleteFunc is _nice_. + filteredPRs := slices.DeleteFunc(openPRs, func(pr github.PR) bool { // Check if PR matches all filtering criteria - if !PrMatchesCriteria(branch, pull.Labels) { - continue + if !PrMatchesCriteria(branch, pr.Labels) { + return true } // Check if PR meets additional requirements (CI, approval) @@ -284,32 +233,16 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien // Skip this PR as it doesn't meet CI/approval requirements continue } + }) - matchedPRs = append(matchedPRs, struct { - Number int - Title string - Branch string - Base string - BaseSHA string - }{ - Number: pull.Number, - Title: pull.Title, - Branch: branch, - Base: pull.Base.Ref, - BaseSHA: pull.Base.SHA, - }) - } - - // Check if we have enough PRs to combine if len(matchedPRs) < minimum { + // @grant: shouldn't this be an error? Logger.Debug("Not enough PRs match criteria", "repo", repo, "matched", len(matchedPRs), "required", minimum) return nil } 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 { diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..61df7c9 --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,61 @@ +package github + +import ( + "context" + "fmt" + + "github.com/cli/go-gh/v2/pkg/api" +) + +type Client struct { + REST *api.RESTClient + GraphQL *api.GraphQLClient +} + +type PR struct { + Number int + Title string + Head struct { + Ref string + } + Base struct { + Ref string + SHA string + } + Labels []struct { + Name string + } +} + +func New() (Client, error) { + var err error + + c := Client{} + + if c.REST, err = api.DefaultRESTClient(); err != nil { + return Client{}, fmt.Errorf("failed to get the default REST client: %w", err) + } + + if c.GraphQL, err = api.DefaultGraphQLClient(); err != nil { + return Client{}, fmt.Errorf("failed to create the default GraphQL Client : %w", err) + } + + return c, nil +} + +func (c Client) GetOpenPRs(ctx context.Context, repo Repo) ([]PR, error) { + // Check for cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + // Continue processing + } + + var pulls []PR + + endpoint := fmt.Sprintf("repos/%s/%s/pulls?state=open", owner, repoName) + if err := c.REST.DoWithContext(ctx, "GET", endpoint, &pulls); err != nil { + return err + } +} diff --git a/internal/github/repo.go b/internal/github/repo.go new file mode 100644 index 0000000..3f1715f --- /dev/null +++ b/internal/github/repo.go @@ -0,0 +1,21 @@ +Package github + + +type Repo struct { + Owner string + Repo string +} + +// @grant: testing this should be pretty easy. +// Make sure to create a custom error. +func ParseRepo(repo string) (Repo, error) { + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return Repo{}, fmt.Errorf("invalid repository format: %s", repo) + } + + return Repo{ + Owner: parts[0], + Repo: parts[1], + }, nil +} From 839569b59a1a48100ce90e01093cf8b99dc8da73 Mon Sep 17 00:00:00 2001 From: Grant Birkinbine <grant.birkinbine@gmail.com> Date: Tue, 8 Apr 2025 16:01:28 -0700 Subject: [PATCH 6/8] Update internal/github/repo.go --- internal/github/repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/github/repo.go b/internal/github/repo.go index 3f1715f..2427128 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -1,4 +1,4 @@ -Package github +package github type Repo struct { From 5bbce843b784bf5a3429f3e90c43ffe90c1141ae Mon Sep 17 00:00:00 2001 From: GrantBirki <grant.birkinbine@gmail.com> Date: Tue, 8 Apr 2025 16:03:26 -0700 Subject: [PATCH 7/8] run the linter --- internal/github/repo.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/github/repo.go b/internal/github/repo.go index 2427128..093caae 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -1,6 +1,5 @@ package github - type Repo struct { Owner string Repo string From 9fa80b89341767045c5a99217b24c079c7b2cbb5 Mon Sep 17 00:00:00 2001 From: GrantBirki <grant.birkinbine@gmail.com> Date: Tue, 8 Apr 2025 16:06:29 -0700 Subject: [PATCH 8/8] add missing imports --- internal/github/repo.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/github/repo.go b/internal/github/repo.go index 093caae..ffb34cc 100644 --- a/internal/github/repo.go +++ b/internal/github/repo.go @@ -1,5 +1,10 @@ package github +import ( + "fmt" + "strings" +) + type Repo struct { Owner string Repo string