|
| 1 | +// Usage examples: |
| 2 | +// go run ./cmd/list-failed-commits --max-commits 20 |
| 3 | +// go run ./cmd/list-failed-commits --max-commits 50 --out failed.json |
| 4 | +// go run ./cmd/list-failed-commits --github-token $GITHUB_TOKEN --max-commits 100 |
| 5 | +// go run ./cmd/list-failed-commits --repo owncloud/ocis --branch master --max-commits 20 |
| 6 | +// go run ./cmd/list-failed-commits --since 7d --max-commits 50 |
| 7 | +// go run ./cmd/list-failed-commits --since 30d --out failed.json |
| 8 | +// go run ./cmd/list-failed-commits --since 2026-01-01 --max-commits 100 |
| 9 | +// go run ./cmd/list-failed-commits --since 2026-02-15 --out recent.json |
| 10 | + |
| 11 | +package main |
| 12 | + |
| 13 | +import ( |
| 14 | + "encoding/json" |
| 15 | + "flag" |
| 16 | + "fmt" |
| 17 | + "io" |
| 18 | + "net/http" |
| 19 | + "os" |
| 20 | + "regexp" |
| 21 | + "strings" |
| 22 | + "time" |
| 23 | + |
| 24 | + "github.com/owncloud/ocis/v2/tools/ci-reporter/pkg/droneextractor" |
| 25 | + "github.com/owncloud/ocis/v2/tools/ci-reporter/pkg/githubextractor" |
| 26 | + "github.com/owncloud/ocis/v2/tools/ci-reporter/pkg/reporter" |
| 27 | + "github.com/owncloud/ocis/v2/tools/ci-reporter/pkg/util" |
| 28 | +) |
| 29 | + |
| 30 | +const ( |
| 31 | + defaultRepo = "owncloud/ocis" |
| 32 | + defaultBranch = "master" |
| 33 | + defaultMaxCommits = 20 |
| 34 | + requestDelay = 1 * time.Second |
| 35 | +) |
| 36 | + |
| 37 | +func main() { |
| 38 | + repo := flag.String("repo", defaultRepo, "GitHub repository (owner/repo)") |
| 39 | + branch := flag.String("branch", defaultBranch, "Branch name") |
| 40 | + maxCommits := flag.Int("max-commits", defaultMaxCommits, "Maximum commits to check") |
| 41 | + since := flag.String("since", "", "Date filter: YYYY-MM-DD, RFC3339 timestamp, or 'Nd' for last N days (e.g., '7d', '30d')") |
| 42 | + outFile := flag.String("out", "", "Output file path (default: stdout)") |
| 43 | + githubToken := flag.String("github-token", "", "GitHub token (optional; falls back to env GITHUB_TOKEN)") |
| 44 | + failedPipelineHistory := flag.Bool("failed_pipeline_history", false, "if set, fetch failed_pipeline_history per commit (extra Drone API calls)") |
| 45 | + flag.Parse() |
| 46 | + |
| 47 | + token := strings.TrimSpace(*githubToken) |
| 48 | + if token == "" { |
| 49 | + token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) |
| 50 | + } |
| 51 | + |
| 52 | + if token != "" { |
| 53 | + fmt.Fprintf(os.Stderr, "Using GitHub token\n") |
| 54 | + } else { |
| 55 | + fmt.Fprintf(os.Stderr, "No token provided, unauthenticated, 60 req/hr limit\n") |
| 56 | + } |
| 57 | + |
| 58 | + command := strings.Join(os.Args[1:], " ") |
| 59 | + command = redactGitHubToken(command) |
| 60 | + |
| 61 | + parsedSince, err := util.ParseSinceFlag(*since) |
| 62 | + if err != nil { |
| 63 | + fmt.Fprintf(os.Stderr, "Error parsing --since flag: %v\n", err) |
| 64 | + os.Exit(1) |
| 65 | + } |
| 66 | + |
| 67 | + if err := run(*repo, *branch, *maxCommits, parsedSince, *outFile, token, command, *failedPipelineHistory); err != nil { |
| 68 | + fmt.Fprintf(os.Stderr, "Error: %v\n", err) |
| 69 | + os.Exit(1) |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +func run(repo, branch string, maxCommits int, since, outFile, token, command string, fetchFailedPipelineHistory bool) error { |
| 74 | + // Configure Transport to prevent stale connection reuse during long pagination |
| 75 | + transport := &http.Transport{ |
| 76 | + MaxIdleConns: 10, |
| 77 | + IdleConnTimeout: 30 * time.Second, |
| 78 | + DisableKeepAlives: false, |
| 79 | + MaxIdleConnsPerHost: 2, |
| 80 | + } |
| 81 | + client := &http.Client{ |
| 82 | + Timeout: 60 * time.Second, // Increased for long-running pagination |
| 83 | + Transport: transport, |
| 84 | + } |
| 85 | + githubExtractor := githubextractor.NewExtractor(client, token) |
| 86 | + droneExtractor := droneextractor.NewExtractor(client) |
| 87 | + |
| 88 | + fmt.Fprintf(os.Stderr, "Scanning %d commits from %s/%s\n", maxCommits, repo, branch) |
| 89 | + commits, err := githubExtractor.GetCommits(repo, branch, maxCommits, since) |
| 90 | + if err != nil { |
| 91 | + return fmt.Errorf("fetching commits: %w", err) |
| 92 | + } |
| 93 | + |
| 94 | + var failedCommits []reporter.FailedCommit |
| 95 | + for _, commit := range commits { |
| 96 | + status, err := githubExtractor.GetCommitStatus(repo, commit.SHA) |
| 97 | + if err != nil { |
| 98 | + fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", commit.SHA[:7], err) |
| 99 | + time.Sleep(requestDelay) |
| 100 | + continue |
| 101 | + } |
| 102 | + |
| 103 | + if status.State == "failure" || status.State == "error" { |
| 104 | + var failedCtxs []reporter.StatusContext |
| 105 | + var pipelineInfo *droneextractor.PipelineInfo |
| 106 | + |
| 107 | + for _, s := range status.Statuses { |
| 108 | + if s.State != "success" { |
| 109 | + // Skip Drone entries - they'll be in pipeline_info instead |
| 110 | + if droneExtractor.IsDroneURL(s.TargetURL) { |
| 111 | + info, err := droneExtractor.Extract(s.TargetURL) |
| 112 | + if err == nil { |
| 113 | + pipelineInfo = info |
| 114 | + } |
| 115 | + // Don't add to failed_contexts to avoid redundancy |
| 116 | + continue |
| 117 | + } |
| 118 | + failedCtxs = append(failedCtxs, toReporterStatusContext(s)) |
| 119 | + } |
| 120 | + } |
| 121 | + // TODO: Refactor redundant field assignment - prPipelineHistory always assigned, failedPipelineHistory conditional |
| 122 | + var failedPipelineHistory []droneextractor.PipelineInfoSummary |
| 123 | + var prPipelineHistory []droneextractor.PipelineInfoSummary |
| 124 | + var prNumber int |
| 125 | + var headRef string |
| 126 | + |
| 127 | + // Get PR for this commit (master commit -> PR ID -> PR branch) |
| 128 | + prs, err := githubExtractor.GetPRsForCommit(repo, commit.SHA) |
| 129 | + if err == nil && len(prs) > 0 { |
| 130 | + prNumber = prs[0].Number |
| 131 | + headRef = prs[0].Head.Ref |
| 132 | + |
| 133 | + // Get failed builds by PR branch (black box: all logic in droneextractor) |
| 134 | + failedBuilds, err := droneExtractor.GetFailedBuildsByPRBranch(droneextractor.BaseURL, repo, headRef) |
| 135 | + if err == nil { |
| 136 | + prPipelineHistory = failedBuilds |
| 137 | + if fetchFailedPipelineHistory { |
| 138 | + failedPipelineHistory = failedBuilds |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + // Initialize empty slice if flag is set but no PR/builds found |
| 144 | + if fetchFailedPipelineHistory && failedPipelineHistory == nil { |
| 145 | + failedPipelineHistory = []droneextractor.PipelineInfoSummary{} |
| 146 | + } |
| 147 | + |
| 148 | + subject := extractSubject(commit.Commit.Message) |
| 149 | + failedCommits = append(failedCommits, reporter.FailedCommit{ |
| 150 | + SHA: commit.SHA, |
| 151 | + Date: commit.Commit.Author.Date, |
| 152 | + Subject: subject, |
| 153 | + HTMLURL: commit.HTMLURL, |
| 154 | + CombinedState: status.State, |
| 155 | + FailedContexts: failedCtxs, |
| 156 | + PipelineInfo: pipelineInfo, |
| 157 | + FailedPipelineHistory: failedPipelineHistory, |
| 158 | + PrPipelineHistory: prPipelineHistory, |
| 159 | + PR: prNumber, |
| 160 | + }) |
| 161 | + |
| 162 | + // Single line output: ✗ sha subject -> PR #num (branch: name) |
| 163 | + if prNumber > 0 && headRef != "" { |
| 164 | + fmt.Fprintf(os.Stderr, "✗ %s %s -> PR #%d (branch: %s)\n", commit.SHA[:7], subject, prNumber, headRef) |
| 165 | + } else { |
| 166 | + fmt.Fprintf(os.Stderr, "✗ %s %s\n", commit.SHA[:7], subject) |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + time.Sleep(requestDelay) |
| 171 | + } |
| 172 | + |
| 173 | + fmt.Fprintf(os.Stderr, "\nResult: %d failed / %d total\n", len(failedCommits), len(commits)) |
| 174 | + |
| 175 | + var minDate, maxDate string |
| 176 | + for _, c := range commits { |
| 177 | + date := c.Commit.Author.Date |
| 178 | + if minDate == "" || date < minDate { |
| 179 | + minDate = date |
| 180 | + } |
| 181 | + if maxDate == "" || date > maxDate { |
| 182 | + maxDate = date |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + metadata := reporter.RaportMetadata{ |
| 187 | + Args: command, |
| 188 | + Repo: repo, |
| 189 | + Branch: branch, |
| 190 | + DateRange: reporter.DateRange{StartDate: minDate, EndDate: maxDate}, |
| 191 | + TotalCommits: len(commits), |
| 192 | + } |
| 193 | + |
| 194 | + report := reporter.GenerateReport(failedCommits, metadata) |
| 195 | + |
| 196 | + var w io.Writer = os.Stdout |
| 197 | + if outFile != "" { |
| 198 | + f, err := os.Create(outFile) |
| 199 | + if err != nil { |
| 200 | + return fmt.Errorf("creating output file: %w", err) |
| 201 | + } |
| 202 | + defer f.Close() |
| 203 | + w = f |
| 204 | + fmt.Fprintf(os.Stderr, "Writing JSON to %s...\n", outFile) |
| 205 | + } |
| 206 | + |
| 207 | + enc := json.NewEncoder(w) |
| 208 | + enc.SetIndent("", " ") |
| 209 | + if err := enc.Encode(report); err != nil { |
| 210 | + return fmt.Errorf("encoding JSON: %w", err) |
| 211 | + } |
| 212 | + |
| 213 | + return nil |
| 214 | +} |
| 215 | + |
| 216 | +func extractSubject(message string) string { |
| 217 | + lines := strings.Split(message, "\n") |
| 218 | + return strings.TrimSpace(lines[0]) |
| 219 | +} |
| 220 | + |
| 221 | +func toReporterStatusContext(ctx githubextractor.StatusContext) reporter.StatusContext { |
| 222 | + return reporter.StatusContext{ |
| 223 | + Context: ctx.Context, |
| 224 | + State: ctx.State, |
| 225 | + TargetURL: ctx.TargetURL, |
| 226 | + Description: ctx.Description, |
| 227 | + } |
| 228 | +} |
| 229 | + |
| 230 | +func redactGitHubToken(command string) string { |
| 231 | + // Match --github-token=<token> or --github-token <token> |
| 232 | + pattern1 := regexp.MustCompile(`--github-token=[^\s]+`) |
| 233 | + command = pattern1.ReplaceAllString(command, "--github-token=***") |
| 234 | + |
| 235 | + pattern2 := regexp.MustCompile(`--github-token\s+[^\s]+`) |
| 236 | + command = pattern2.ReplaceAllString(command, "--github-token ***") |
| 237 | + |
| 238 | + return command |
| 239 | +} |
0 commit comments