Skip to content

Commit a61ccdd

Browse files
committed
feat: ci, weekly pipeline report, 2 weeks window to gather drone pipeline logs before expiry
1 parent 95ba2da commit a61ccdd

File tree

10 files changed

+1556
-0
lines changed

10 files changed

+1556
-0
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Weekly Pipeline Report
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "0 8 * * 1" # Every Monday at 8:00 AM UTC
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
generate-report:
13+
name: Generate Pipeline Report
14+
runs-on: ubuntu-latest
15+
container: golang:1.25-alpine
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v4
19+
20+
- name: Run CI Reporter
21+
working-directory: tools/ci-reporter
22+
env:
23+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24+
run: |
25+
GOWORK=off go run ./cmd/list-failed-commits \
26+
--github-token "$GITHUB_TOKEN" \
27+
--since 14d \
28+
--max-commits 9999999
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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

Comments
 (0)