Skip to content

Stats output #15

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ gh combine owner/repo --ignore-labels wip,dependencies
gh combine owner/repo --update-branch
```

### Disable Stats Output

```bash
gh combine owner/repo --no-stats
```

### Disable Color

```bash
gh combine owner/repo --no-color
```

### Running with Debug Logging

```bash
Expand Down
96 changes: 69 additions & 27 deletions internal/cmd/combine_prs.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,27 @@ type RESTClientInterface interface {
Patch(endpoint string, body io.Reader, response interface{}) error
}

func CombinePRs(ctx context.Context, graphQlClient *api.GraphQLClient, restClient RESTClientInterface, repo github.Repo, pulls github.Pulls) error {
// Define the combined branch name
// CombinePRsWithStats combines PRs and returns stats for summary output
func CombinePRsWithStats(ctx context.Context, graphQlClient *api.GraphQLClient, restClient RESTClientInterface, repo github.Repo, pulls github.Pulls) (combined []string, mergeConflicts []string, combinedPRLink string, err error) {
workingBranchName := combineBranchName + workingBranchSuffix

// Get the default branch of the repository
repoDefaultBranch, err := getDefaultBranch(ctx, restClient, repo)
if err != nil {
return fmt.Errorf("failed to get default branch: %w", err)
return nil, nil, "", fmt.Errorf("failed to get default branch: %w", err)
}

baseBranchSHA, err := getBranchSHA(ctx, restClient, repo, repoDefaultBranch)
if err != nil {
return fmt.Errorf("failed to get SHA of main branch: %w", err)
return nil, nil, "", fmt.Errorf("failed to get SHA of main branch: %w", err)
}
// Delete any pre-existing working branch

// Delete any pre-existing working branch
err = deleteBranch(ctx, restClient, repo, workingBranchName)
if err != nil {
Logger.Debug("Working branch not found, continuing", "branch", workingBranchName)

// Delete any pre-existing combined branch
}

// Delete any pre-existing combined branch
Expand All @@ -47,60 +49,100 @@ func CombinePRs(ctx context.Context, graphQlClient *api.GraphQLClient, restClien
Logger.Debug("Combined branch not found, continuing", "branch", combineBranchName)
}

// Create the combined branch
err = createBranch(ctx, restClient, repo, combineBranchName, baseBranchSHA)
if err != nil {
return fmt.Errorf("failed to create combined branch: %w", err)
return nil, nil, "", fmt.Errorf("failed to create combined branch: %w", err)
}

// Create the working branch
err = createBranch(ctx, restClient, repo, workingBranchName, baseBranchSHA)
if err != nil {
return fmt.Errorf("failed to create working branch: %w", err)
return nil, nil, "", 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 pulls {
err := mergeBranch(ctx, restClient, repo, workingBranchName, pr.Head.Ref)
if err != nil {
// Check if the error is a 409 merge conflict
if isMergeConflictError(err) {
// Log merge conflicts at DEBUG level
Logger.Debug("Merge conflict", "branch", pr.Head.Ref, "error", err)
} else {
// Log other errors at WARN level
Logger.Warn("Failed to merge branch", "branch", pr.Head.Ref, "error", err)
}
mergeFailedPRs = append(mergeFailedPRs, fmt.Sprintf("#%d", pr.Number))
mergeConflicts = append(mergeConflicts, fmt.Sprintf("#%d", pr.Number))
} else {
Logger.Debug("Merged branch", "branch", pr.Head.Ref)
combinedPRs = append(combinedPRs, fmt.Sprintf("#%d - %s", pr.Number, pr.Title))
combined = append(combined, fmt.Sprintf("#%d - %s", pr.Number, pr.Title))
}
}

// Update the combined branch to the latest commit of the working branch
err = updateRef(ctx, restClient, repo, combineBranchName, workingBranchName)
if err != nil {
return fmt.Errorf("failed to update combined branch: %w", err)
return combined, mergeConflicts, "", fmt.Errorf("failed to update combined branch: %w", err)
}

// Delete the temporary working branch
err = deleteBranch(ctx, restClient, repo, workingBranchName)
if err != nil {
Logger.Warn("Failed to delete working branch", "branch", workingBranchName, "error", err)
}

// Create the combined PR
prBody := generatePRBody(combinedPRs, mergeFailedPRs)
prBody := generatePRBody(combined, mergeConflicts)
prTitle := "Combined PRs"
err = createPullRequest(ctx, restClient, repo, prTitle, combineBranchName, repoDefaultBranch, prBody, addLabels, addAssignees)
prNumber, prErr := createPullRequestWithNumber(ctx, restClient, repo, prTitle, combineBranchName, repoDefaultBranch, prBody, addLabels, addAssignees)
if prErr != nil {
return combined, mergeConflicts, "", fmt.Errorf("failed to create combined PR: %w", prErr)
}
if prNumber > 0 {
combinedPRLink = fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.Owner, repo.Repo, prNumber)
}

return combined, mergeConflicts, combinedPRLink, nil
}

// createPullRequestWithNumber creates a PR and returns its number
func createPullRequestWithNumber(ctx context.Context, client RESTClientInterface, repo github.Repo, title, head, base, body string, labels, assignees []string) (int, error) {
endpoint := fmt.Sprintf("repos/%s/%s/pulls", repo.Owner, repo.Repo)
payload := map[string]interface{}{
"title": title,
"head": head,
"base": base,
"body": body,
}

requestBody, err := encodePayload(payload)
if err != nil {
return fmt.Errorf("failed to create combined PR: %w", err)
return 0, fmt.Errorf("failed to encode payload: %w", err)
}

return nil
var prResponse struct {
Number int `json:"number"`
}
err = client.Post(endpoint, requestBody, &prResponse)
if err != nil {
return 0, fmt.Errorf("failed to create pull request: %w", err)
}

if len(labels) > 0 {
labelsEndpoint := fmt.Sprintf("repos/%s/%s/issues/%d/labels", repo.Owner, repo.Repo, prResponse.Number)
labelsPayload, err := encodePayload(map[string][]string{"labels": labels})
if err != nil {
return prResponse.Number, fmt.Errorf("failed to encode labels payload: %w", err)
}
err = client.Post(labelsEndpoint, labelsPayload, nil)
if err != nil {
return prResponse.Number, fmt.Errorf("failed to add labels: %w", err)
}
}

if len(assignees) > 0 {
assigneesEndpoint := fmt.Sprintf("repos/%s/%s/issues/%d/assignees", repo.Owner, repo.Repo, prResponse.Number)
assigneesPayload, err := encodePayload(map[string][]string{"assignees": assignees})
if err != nil {
return prResponse.Number, fmt.Errorf("failed to encode assignees payload: %w", err)
}
err = client.Post(assigneesEndpoint, assigneesPayload, nil)
if err != nil {
return prResponse.Number, fmt.Errorf("failed to add assignees: %w", err)
}
}

return prResponse.Number, nil
}

// isMergeConflictError checks if the error is a 409 Merge Conflict
Expand Down
110 changes: 102 additions & 8 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"

"github.com/cli/go-gh/v2/pkg/api"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -35,8 +36,31 @@ var (
workingBranchSuffix string
dependabot bool
caseSensitiveLabels bool
noColor bool
noStats bool
)

// StatsCollector tracks stats for the CLI run
type StatsCollector struct {
ReposProcessed int
PRsCombined int
PRsSkippedMergeConflict int
PRsSkippedCriteria int
PerRepoStats map[string]*RepoStats
CombinedPRLinks []string
StartTime time.Time
EndTime time.Time
}

type RepoStats struct {
RepoName string
CombinedCount int
SkippedMergeConf int
SkippedCriteria int
CombinedPRLink string
NotEnoughPRs bool
}

// NewRootCmd creates the root command for the gh-combine CLI
func NewRootCmd() *cobra.Command {
rootCmd := &cobra.Command{
Expand Down Expand Up @@ -96,6 +120,8 @@ func NewRootCmd() *cobra.Command {
# Additional options
gh combine owner/repo --autoclose # Close source PRs when combined PR is merged
gh combine owner/repo --base-branch main # Use a different base branch for the combined PR
gh combine owner/repo --no-color # Disable color output
gh combine owner/repo --no-stats # Disable stats summary display
gh combine owner/repo --combine-branch-name combined-prs # Use a different name for the combined PR branch
gh combine owner/repo --working-branch-suffix -working # Use a different suffix for the working branch
gh combine owner/repo --update-branch # Update the branch of the combined PR`,
Expand Down Expand Up @@ -127,6 +153,8 @@ func NewRootCmd() *cobra.Command {
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)")
rootCmd.Flags().BoolVar(&caseSensitiveLabels, "case-sensitive-labels", false, "Use case-sensitive label matching")
rootCmd.Flags().BoolVar(&noColor, "no-color", false, "Disable color output")
rootCmd.Flags().BoolVar(&noStats, "no-stats", false, "Disable stats summary display")

// Add deprecated flags for backward compatibility
// rootCmd.Flags().IntVar(&minimum, "min-combine", 2, "Minimum number of PRs to combine (deprecated, use --minimum)")
Expand Down Expand Up @@ -172,16 +200,27 @@ func runCombine(cmd *cobra.Command, args []string) error {
return errors.New("no repositories specified")
}

stats := &StatsCollector{
PerRepoStats: make(map[string]*RepoStats),
StartTime: time.Now(),
}

// Execute combination logic
if err := executeCombineCommand(ctx, spinner, repos); err != nil {
if err := executeCombineCommand(ctx, spinner, repos, stats); err != nil {
return fmt.Errorf("command execution failed: %w", err)
}
stats.EndTime = time.Now()

if !noStats {
spinner.Stop()
displayStatsSummary(stats)
}

return nil
}

// executeCombineCommand performs the actual API calls and processing
func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string) error {
func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string, stats *StatsCollector) error {
// Create GitHub API client
restClient, err := api.DefaultRESTClient()
if err != nil {
Expand Down Expand Up @@ -213,23 +252,27 @@ func executeCombineCommand(ctx context.Context, spinner *Spinner, repos []string
spinner.UpdateMessage("Processing " + repo.String())
Logger.Debug("Processing repository", "repo", repo)

if stats.PerRepoStats[repo.String()] == nil {
stats.PerRepoStats[repo.String()] = &RepoStats{RepoName: repo.String()}
}

// Process the repository
if err := processRepository(ctx, restClient, graphQlClient, spinner, repo); err != nil {
if err := processRepository(ctx, restClient, graphQlClient, spinner, repo, stats.PerRepoStats[repo.String()], stats); err != nil {
if ctx.Err() != nil {
// If the context was cancelled, stop processing
return ctx.Err()
}
// Otherwise just log the error and continue
Logger.Warn("Failed to process repository", "repo", repo, "error", err)
continue
}
stats.ReposProcessed++
}

return nil
}

// processRepository handles a single repository's PRs
func processRepository(ctx context.Context, client *api.RESTClient, graphQlClient *api.GraphQLClient, spinner *Spinner, repo github.Repo) error {
func processRepository(ctx context.Context, client *api.RESTClient, graphQlClient *api.GraphQLClient, spinner *Spinner, repo github.Repo, repoStats *RepoStats, stats *StatsCollector) error {
// Check for cancellation
select {
case <-ctx.Done():
Expand Down Expand Up @@ -263,6 +306,8 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien

// Check if PR matches all filtering criteria
if !PrMatchesCriteria(pull.Head.Ref, labels) {
repoStats.SkippedCriteria++
stats.PRsSkippedCriteria++
continue
}

Expand All @@ -274,7 +319,8 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien
}

if !meetsRequirements {
// Skip this PR as it doesn't meet CI/approval requirements
repoStats.SkippedCriteria++
stats.PRsSkippedCriteria++
continue
}

Expand All @@ -284,6 +330,7 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien
// Check if we have enough PRs to combine
if len(matchedPRs) < minimum {
Logger.Debug("Not enough PRs match criteria", "repo", repo, "matched", len(matchedPRs), "required", minimum)
repoStats.NotEnoughPRs = true
return nil
}

Expand All @@ -294,12 +341,21 @@ func processRepository(ctx context.Context, client *api.RESTClient, graphQlClien
RESTClientInterface
}{client}

// Combine the PRs
err = CombinePRs(ctx, graphQlClient, restClientWrapper, repo, matchedPRs)
// Combine the PRs and collect stats
combined, mergeConflicts, combinedPRLink, err := CombinePRsWithStats(ctx, graphQlClient, restClientWrapper, repo, matchedPRs)
if err != nil {
return fmt.Errorf("failed to combine PRs: %w", err)
}

repoStats.CombinedCount = len(combined)
repoStats.SkippedMergeConf = len(mergeConflicts)
repoStats.CombinedPRLink = combinedPRLink
stats.PRsCombined += len(combined)
stats.PRsSkippedMergeConflict += len(mergeConflicts)
if combinedPRLink != "" {
stats.CombinedPRLinks = append(stats.CombinedPRLinks, combinedPRLink)
}

Logger.Debug("Combined PRs", "count", len(matchedPRs), "owner", repo.Owner, "repo", repo.Repo)

return nil
Expand Down Expand Up @@ -343,3 +399,41 @@ func fetchOpenPullRequests(ctx context.Context, client *api.RESTClient, repo git

return allPulls, nil
}

func displayStatsSummary(stats *StatsCollector) {
elapsed := stats.EndTime.Sub(stats.StartTime)
if noColor {
fmt.Println("Stats Summary (Color Disabled):")
} else {
fmt.Println("\033[1;34mStats Summary:\033[0m")
}
fmt.Printf("Repositories Processed: %d\n", stats.ReposProcessed)
fmt.Printf("PRs Combined: %d\n", stats.PRsCombined)
fmt.Printf("PRs Skipped (Merge Conflicts): %d\n", stats.PRsSkippedMergeConflict)
fmt.Printf("PRs Skipped (Criteria Not Met): %d\n", stats.PRsSkippedCriteria)
fmt.Printf("Execution Time: %s\n", elapsed.Round(time.Second))

if !noColor {
fmt.Println("\033[1;32mLinks to Combined PRs:\033[0m")
} else {
fmt.Println("Links to Combined PRs:")
}
for _, link := range stats.CombinedPRLinks {
fmt.Println("-", link)
}

fmt.Println("\nPer-Repository Details:")
for _, repoStat := range stats.PerRepoStats {
fmt.Printf(" %s\n", repoStat.RepoName)
if repoStat.NotEnoughPRs {
fmt.Println(" Not enough PRs to combine.")
continue
}
fmt.Printf(" Combined: %d\n", repoStat.CombinedCount)
fmt.Printf(" Skipped (Merge Conflicts): %d\n", repoStat.SkippedMergeConf)
fmt.Printf(" Skipped (Criteria): %d\n", repoStat.SkippedCriteria)
if repoStat.CombinedPRLink != "" {
fmt.Printf(" Combined PR: %s\n", repoStat.CombinedPRLink)
}
}
}
Loading