From e6488ea1ef5e5aae074f655181c3c5532877109f Mon Sep 17 00:00:00 2001 From: Michal Klos Date: Tue, 3 Mar 2026 09:25:40 +0100 Subject: [PATCH] feat: ci, weekly pipeline report, 2 weeks window to gather drone pipeline logs before expiry (#12053) --- .github/workflows/weekly_pipeline_report.yml | 32 + .../cmd/list-failed-commits/main.go | 379 +++++++++++ tools/ci-reporter/go.mod | 5 + tools/ci-reporter/go.sum | 2 + .../pkg/droneextractor/extractor.go | 631 ++++++++++++++++++ .../pkg/droneextractor/pr_builds.go | 78 +++ tools/ci-reporter/pkg/droneextractor/types.go | 39 ++ .../pkg/githubextractor/extractor.go | 308 +++++++++ tools/ci-reporter/pkg/util/util.go | 70 ++ 9 files changed, 1544 insertions(+) create mode 100644 .github/workflows/weekly_pipeline_report.yml create mode 100644 tools/ci-reporter/cmd/list-failed-commits/main.go create mode 100644 tools/ci-reporter/go.mod create mode 100644 tools/ci-reporter/go.sum create mode 100644 tools/ci-reporter/pkg/droneextractor/extractor.go create mode 100644 tools/ci-reporter/pkg/droneextractor/pr_builds.go create mode 100644 tools/ci-reporter/pkg/droneextractor/types.go create mode 100644 tools/ci-reporter/pkg/githubextractor/extractor.go create mode 100644 tools/ci-reporter/pkg/util/util.go diff --git a/.github/workflows/weekly_pipeline_report.yml b/.github/workflows/weekly_pipeline_report.yml new file mode 100644 index 00000000000..22c03e35571 --- /dev/null +++ b/.github/workflows/weekly_pipeline_report.yml @@ -0,0 +1,32 @@ +name: Weekly Pipeline Report + +on: + workflow_dispatch: + pull_request: + branches: + - feat/ci-report-tool + schedule: + - cron: "0 8 * * 1" # Every Monday at 8:00 AM UTC + +permissions: + contents: read + +jobs: + generate-report: + name: Generate Pipeline Report + runs-on: ubuntu-latest + container: golang:1.25-alpine + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run CI Reporter + working-directory: tools/ci-reporter + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Review the pipelines in last 14 days before the dorne logs expire + run: | + GOWORK=off go run ./cmd/list-failed-commits \ + --github-token "$GITHUB_TOKEN" \ + --since 14d \ + --max-commits 9999999 diff --git a/tools/ci-reporter/cmd/list-failed-commits/main.go b/tools/ci-reporter/cmd/list-failed-commits/main.go new file mode 100644 index 00000000000..b9acc5ce166 --- /dev/null +++ b/tools/ci-reporter/cmd/list-failed-commits/main.go @@ -0,0 +1,379 @@ +// Usage examples: +// go run ./cmd/list-failed-commits --max-commits 20 +// go run ./cmd/list-failed-commits --max-commits 50 --out failed.json +// go run ./cmd/list-failed-commits --github-token $GITHUB_TOKEN --max-commits 100 +// go run ./cmd/list-failed-commits --repo owncloud/ocis --branch master --max-commits 20 +// go run ./cmd/list-failed-commits --since 7d --max-commits 50 +// go run ./cmd/list-failed-commits --since 30d --out failed.json +// go run ./cmd/list-failed-commits --since 2026-01-01 --max-commits 100 +// go run ./cmd/list-failed-commits --since 2026-02-15 --out recent.json + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/owncloud/ocis/v2/tools/ci-reporter/pkg/droneextractor" + "github.com/owncloud/ocis/v2/tools/ci-reporter/pkg/githubextractor" + "github.com/owncloud/ocis/v2/tools/ci-reporter/pkg/util" +) + +const ( + defaultRepo = "owncloud/ocis" + defaultBranch = "master" + requestDelay = 1 * time.Second + defaultLimit = 20 + defaultUnlimited = 999999999 +) + +type RunConfig struct { + Repo string + Branch string + MaxCommits int + SinceRaw string + SinceParsed string + OutFile string + Token string + Command string + FetchFailedPipelineHistory bool +} + +type FailureCount struct { + StageName string `json:"stage_name"` + StepName string `json:"step_name"` + Count int `json:"count"` +} + +type DateRange struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` +} + +type Raport struct { + Args string `json:"args"` + Repo string `json:"repo"` + Branch string `json:"branch"` + DateRange DateRange `json:"date_range"` + TotalCommits int `json:"total_commits"` + FailedCommits int `json:"failed_commits"` + Percentage float64 `json:"percentage"` +} + +type RaportMetadata struct { + Args string + Repo string + Branch string + DateRange DateRange + TotalCommits int +} + +type Report struct { + Raport *Raport `json:"raport"` + Count []FailureCount `json:"count"` + Pipelines []FailedCommit `json:"pipelines"` +} + +type FailedCommit struct { + PR int `json:"pr,omitempty"` + SHA string `json:"sha"` + Date string `json:"date"` + Subject string `json:"subject"` + HTMLURL string `json:"html_url"` + CombinedState string `json:"combined_state"` + FailedContexts []StatusContext `json:"failed_contexts"` + PipelineInfo *droneextractor.PipelineInfo `json:"pipeline_info,omitempty"` + FailedPipelineHistory []droneextractor.PipelineInfoSummary `json:"failed_pipeline_history,omitempty"` + PrPipelineHistory []droneextractor.PipelineInfoSummary `json:"pr_pipeline_history,omitempty"` +} + +type StatusContext struct { + Context string `json:"context"` + State string `json:"state"` + TargetURL string `json:"target_url"` + Description string `json:"description"` +} + +func main() { + repo := flag.String("repo", defaultRepo, "GitHub repository (owner/repo)") + branch := flag.String("branch", defaultBranch, "Branch name") + maxCommits := flag.Int("max-commits", 0, "Maximum commits to check (default: 20 if no --since, unlimited if --since set)") + since := flag.String("since", "", "Date filter: YYYY-MM-DD, RFC3339 timestamp, or 'Nd' for last N days (e.g., '7d', '30d')") + outFile := flag.String("out", "", "Output file path (default: stdout)") + githubToken := flag.String("github-token", "", "GitHub token (optional; falls back to env GITHUB_TOKEN)") + failedPipelineHistory := flag.Bool("failed_pipeline_history", false, "if set, fetch failed_pipeline_history per commit (extra Drone API calls)") + flag.Parse() + + token := strings.TrimSpace(*githubToken) + if token == "" { + token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + } + + if token != "" { + fmt.Fprintf(os.Stderr, "Using GitHub token\n") + } else { + fmt.Fprintf(os.Stderr, "No token provided, unauthenticated, 60 req/hr limit\n") + } + + command := strings.Join(os.Args[1:], " ") + command = util.RedactGitHubToken(command) + + parsedSince, err := util.ParseSinceFlag(*since) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing --since flag: %v\n", err) + os.Exit(1) + } + + // Determine final max commits based on git-like UX: + // - No flags: default to 20 + // - --since only: unlimited (all in range) + // - --max-commits only: use explicit value + // - Both: use explicit value (both filters apply) + finalMaxCommits := *maxCommits + if finalMaxCommits == 0 { + if *since != "" { + finalMaxCommits = defaultUnlimited // Unlimited when --since is used + } else { + finalMaxCommits = defaultLimit // Default for no flags + } + } + + config := RunConfig{ + Repo: *repo, + Branch: *branch, + MaxCommits: finalMaxCommits, + SinceRaw: *since, + SinceParsed: parsedSince, + OutFile: *outFile, + Token: token, + Command: command, + FetchFailedPipelineHistory: *failedPipelineHistory, + } + + if err := run(config); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run(cfg RunConfig) error { + // Configure Transport to prevent stale connection reuse during long pagination + transport := &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + DisableKeepAlives: false, + MaxIdleConnsPerHost: 2, + } + client := &http.Client{ + Timeout: 60 * time.Second, // Increased for long-running pagination + Transport: transport, + } + githubExtractor := githubextractor.NewExtractor(client, cfg.Token) + droneExtractor := droneextractor.NewExtractor(client) + + // Build scanning message + var scanMsg string + if cfg.SinceRaw != "" { + scanMsg = fmt.Sprintf("Scanning commits since %s from %s/%s", cfg.SinceRaw, cfg.Repo, cfg.Branch) + } else { + scanMsg = fmt.Sprintf("Scanning up to %d commits from %s/%s", cfg.MaxCommits, cfg.Repo, cfg.Branch) + } + fmt.Fprintf(os.Stderr, "%s\n", scanMsg) + commits, err := githubExtractor.GetCommits(cfg.Repo, cfg.Branch, cfg.MaxCommits, cfg.SinceParsed) + if err != nil { + return fmt.Errorf("fetching commits: %w", err) + } + + var failedCommits []FailedCommit + for _, commit := range commits { + status, err := githubExtractor.GetCommitStatus(cfg.Repo, commit.SHA) + if err != nil { + fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", commit.SHA[:7], err) + time.Sleep(requestDelay) + continue + } + + if status.State == "failure" || status.State == "error" { + var failedCtxs []StatusContext + var pipelineInfo *droneextractor.PipelineInfo + + for _, s := range status.Statuses { + if s.State != "success" { + // Skip Drone entries - they'll be in pipeline_info instead + if droneExtractor.IsDroneURL(s.TargetURL) { + info, err := droneExtractor.Extract(s.TargetURL) + if err == nil { + pipelineInfo = info + } + // Don't add to failed_contexts to avoid redundancy + continue + } + failedCtxs = append(failedCtxs, toStatusContext(s)) + } + } + // TODO: Refactor redundant field assignment - prPipelineHistory always assigned, failedPipelineHistory conditional + var failedPipelineHistory []droneextractor.PipelineInfoSummary + var prPipelineHistory []droneextractor.PipelineInfoSummary + var prNumber int + var headRef string + + // Get PR for this commit (master commit -> PR ID -> PR branch) + prs, err := githubExtractor.GetPRsForCommit(cfg.Repo, commit.SHA) + if err == nil && len(prs) > 0 { + prNumber = prs[0].Number + headRef = prs[0].Head.Ref + + // Get failed builds by PR branch (black box: all logic in droneextractor) + failedBuilds, err := droneExtractor.GetFailedBuildsByPRBranch(droneextractor.BaseURL, cfg.Repo, headRef) + if err == nil { + prPipelineHistory = failedBuilds + if cfg.FetchFailedPipelineHistory { + failedPipelineHistory = failedBuilds + } + } + } + + // Initialize empty slice if flag is set but no PR/builds found + if cfg.FetchFailedPipelineHistory && failedPipelineHistory == nil { + failedPipelineHistory = []droneextractor.PipelineInfoSummary{} + } + + subject := util.ExtractSubject(commit.Commit.Message) + failedCommits = append(failedCommits, FailedCommit{ + PR: prNumber, + SHA: commit.SHA, + Date: commit.Commit.Author.Date, + Subject: subject, + HTMLURL: commit.HTMLURL, + CombinedState: status.State, + FailedContexts: failedCtxs, + PipelineInfo: pipelineInfo, + FailedPipelineHistory: failedPipelineHistory, + PrPipelineHistory: prPipelineHistory, + }) + + // Single line output: ✗ sha subject -> PR #num (branch: name) + if prNumber > 0 && headRef != "" { + fmt.Fprintf(os.Stderr, "✗ %s %s -> PR #%d (branch: %s)\n", commit.SHA[:7], subject, prNumber, headRef) + } else { + fmt.Fprintf(os.Stderr, "✗ %s %s\n", commit.SHA[:7], subject) + } + } + + time.Sleep(requestDelay) + } + + fmt.Fprintf(os.Stderr, "\nResult: %d failed / %d total\n", len(failedCommits), len(commits)) + + var minDate, maxDate string + for _, c := range commits { + date := c.Commit.Author.Date + if minDate == "" || date < minDate { + minDate = date + } + if maxDate == "" || date > maxDate { + maxDate = date + } + } + + metadata := RaportMetadata{ + Args: cfg.Command, + Repo: cfg.Repo, + Branch: cfg.Branch, + DateRange: DateRange{StartDate: minDate, EndDate: maxDate}, + TotalCommits: len(commits), + } + + report := generateReport(failedCommits, metadata) + + var w io.Writer = os.Stdout + if cfg.OutFile != "" { + f, err := os.Create(cfg.OutFile) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + defer f.Close() + w = f + fmt.Fprintf(os.Stderr, "Writing JSON to %s...\n", cfg.OutFile) + } + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return fmt.Errorf("encoding JSON: %w", err) + } + + return nil +} + +func toStatusContext(ctx githubextractor.StatusContext) StatusContext { + return StatusContext{ + Context: ctx.Context, + State: ctx.State, + TargetURL: ctx.TargetURL, + Description: ctx.Description, + } +} + + +func generateReport(failedCommits []FailedCommit, metadata RaportMetadata) *Report { + countMap := make(map[string]int) + + for _, commit := range failedCommits { + if commit.PipelineInfo == nil { + continue + } + for _, stage := range commit.PipelineInfo.PipelineStages { + for _, step := range stage.Steps { + if step.Status == "failure" { + key := stage.StageName + "/" + step.StepName + countMap[key]++ + } + } + } + } + + var counts []FailureCount + for key, count := range countMap { + parts := strings.Split(key, "/") + if len(parts) >= 2 { + counts = append(counts, FailureCount{ + StageName: parts[0], + StepName: strings.Join(parts[1:], "/"), + Count: count, + }) + } + } + + sort.Slice(counts, func(i, j int) bool { + return counts[i].Count > counts[j].Count + }) + + percentage := 0.0 + if metadata.TotalCommits > 0 { + percentage = float64(len(failedCommits)) / float64(metadata.TotalCommits) * 100.0 + } + + raport := &Raport{ + Args: metadata.Args, + Repo: metadata.Repo, + Branch: metadata.Branch, + DateRange: metadata.DateRange, + TotalCommits: metadata.TotalCommits, + FailedCommits: len(failedCommits), + Percentage: percentage, + } + + return &Report{ + Raport: raport, + Count: counts, + Pipelines: failedCommits, + } +} diff --git a/tools/ci-reporter/go.mod b/tools/ci-reporter/go.mod new file mode 100644 index 00000000000..be147011136 --- /dev/null +++ b/tools/ci-reporter/go.mod @@ -0,0 +1,5 @@ +module github.com/owncloud/ocis/v2/tools/ci-reporter + +go 1.24.0 + +require golang.org/x/net v0.50.0 // indirect diff --git a/tools/ci-reporter/go.sum b/tools/ci-reporter/go.sum new file mode 100644 index 00000000000..13ce0c7b95e --- /dev/null +++ b/tools/ci-reporter/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= diff --git a/tools/ci-reporter/pkg/droneextractor/extractor.go b/tools/ci-reporter/pkg/droneextractor/extractor.go new file mode 100644 index 00000000000..0e1d12b5bc4 --- /dev/null +++ b/tools/ci-reporter/pkg/droneextractor/extractor.go @@ -0,0 +1,631 @@ +package droneextractor + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "sort" + "strings" + + "golang.org/x/net/html" +) + +const ( + // BaseURL is the default Drone instance URL + BaseURL = "https://drone.owncloud.com" +) + +type Extractor struct { + client *http.Client +} + +func NewExtractor(client *http.Client) *Extractor { + return &Extractor{client: client} +} + +// IsDroneURL checks if a URL is a Drone CI URL +func (e *Extractor) IsDroneURL(url string) bool { + return strings.Contains(url, "drone.owncloud.com") +} + +func (e *Extractor) Extract(buildURL string) (*PipelineInfo, error) { + apiURL := e.buildURLToAPIURL(buildURL) + if apiURL == "" { + return nil, fmt.Errorf("invalid build URL: %s", buildURL) + } + + buildData, err := e.fetchDroneAPI(apiURL) + if err != nil { + return nil, fmt.Errorf("fetching Drone API: %w", err) + } + + stages := e.parseStagesFromAPI(buildData, buildURL) + + var durationMinutes float64 + if buildData.Finished > 0 && buildData.Started > 0 { + durationMinutes = float64(buildData.Finished-buildData.Started) / 60.0 + } + + return &PipelineInfo{ + BuildURL: buildURL, + Started: buildData.Started, + Finished: buildData.Finished, + DurationMinutes: durationMinutes, + PipelineStages: stages, + }, nil +} + +func (e *Extractor) buildURLToAPIURL(buildURL string) string { + if !strings.Contains(buildURL, "drone.owncloud.com") { + return "" + } + parts := strings.Split(buildURL, "/") + if len(parts) < 5 { + return "" + } + repo := parts[3] + "/" + parts[4] + buildNum := parts[5] + return fmt.Sprintf("https://drone.owncloud.com/api/repos/%s/builds/%s", repo, buildNum) +} + +func (e *Extractor) fetchDroneAPI(url string) (*droneBuildResponse, error) { + resp, err := e.client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var buildData droneBuildResponse + if err := json.NewDecoder(resp.Body).Decode(&buildData); err != nil { + return nil, err + } + + return &buildData, nil +} + +func (e *Extractor) parseStagesFromAPI(buildData *droneBuildResponse, buildURL string) []PipelineStage { + var stages []PipelineStage + + for _, apiStage := range buildData.Stages { + normalizedStatus := e.normalizeStatus(apiStage.Status) + if normalizedStatus == "success" || normalizedStatus == "skipped" { + continue + } + + stage := PipelineStage{ + StageNumber: apiStage.Number, + StageName: apiStage.Name, + Status: normalizedStatus, + Steps: []PipelineStep{}, + } + + for _, apiStep := range apiStage.Steps { + normalizedStepStatus := e.normalizeStatus(apiStep.Status) + if normalizedStepStatus == "skipped" { + continue + } + stepURL := fmt.Sprintf("%s/%d/%d", buildURL, apiStage.Number, apiStep.Number) + step := PipelineStep{ + StepNumber: apiStep.Number, + StepName: apiStep.Name, + Status: normalizedStepStatus, + URL: stepURL, + } + + if normalizedStepStatus == "failure" { + logs, err := e.fetchStepLogs(stepURL, buildURL, apiStage.Number, apiStep.Number) + if err == nil { + lines := strings.Split(logs, "\n") + if len(lines) > 500 && (strings.Contains(logs, "--- Failed scenarios:") || strings.Contains(logs, "Scenario:")) { + logs = e.extractBehatFailures(logs) + } + step.Logs = logs + } + } + + stage.Steps = append(stage.Steps, step) + } + + stages = append(stages, stage) + } + + return stages +} + +func (e *Extractor) normalizeStatus(status string) string { + switch strings.ToLower(status) { + case "success", "passed": + return "success" + case "failure", "failed", "error": + return "failure" + case "running", "pending", "started": + return "running" + case "skipped": + return "skipped" + default: + return status + } +} + +// droneBuildResponse matches the structure returned by Drone API: +// GET /api/repos/{owner}/{repo}/builds/{buildNumber} +// Verified against: https://drone.owncloud.com/api/repos/owncloud/ocis/builds/51629 +type droneBuildResponse struct { + Started int64 `json:"started"` + Finished int64 `json:"finished"` + Stages []droneStage `json:"stages"` +} + +type droneStage struct { + Number int `json:"number"` // Stage number (1, 2, 3, ...) + Name string `json:"name"` // Stage name (e.g., "API-wopi", "coding-standard-php8.4") + Status string `json:"status"` // "success", "failure", "running", etc. + Steps []droneStep `json:"steps"` // Array of steps in this stage +} + +type droneStep struct { + Number int `json:"number"` // Step number within stage + Name string `json:"name"` // Step name (e.g., "clone", "test-acceptance-api") + Status string `json:"status"` // "success", "failure", "skipped", etc. +} + +type droneLogEntry struct { + Pos int `json:"pos"` + Out string `json:"out"` + Time int64 `json:"time"` +} + +type BuildInfo struct { + BuildNumber int `json:"build_number"` + Status string `json:"status"` // "success", "failure", "error" + Started int64 `json:"started"` + Finished int64 `json:"finished"` + CommitSHA string `json:"commit_sha"` +} + +type droneBuildListItem struct { + Number int `json:"number"` + Status string `json:"status"` + Event string `json:"event"` // "pull_request", "push", etc. + Source string `json:"source"` // Source branch for PR builds + Target string `json:"target"` // Target branch for PR builds + After string `json:"after"` // Commit SHA + Started int64 `json:"started"` + Finished int64 `json:"finished"` +} + +func (e *Extractor) fetchStepLogs(stepURL, buildURL string, stageNum, stepNum int) (string, error) { + apiLogsURL := e.buildLogsAPIURL(buildURL, stageNum, stepNum) + if apiLogsURL != "" { + logs, err := e.fetchLogsFromAPI(apiLogsURL) + if err == nil { + return logs, nil + } + if err != nil && !strings.Contains(err.Error(), "401") && !strings.Contains(err.Error(), "403") { + return "", err + } + } + + return e.fetchLogsFromHTML(stepURL) +} + +func (e *Extractor) buildLogsAPIURL(buildURL string, stageNum, stepNum int) string { + if !strings.Contains(buildURL, "drone.owncloud.com") { + return "" + } + parts := strings.Split(buildURL, "/") + if len(parts) < 5 { + return "" + } + repo := parts[3] + "/" + parts[4] + buildNum := parts[5] + return fmt.Sprintf("https://drone.owncloud.com/api/repos/%s/builds/%s/logs/%d/%d", repo, buildNum, stageNum, stepNum) +} + +func (e *Extractor) fetchLogsFromAPI(apiURL string) (string, error) { + resp, err := e.client.Get(apiURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var logEntries []droneLogEntry + if err := json.NewDecoder(resp.Body).Decode(&logEntries); err != nil { + return "", err + } + + var logs strings.Builder + for _, entry := range logEntries { + logs.WriteString(entry.Out) + } + + return logs.String(), nil +} + +func (e *Extractor) fetchLogsFromHTML(stepURL string) (string, error) { + resp, err := e.client.Get(stepURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return e.extractLogsFromHTML(string(body)) +} + +func (e *Extractor) extractLogsFromHTML(htmlContent string) (string, error) { + doc, err := html.Parse(strings.NewReader(htmlContent)) + if err != nil { + return "", err + } + + var logs strings.Builder + var extractText func(*html.Node) + extractText = func(n *html.Node) { + if n.Type == html.ElementNode { + if n.Data == "pre" || n.Data == "code" { + text := e.getTextContent(n) + if text != "" { + logs.WriteString(text) + logs.WriteString("\n") + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + extractText(c) + } + } + + extractText(doc) + return strings.TrimSpace(logs.String()), nil +} + +func (e *Extractor) getTextContent(n *html.Node) string { + var text strings.Builder + var collectText func(*html.Node) + collectText = func(n *html.Node) { + if n.Type == html.TextNode { + text.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + collectText(c) + } + } + collectText(n) + return text.String() +} + +func (e *Extractor) extractBehatFailures(logs string) string { + var result strings.Builder + + // ANSI red code pattern + redCodePattern := regexp.MustCompile(`\x1b\[31m|\033\[31m|\[31m`) + ansiStripPattern := regexp.MustCompile(`\x1b\[[0-9;]*m|\033\[[0-9;]*m`) + + // Split by Scenario: or Scenario Outline: (case-insensitive) + // Use regex to find all scenario starts while preserving the marker + scenarioPattern := regexp.MustCompile(`(?i)(Scenario(?:\s+Outline)?:\s*)`) + matches := scenarioPattern.FindAllStringIndex(logs, -1) + + if len(matches) == 0 { + // No scenarios found, try to extract just "--- Failed scenarios:" section + return e.extractFailedScenariosSection(logs, ansiStripPattern) + } + + // Extract each scenario section + for i, match := range matches { + start := match[0] + end := len(logs) + if i+1 < len(matches) { + end = matches[i+1][0] + } + + scenarioSection := logs[start:end] + + // Check if this section contains ANSI red codes + if redCodePattern.MatchString(scenarioSection) { + // Strip ANSI codes and add to result + cleaned := ansiStripPattern.ReplaceAllString(scenarioSection, "") + if result.Len() > 0 { + result.WriteString("\n\n") + } + result.WriteString(cleaned) + } + } + + // Extract "--- Failed scenarios:" section + failedScenariosSection := e.extractFailedScenariosSection(logs, ansiStripPattern) + if failedScenariosSection != "" { + if result.Len() > 0 { + result.WriteString("\n\n") + } + result.WriteString(failedScenariosSection) + } + + extracted := strings.TrimSpace(result.String()) + if extracted == "" { + // If extraction failed, return original logs + return logs + } + + return extracted +} + +func (e *Extractor) extractFailedScenariosSection(logs string, ansiStripPattern *regexp.Regexp) string { + failedScenariosMarker := "--- Failed scenarios:" + if idx := strings.Index(logs, failedScenariosMarker); idx != -1 { + failedScenariosSection := logs[idx:] + cleaned := ansiStripPattern.ReplaceAllString(failedScenariosSection, "") + return cleaned + } + return "" +} + +// GetBuildsByCommitSHA queries Drone API for all builds matching a specific commit SHA +func (e *Extractor) GetBuildsByCommitSHA(baseURL, repo, commitSHA string) ([]BuildInfo, error) { + var allBuilds []BuildInfo + page := 1 + maxPages := 200 // Safety limit (25 items per page = 5000 builds max) + + for page <= maxPages { + url := fmt.Sprintf("%s/api/repos/%s/builds?page=%d", baseURL, repo, page) + resp, err := e.client.Get(url) + if err != nil { + return nil, fmt.Errorf("fetching build list: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var buildList []droneBuildListItem + if err := json.NewDecoder(resp.Body).Decode(&buildList); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("decoding build list: %w", err) + } + resp.Body.Close() + + // Stop when we get an empty page + if len(buildList) == 0 { + break + } + + // Filter builds by commit SHA + for _, item := range buildList { + if item.After == commitSHA { + allBuilds = append(allBuilds, BuildInfo{ + BuildNumber: item.Number, + Status: item.Status, + Started: item.Started, + Finished: item.Finished, + CommitSHA: item.After, + }) + } + } + + page++ + } + + // Sort by build number (ascending = chronological) + sort.Slice(allBuilds, func(i, j int) bool { + return allBuilds[i].BuildNumber < allBuilds[j].BuildNumber + }) + + return allBuilds, nil +} + +// GetBuildsByPRBranch queries Drone API for all builds matching PR event and source branch +func (e *Extractor) GetBuildsByPRBranch(baseURL, repo, sourceBranch string) ([]BuildInfo, error) { + var allBuilds []BuildInfo + page := 1 + maxPages := 200 // Safety limit (25 items per page = 5000 builds max) + + for page <= maxPages { + url := fmt.Sprintf("%s/api/repos/%s/builds?page=%d", baseURL, repo, page) + resp, err := e.client.Get(url) + if err != nil { + return nil, fmt.Errorf("fetching build list: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var buildList []droneBuildListItem + if err := json.NewDecoder(resp.Body).Decode(&buildList); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("decoding build list: %w", err) + } + resp.Body.Close() + + // Stop when we get an empty page + if len(buildList) == 0 { + break + } + + // Filter builds by event="pull_request" and source branch + for _, item := range buildList { + if item.Event == "pull_request" && item.Source == sourceBranch { + allBuilds = append(allBuilds, BuildInfo{ + BuildNumber: item.Number, + Status: item.Status, + Started: item.Started, + Finished: item.Finished, + CommitSHA: item.After, + }) + } + } + + page++ + } + + // Sort by build number (ascending = chronological) + sort.Slice(allBuilds, func(i, j int) bool { + return allBuilds[i].BuildNumber < allBuilds[j].BuildNumber + }) + + return allBuilds, nil +} + +// GetBuildsByPushBranch queries Drone API for all builds matching push/cron events to a specific branch (e.g., master). +// This is specifically for merged commits to main branches, not PR builds. +// It includes both "push" events (actual merges) and "cron" events (scheduled/nightly builds). +func (e *Extractor) GetBuildsByPushBranch(baseURL, repo, targetBranch string) ([]BuildInfo, error) { + var allBuilds []BuildInfo + page := 1 + maxPages := 200 // Safety limit (25 items per page = 5000 builds max) + + for page <= maxPages { + url := fmt.Sprintf("%s/api/repos/%s/builds?page=%d", baseURL, repo, page) + resp, err := e.client.Get(url) + if err != nil { + return nil, fmt.Errorf("fetching build list: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var buildList []droneBuildListItem + if err := json.NewDecoder(resp.Body).Decode(&buildList); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("decoding build list: %w", err) + } + resp.Body.Close() + + // Stop when we get an empty page + if len(buildList) == 0 { + break + } + + // Filter builds by event="push" or event="cron" and target branch + // "push" = actual merges to branch + // "cron" = scheduled/nightly builds running on the branch + for _, item := range buildList { + if (item.Event == "push" || item.Event == "cron") && item.Target == targetBranch { + allBuilds = append(allBuilds, BuildInfo{ + BuildNumber: item.Number, + Status: item.Status, + Started: item.Started, + Finished: item.Finished, + CommitSHA: item.After, + }) + } + } + + page++ + } + + // Sort by build number (ascending = chronological) + sort.Slice(allBuilds, func(i, j int) bool { + return allBuilds[i].BuildNumber < allBuilds[j].BuildNumber + }) + + return allBuilds, nil +} + +// extractBaseURL extracts the base URL (e.g., "https://drone.owncloud.com") from a build URL +func extractBaseURL(buildURL string) string { + if !strings.Contains(buildURL, "://") { + return "" + } + parts := strings.Split(buildURL, "/") + if len(parts) >= 3 { + return parts[0] + "//" + parts[2] + } + return "" +} + +// BuildInfoToPipelineSummary converts a BuildInfo to PipelineInfoSummary +func BuildInfoToPipelineSummary(build BuildInfo, baseURL, repo string) PipelineInfoSummary { + var durationMinutes float64 + if build.Finished > 0 && build.Started > 0 { + durationMinutes = float64(build.Finished-build.Started) / 60.0 + } + + // URL format: https://drone.owncloud.com/owncloud/ocis/{buildNumber} + // repo format: "owncloud/ocis" + buildURL := fmt.Sprintf("%s/%s/%d", baseURL, repo, build.BuildNumber) + + return PipelineInfoSummary{ + BuildURL: buildURL, + Started: build.Started, + Finished: build.Finished, + DurationMinutes: durationMinutes, + Status: build.Status, + } +} + +// BuildInfoToPipelineSummaryWithStages converts a BuildInfo to PipelineInfoSummary with failed stages +// by fetching full pipeline details from the build URL +func (e *Extractor) BuildInfoToPipelineSummaryWithStages(build BuildInfo, baseURL, repo string) PipelineInfoSummary { + summary := BuildInfoToPipelineSummary(build, baseURL, repo) + + // Fetch full pipeline info to extract failed stages + pipelineInfo, err := e.Extract(summary.BuildURL) + if err == nil && pipelineInfo != nil { + var failedStages []FailedStage + for _, stage := range pipelineInfo.PipelineStages { + if stage.Status == "failure" || stage.Status == "error" { + failedStages = append(failedStages, FailedStage{ + StageNumber: stage.StageNumber, + StageName: stage.StageName, + Status: stage.Status, + }) + } + } + summary.FailedStages = failedStages + } + + return summary +} + +// IsFailedBuild checks if a build status indicates failure +func IsFailedBuild(status string) bool { + normalized := strings.ToLower(status) + return normalized == "failure" || normalized == "failed" || normalized == "error" +} + +// GetFailedBuildsByPRBranch returns failed builds for a PR branch with pipeline summaries +func (e *Extractor) GetFailedBuildsByPRBranch(baseURL, repo, sourceBranch string) ([]PipelineInfoSummary, error) { + builds, err := e.GetBuildsByPRBranch(baseURL, repo, sourceBranch) + if err != nil { + return nil, err + } + + var failedSummaries []PipelineInfoSummary + for _, build := range builds { + if IsFailedBuild(build.Status) { + summary := e.BuildInfoToPipelineSummaryWithStages(build, baseURL, repo) + failedSummaries = append(failedSummaries, summary) + // Rate limiting delay for API calls in BuildInfoToPipelineSummaryWithStages + } + } + + return failedSummaries, nil +} diff --git a/tools/ci-reporter/pkg/droneextractor/pr_builds.go b/tools/ci-reporter/pkg/droneextractor/pr_builds.go new file mode 100644 index 00000000000..d052cdc9f42 --- /dev/null +++ b/tools/ci-reporter/pkg/droneextractor/pr_builds.go @@ -0,0 +1,78 @@ +package droneextractor + +import ( + "fmt" + "io" + "time" + + "github.com/owncloud/ocis/v2/tools/ci-reporter/pkg/githubextractor" +) + +const requestDelay = 1 * time.Second + +// FetchPRBuilds fetches all Drone builds for a PR by querying: +// 1. Builds by PR branch (handles force-pushes) +// 2. Builds by individual commit SHAs (fallback for merged/deleted branches) +// +// Returns a map of commit SHA -> builds +func FetchPRBuilds( + extractor *Extractor, + baseURL string, + repo string, + prBranch string, + commits []githubextractor.PRCommit, + logWriter io.Writer, +) (map[string][]BuildInfo, error) { + buildsByCommit := make(map[string][]BuildInfo) + + // Query builds by PR branch (handles force-pushes) + if logWriter != nil { + fmt.Fprintf(logWriter, "Fetching Drone builds for PR (by event and branch)...\n") + } + prBuilds, err := extractor.GetBuildsByPRBranch(baseURL, repo, prBranch) + if err != nil { + if logWriter != nil { + fmt.Fprintf(logWriter, "⚠ Failed to get builds by branch: %v\n", err) + } + } else { + if logWriter != nil { + fmt.Fprintf(logWriter, "Found %d builds for PR branch %s\n", len(prBuilds), prBranch) + } + // Group builds by commit SHA + for _, build := range prBuilds { + buildsByCommit[build.CommitSHA] = append(buildsByCommit[build.CommitSHA], build) + } + } + + // Also query by current commit SHAs (for completeness) + if logWriter != nil { + fmt.Fprintf(logWriter, "Fetching Drone builds for current commits...\n") + } + for i, commit := range commits { + if _, exists := buildsByCommit[commit.SHA]; exists { + if logWriter != nil { + fmt.Fprintf(logWriter, " [%d/%d] %s: already found via PR query\n", i+1, len(commits), commit.SHA[:7]) + } + continue + } + if logWriter != nil { + fmt.Fprintf(logWriter, " [%d/%d] Fetching builds for %s...\n", i+1, len(commits), commit.SHA[:7]) + } + builds, err := extractor.GetBuildsByCommitSHA(baseURL, repo, commit.SHA) + if err != nil { + if logWriter != nil { + fmt.Fprintf(logWriter, " ⚠ Failed to get builds: %v\n", err) + } + continue + } + if len(builds) > 0 { + buildsByCommit[commit.SHA] = append(buildsByCommit[commit.SHA], builds...) + if logWriter != nil { + fmt.Fprintf(logWriter, " Found %d builds\n", len(builds)) + } + } + time.Sleep(requestDelay) + } + + return buildsByCommit, nil +} diff --git a/tools/ci-reporter/pkg/droneextractor/types.go b/tools/ci-reporter/pkg/droneextractor/types.go new file mode 100644 index 00000000000..b1895ea8bb9 --- /dev/null +++ b/tools/ci-reporter/pkg/droneextractor/types.go @@ -0,0 +1,39 @@ +package droneextractor + +type PipelineInfo struct { + BuildURL string `json:"build_url"` + Started int64 `json:"started,omitempty"` // Unix timestamp + Finished int64 `json:"finished,omitempty"` // Unix timestamp + DurationMinutes float64 `json:"duration_minutes,omitempty"` // Calculated: (finished - started) / 60 + PipelineStages []PipelineStage `json:"pipeline_stages"` +} + +type PipelineStage struct { + StageNumber int `json:"stage_number"` + StageName string `json:"stage_name,omitempty"` + Status string `json:"status,omitempty"` + Steps []PipelineStep `json:"steps"` +} + +type PipelineStep struct { + StepNumber int `json:"step_number"` + StepName string `json:"step_name"` + Status string `json:"status"` // "success", "failure", "error", "running", etc. + URL string `json:"url,omitempty"` + Logs string `json:"logs,omitempty"` // Console logs content +} + +type PipelineInfoSummary struct { + BuildURL string `json:"build_url"` + Started int64 `json:"started,omitempty"` + Finished int64 `json:"finished,omitempty"` + DurationMinutes float64 `json:"duration_minutes,omitempty"` + Status string `json:"status,omitempty"` // Overall build status + FailedStages []FailedStage `json:"failed_stages,omitempty"` +} + +type FailedStage struct { + StageNumber int `json:"stage_number"` + StageName string `json:"stage_name"` + Status string `json:"status"` +} diff --git a/tools/ci-reporter/pkg/githubextractor/extractor.go b/tools/ci-reporter/pkg/githubextractor/extractor.go new file mode 100644 index 00000000000..b0f28b20604 --- /dev/null +++ b/tools/ci-reporter/pkg/githubextractor/extractor.go @@ -0,0 +1,308 @@ +package githubextractor + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + apiBase = "https://api.github.com" + requestDelay = 1 * time.Second + retryDelay = 2 * time.Second + maxRetries = 3 +) + +type Extractor struct { + client *http.Client + token string +} + +func NewExtractor(client *http.Client, token string) *Extractor { + return &Extractor{ + client: client, + token: token, + } +} + +type PRInfo struct { + Number int `json:"number"` + Head struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + } `json:"head"` + Base struct { + Ref string `json:"ref"` + } `json:"base"` +} + +type PRCommit struct { + SHA string `json:"sha"` + Commit struct { + Author struct { + Date string `json:"date"` + } `json:"author"` + Message string `json:"message"` + } `json:"commit"` +} + +type CommitListItem struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url"` + Commit struct { + Author struct { + Date string `json:"date"` + } `json:"author"` + Message string `json:"message"` + } `json:"commit"` +} + +type CombinedStatus struct { + State string `json:"state"` + Statuses []StatusContext `json:"statuses"` +} + +type StatusContext struct { + Context string `json:"context"` + State string `json:"state"` + TargetURL string `json:"target_url"` + Description string `json:"description"` +} + +func (e *Extractor) GetPRInfo(repo string, prNumber int) (*PRInfo, error) { + url := fmt.Sprintf("%s/repos/%s/pulls/%d", apiBase, repo, prNumber) + var prInfo PRInfo + return &prInfo, e.get(url, &prInfo) +} + +func (e *Extractor) GetPRCommits(repo string, prNumber int) ([]PRCommit, error) { + url := fmt.Sprintf("%s/repos/%s/pulls/%d/commits", apiBase, repo, prNumber) + var commits []PRCommit + return commits, e.get(url, &commits) +} + +func (e *Extractor) GetCommits(repo, branch string, maxCommits int, since string) ([]CommitListItem, error) { + var commits []CommitListItem + page := 1 + + for len(commits) < maxCommits { + url := fmt.Sprintf("%s/repos/%s/commits?sha=%s&per_page=100&page=%d", apiBase, repo, branch, page) + if since != "" { + url += "&since=" + since + } + + resp, err := e.request(url) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + var pageCommits []CommitListItem + if err := json.NewDecoder(resp.Body).Decode(&pageCommits); err != nil { + resp.Body.Close() + return nil, err + } + resp.Body.Close() + + if len(pageCommits) == 0 { + break + } + + commits = append(commits, pageCommits...) + if len(commits) >= maxCommits { + commits = commits[:maxCommits] + break + } + + page++ + time.Sleep(requestDelay) + } + + return commits, nil +} + +func (e *Extractor) GetCommitStatus(repo, sha string) (*CombinedStatus, error) { + url := fmt.Sprintf("%s/repos/%s/commits/%s/status", apiBase, repo, sha) + var status CombinedStatus + return &status, e.get(url, &status) +} + +// GetPRsForCommit returns PRs associated with a commit (e.g. merged PR that introduced the commit). +// See https://docs.github.com/en/rest/commits/commits#list-pull-requests-associated-with-a-commit +func (e *Extractor) GetPRsForCommit(repo, commitSHA string) ([]PRInfo, error) { + url := fmt.Sprintf("%s/repos/%s/commits/%s/pulls", apiBase, repo, commitSHA) + var prs []PRInfo + return prs, e.get(url, &prs) +} + +func isRetryableNetworkError(err error) bool { + if err == nil { + return false + } + s := strings.ToLower(err.Error()) + return strings.Contains(s, "broken pipe") || + strings.Contains(s, "connection reset") || + strings.Contains(s, "connection refused") || + strings.Contains(s, "EOF") +} + +// get makes an HTTP GET request, decodes JSON response, and handles rate limiting +func (e *Extractor) get(url string, result any) error { + resp, err := e.request(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return err + } + + time.Sleep(requestDelay) + return nil +} + +func (e *Extractor) request(url string) (*http.Response, error) { + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + if e.token != "" { + req.Header.Set("Authorization", "Bearer "+e.token) + } + resp, err := e.client.Do(req) + if err != nil { + lastErr = err + if isRetryableNetworkError(err) && attempt < maxRetries-1 { + time.Sleep(retryDelay) + continue + } + return nil, err + } + return resp, nil + } + return nil, lastErr +} + +// Pipeline represents a single Drone pipeline run +type Pipeline struct { + ID int `json:"id"` + Status string `json:"status"` + Started int64 `json:"started"` + Finished int64 `json:"finished"` + Duration float64 `json:"duration_minutes"` + Trigger string `json:"trigger"` +} + +// OriginalCommit represents an individual commit from the PR branch before squashing +type OriginalCommit struct { + SHA string `json:"sha"` + Title string `json:"title"` + Pipelines []Pipeline `json:"pipelines,omitempty"` +} + +// ExtractedCommit represents a commit with PR information +type ExtractedCommit struct { + PR int `json:"pr"` + SHA string `json:"sha"` + Date string `json:"date"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + Pipelines []Pipeline `json:"pipelines,omitempty"` + OriginalCommits []OriginalCommit `json:"original_commits,omitempty"` +} + +// ExtractedData represents the output structure +type ExtractedData struct { + Commits []ExtractedCommit `json:"commits"` +} + +// extractSubject extracts the first line of the commit message +func extractSubject(message string) string { + if idx := strings.Index(message, "\n"); idx >= 0 { + return strings.TrimSpace(message[:idx]) + } + return strings.TrimSpace(message) +} + +// ExtractCommitData fetches commits and extracts them into structured format +func (e *Extractor) ExtractCommitData(repo, branch string, maxCommits int, since string) (*ExtractedData, error) { + commits, err := e.GetCommits(repo, branch, maxCommits, since) + if err != nil { + return nil, fmt.Errorf("fetching commits: %w", err) + } + + extractedCommits := make([]ExtractedCommit, 0, len(commits)) + originalCommitSHAs := make(map[string]bool) // Track SHAs that appear as original commits + + for _, commit := range commits { + title := extractSubject(commit.Commit.Message) + + // Fetch PR information from GitHub API + prNumber := 0 + var originalCommits []OriginalCommit + prs, err := e.GetPRsForCommit(repo, commit.SHA) + if err == nil && len(prs) > 0 { + // Use the first PR number if multiple PRs are associated + prNumber = prs[0].Number + + // Fetch original commits from the PR before squashing + prCommits, err := e.GetPRCommits(repo, prNumber) + if err == nil && len(prCommits) > 0 { + originalCommits = make([]OriginalCommit, 0, len(prCommits)) + for _, prCommit := range prCommits { + // Skip if this is the same commit (don't self-reference) + if prCommit.SHA == commit.SHA { + continue + } + originalCommits = append(originalCommits, OriginalCommit{ + SHA: prCommit.SHA, + Title: extractSubject(prCommit.Commit.Message), + }) + // Track this SHA as an original commit + originalCommitSHAs[prCommit.SHA] = true + } + } + } + + extractedCommits = append(extractedCommits, ExtractedCommit{ + PR: prNumber, + SHA: commit.SHA, + Date: commit.Commit.Author.Date, + Title: title, + HTMLURL: commit.HTMLURL, + OriginalCommits: originalCommits, + }) + } + + // Filter out commits that are already listed as original commits in merge commits + // These are PR commits that appear in master history but should only show under the merge commit + filteredCommits := make([]ExtractedCommit, 0, len(extractedCommits)) + for _, commit := range extractedCommits { + // Keep the commit ONLY if it's NOT in the originalCommitSHAs set + // If a commit appears in any other commit's original_commits, it should not be a top-level entry + if !originalCommitSHAs[commit.SHA] { + filteredCommits = append(filteredCommits, commit) + } + } + + return &ExtractedData{ + Commits: filteredCommits, + }, nil +} diff --git a/tools/ci-reporter/pkg/util/util.go b/tools/ci-reporter/pkg/util/util.go new file mode 100644 index 00000000000..1b677c9925f --- /dev/null +++ b/tools/ci-reporter/pkg/util/util.go @@ -0,0 +1,70 @@ +package util + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// ParseSinceFlag parses a date/time string in multiple formats: +// - YYYY-MM-DD: absolute date (e.g., "2026-02-15") +// - RFC3339: timestamp (e.g., "2026-02-15T10:30:00Z") +// - Nd: relative days (e.g., "7d", "30d") +// +// Returns the parsed time in RFC3339 format. +func ParseSinceFlag(since string) (string, error) { + if since == "" { + return "", nil + } + + // Try parsing as YYYY-MM-DD + if t, err := time.Parse("2006-01-02", since); err == nil { + return t.Format(time.RFC3339), nil + } + + // Try parsing as RFC3339 (backward compatibility) + if t, err := time.Parse(time.RFC3339, since); err == nil { + return t.Format(time.RFC3339), nil + } + + // Try parsing as relative days format (e.g., "7d", "30d") + if strings.HasSuffix(since, "d") { + daysStr := strings.TrimSuffix(since, "d") + days, err := time.ParseDuration(daysStr + "h") + if err == nil { + // Convert days to hours (Nd = N*24h) + hours := days.Hours() + t := time.Now().Add(-time.Duration(hours*24) * time.Hour) + return t.Format(time.RFC3339), nil + } + // Try parsing as integer + var numDays int + if _, err := fmt.Sscanf(daysStr, "%d", &numDays); err == nil { + t := time.Now().AddDate(0, 0, -numDays) + return t.Format(time.RFC3339), nil + } + } + + return "", fmt.Errorf("invalid format: use YYYY-MM-DD, RFC3339, or 'Nd' (e.g., '7d')") +} + +// ExtractSubject extracts the first line (subject) from a commit message. +func ExtractSubject(message string) string { + lines := strings.Split(message, "\n") + return strings.TrimSpace(lines[0]) +} + +// RedactGitHubToken redacts GitHub tokens from command strings for safe logging. +// Matches --github-token= or --github-token patterns. +func RedactGitHubToken(command string) string { + // Match --github-token= + pattern1 := regexp.MustCompile(`--github-token=[^\s]+`) + command = pattern1.ReplaceAllString(command, "--github-token=***") + + // Match --github-token + pattern2 := regexp.MustCompile(`--github-token\s+[^\s]+`) + command = pattern2.ReplaceAllString(command, "--github-token ***") + + return command +}