From d50c9f75dc1187e0a1be550b88e36bcfdc697680 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Thu, 30 Jul 2026 08:57:11 +0700 Subject: [PATCH] feat(pulls): add CI/CD pipeline management commands Implement comprehensive CI/CD integration for tea CLI including: - tea pulls status: Show detailed CI/CD pipeline status with watch mode - tea pulls wait: Wait for CI/CD checks to complete with timeout - tea pulls checks: List all checks with filtering and format options - tea pulls trigger: Trigger or re-trigger CI/CD builds - tea pulls retry: Retry failed CI/CD checks - tea pulls cicd: Parent command for all CI/CD operations - tea pulls cicd pipeline: Pipeline management with logs/summary/timeline This implementation provides CI/CD visibility and control directly from the tea CLI, enabling developers to monitor builds, trigger re-runs, and wait for check completion without leaving the terminal. --- cmd/pulls.go | 1 + cmd/pulls/cicd.go | 319 +++++++++++++++++++++++++++++++ cmd/pulls/status.go | 438 +++++++++++++++++++++++++++++++++++++++++++ cmd/pulls/trigger.go | 221 ++++++++++++++++++++++ 4 files changed, 979 insertions(+) create mode 100644 cmd/pulls/cicd.go create mode 100644 cmd/pulls/status.go create mode 100644 cmd/pulls/trigger.go diff --git a/cmd/pulls.go b/cmd/pulls.go index 4f732c05..4017d70f 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -82,6 +82,7 @@ var CmdPulls = cli.Command{ &pulls.CmdPullsReviewComments, &pulls.CmdPullsResolve, &pulls.CmdPullsUnresolve, + &pulls.CmdPullsCICD, }, } diff --git a/cmd/pulls/cicd.go b/cmd/pulls/cicd.go new file mode 100644 index 00000000..a5dd33b4 --- /dev/null +++ b/cmd/pulls/cicd.go @@ -0,0 +1,319 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "fmt" + "strings" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" +) + +// CmdPullsCICD represents the parent command for CI/CD operations +var CmdPullsCICD = cli.Command{ + Name: "cicd", + Aliases: []string{"ci"}, + Usage: "CI/CD operations for pull requests", + Description: `Comprehensive CI/CD management for pull requests including status monitoring, build triggering, and pipeline management`, + Commands: []*cli.Command{ + &CmdPullsStatus, + &CmdPullsWait, + &CmdPullsChecks, + &CmdPullsTrigger, + &CmdPullsRetry, + &CmdPullsPipeline, + }, +} + +// CmdPullsPipeline represents a sub command for pipeline management +var CmdPullsPipeline = cli.Command{ + Name: "pipeline", + Aliases: []string{"pipe"}, + Usage: "Manage CI/CD pipeline for pull request", + Description: `Comprehensive pipeline management including status overview, logs, and control`, + ArgsUsage: "", + Action: RunPullsPipeline, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "logs", + Usage: "Show pipeline logs (if available)", + }, + &cli.BoolFlag{ + Name: "summary", + Usage: "Show pipeline summary", + }, + &cli.StringFlag{ + Name: "filter", + Usage: "Filter pipeline stages: build, test, deploy, all", + Value: "all", + }, + &cli.BoolFlag{ + Name: "timeline", + Usage: "Show pipeline execution timeline", + }, + }, +} + +// RunPullsPipeline manages CI/CD pipeline for a PR +func RunPullsPipeline(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if cmd.Args().Len() != 1 { + return fmt.Errorf("must specify pull request index") + } + + idx, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return err + } + + client := ctx.Login.Client() + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("could not get pull request: %w", err) + } + + return managePipeline(requestCtx, client, ctx, pr, cmd.Bool("logs"), cmd.Bool("summary"), cmd.String("filter"), cmd.Bool("timeline")) +} + +// managePipeline provides comprehensive pipeline management +func managePipeline(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, pr *gitea.PullRequest, showLogs, showSummary bool, filter string, showTimeline bool) error { + fmt.Printf("CI/CD Pipeline for PR #%d: %s\n", pr.Index, pr.Title) + fmt.Printf("Branch: %s -> %s\n", pr.Head.Ref, pr.Base.Ref) + fmt.Printf("Commit: %s\n", pr.Head.Sha[:8]) + fmt.Println(strings.Repeat("=", 60)) + + combinedStatus, _, err := client.Repositories.GetCombinedStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha) + if err != nil { + return fmt.Errorf("could not get combined status: %w", err) + } + + statuses, _, err := client.Repositories.ListStatuses(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha, gitea.ListStatusesOption{ + ListOptions: gitea.ListOptions{Page: -1}, + }) + if err != nil { + return fmt.Errorf("could not get statuses: %w", err) + } + + if showSummary { + return showPipelineSummary(combinedStatus, statuses, filter) + } + + if showTimeline { + return showPipelineTimeline(statuses, filter) + } + + if showLogs { + return showPipelineLogs(statuses, filter) + } + + return showPipelineOverview(combinedStatus, statuses, filter) +} + +// showPipelineSummary displays a summary of the pipeline +func showPipelineSummary(combinedStatus *gitea.CombinedStatus, statuses []*gitea.Status, filter string) error { + fmt.Println("\nPipeline Summary") + fmt.Println("==================") + + icon := getStatusIcon(combinedStatus.State) + fmt.Printf("Overall Status: %s %s\n", icon, strings.ToUpper(string(combinedStatus.State))) + fmt.Printf("Total Checks: %d\n\n", combinedStatus.TotalCount) + + statusCount := make(map[gitea.StatusState]int) + for _, status := range statuses { + if shouldIncludeInFilter(status.Context, filter) { + statusCount[status.State]++ + } + } + + fmt.Println("Check Status Breakdown:") + for state, count := range statusCount { + if count > 0 { + fmt.Printf(" %s %s: %d\n", getStatusIcon(state), strings.ToUpper(string(state)), count) + } + } + + if filter != "all" { + fmt.Printf("\nFiltered by: %s\n", filter) + } + + return nil +} + +// showPipelineTimeline displays execution timeline +func showPipelineTimeline(statuses []*gitea.Status, filter string) error { + fmt.Println("\nPipeline Timeline") + fmt.Println("===================") + + if len(statuses) == 0 { + fmt.Println("No checks found") + return nil + } + + for _, status := range statuses { + if !shouldIncludeInFilter(status.Context, filter) { + continue + } + + duration := status.Updated.Sub(status.Created).Round(time.Second) + icon := getStatusIcon(status.State) + + fmt.Printf("%s %s [%s] %s -> %s (%s)\n", + icon, + status.Context, + strings.ToUpper(string(status.State)), + status.Created.Format("15:04:05"), + status.Updated.Format("15:04:05"), + duration, + ) + + if status.Description != "" { + fmt.Printf(" %s\n", status.Description) + } + } + + return nil +} + +// showPipelineLogs attempts to show pipeline logs +func showPipelineLogs(statuses []*gitea.Status, filter string) error { + fmt.Println("\nPipeline Logs") + fmt.Println("===============") + + fmt.Println("Log access is limited in current Gitea API") + fmt.Println("Available information per check:") + fmt.Println() + + for _, status := range statuses { + if !shouldIncludeInFilter(status.Context, filter) { + continue + } + + icon := getStatusIcon(status.State) + fmt.Printf("%s %s\n", icon, status.Context) + fmt.Printf(" Status: %s\n", strings.ToUpper(string(status.State))) + fmt.Printf(" Description: %s\n", status.Description) + + if status.TargetURL != "" { + fmt.Printf(" Details: %s\n", status.TargetURL) + } + + fmt.Printf(" Created: %s\n", status.Created.Format("2006-01-02 15:04:05")) + fmt.Printf(" Updated: %s\n", status.Updated.Format("2006-01-02 15:04:05")) + fmt.Println() + } + + return nil +} + +// showPipelineOverview displays comprehensive pipeline overview +func showPipelineOverview(combinedStatus *gitea.CombinedStatus, statuses []*gitea.Status, filter string) error { + fmt.Println("\nPipeline Overview") + fmt.Println("===================") + + icon := getStatusIcon(combinedStatus.State) + fmt.Printf("Overall Status: %s %s\n", icon, strings.ToUpper(string(combinedStatus.State))) + fmt.Printf("Total Checks: %d\n", combinedStatus.TotalCount) + + if len(statuses) == 0 { + fmt.Println("No individual checks found") + return nil + } + + stages := groupChecksByStage(statuses, filter) + + for stageName, stageStatuses := range stages { + fmt.Printf("\n%s Stage\n", strings.Title(stageName)) + fmt.Println(strings.Repeat("-", len(stageName)+7)) + + for _, status := range stageStatuses { + icon := getStatusIcon(status.State) + duration := status.Updated.Sub(status.Created).Round(time.Second) + + fmt.Printf(" %s %-25s %s", icon, status.Context, strings.ToUpper(string(status.State))) + + if duration > 0 { + fmt.Printf(" (%s)", duration) + } + fmt.Println() + + if status.Description != "" { + fmt.Printf(" %s\n", status.Description) + } + } + } + + return nil +} + +// groupChecksByStage groups checks by their stage/category +func groupChecksByStage(statuses []*gitea.Status, filter string) map[string][]*gitea.Status { + stages := make(map[string][]*gitea.Status) + + for _, status := range statuses { + if !shouldIncludeInFilter(status.Context, filter) { + continue + } + + stage := categorizeCheck(status.Context) + stages[stage] = append(stages[stage], status) + } + + return stages +} + +// categorizeCheck determines the stage/category of a check based on its context +func categorizeCheck(context string) string { + lower := strings.ToLower(context) + + if strings.Contains(lower, "build") || strings.Contains(lower, "compile") { + return "build" + } + if strings.Contains(lower, "test") || strings.Contains(lower, "spec") || strings.Contains(lower, "unit") { + return "test" + } + if strings.Contains(lower, "lint") || strings.Contains(lower, "format") || strings.Contains(lower, "style") { + return "quality" + } + if strings.Contains(lower, "security") || strings.Contains(lower, "scan") || strings.Contains(lower, "vulner") { + return "security" + } + if strings.Contains(lower, "deploy") || strings.Contains(lower, "publish") || strings.Contains(lower, "release") { + return "deploy" + } + + return "other" +} + +// shouldIncludeInFilter determines if a check should be included based on the filter +func shouldIncludeInFilter(context, filter string) bool { + if filter == "all" { + return true + } + + lower := strings.ToLower(context) + filterLower := strings.ToLower(filter) + + switch filterLower { + case "build": + return strings.Contains(lower, "build") || strings.Contains(lower, "compile") + case "test": + return strings.Contains(lower, "test") || strings.Contains(lower, "spec") || strings.Contains(lower, "unit") + case "deploy": + return strings.Contains(lower, "deploy") || strings.Contains(lower, "publish") || strings.Contains(lower, "release") + default: + return strings.Contains(lower, filterLower) + } +} diff --git a/cmd/pulls/status.go b/cmd/pulls/status.go new file mode 100644 index 00000000..1930662c --- /dev/null +++ b/cmd/pulls/status.go @@ -0,0 +1,438 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" + "gopkg.in/yaml.v3" +) + +// CmdPullsStatus represents a sub command to show PR CI/CD status +var CmdPullsStatus = cli.Command{ + Name: "status", + Usage: "Show CI/CD status for pull request", + Description: `Show detailed CI/CD pipeline status for a pull request`, + ArgsUsage: "", + Action: RunPullsStatus, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "watch", + Usage: "Watch status changes in real-time", + }, + &cli.IntFlag{ + Name: "timeout", + Usage: "Timeout for watching in seconds", + Value: 300, // 5 minutes default + }, + &cli.BoolFlag{ + Name: "verbose", + Usage: "Show detailed status information", + }, + &cli.BoolFlag{ + Name: "failing-only", + Usage: "Show only failing checks", + }, + }, +} + +// CmdPullsWait represents a sub command to wait for PR checks to complete +var CmdPullsWait = cli.Command{ + Name: "wait", + Usage: "Wait for CI/CD checks to complete", + Description: `Wait for all CI/CD checks to complete for a pull request`, + ArgsUsage: "", + Action: RunPullsWait, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "timeout", + Usage: "Timeout for waiting in seconds", + Value: 1800, // 30 minutes default + }, + &cli.IntFlag{ + Name: "interval", + Usage: "Check interval in seconds", + Value: 30, + }, + &cli.BoolFlag{ + Name: "fail-fast", + Usage: "Exit immediately on first failure", + }, + &cli.BoolFlag{ + Name: "verbose", + Usage: "Show detailed progress information", + }, + }, +} + +// CmdPullsChecks represents a sub command to list all checks for a PR +var CmdPullsChecks = cli.Command{ + Name: "checks", + Usage: "List all CI/CD checks for pull request", + Description: `List all CI/CD checks and their status for a pull request`, + ArgsUsage: "", + Action: RunPullsChecks, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "state", + Usage: "Filter by check state: pending, success, error, failure, warning", + Value: "all", + }, + &cli.BoolFlag{ + Name: "show-urls", + Usage: "Show target URLs for checks", + }, + &cli.StringFlag{ + Name: "format", + Usage: "Output format: table, json, yaml", + Value: "table", + }, + }, +} + +// RunPullsStatus shows CI/CD status for a PR +func RunPullsStatus(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if cmd.Args().Len() != 1 { + return fmt.Errorf("must specify pull request index") + } + + idx, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return err + } + + client := ctx.Login.Client() + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("could not get pull request: %w", err) + } + + if cmd.Bool("watch") { + return watchPullStatus(requestCtx, client, ctx, pr, cmd.Int("timeout"), cmd.Bool("verbose")) + } + + return showPullStatus(requestCtx, client, ctx, pr, cmd.Bool("verbose"), cmd.Bool("failing-only")) +} + +// RunPullsWait waits for PR checks to complete +func RunPullsWait(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if cmd.Args().Len() != 1 { + return fmt.Errorf("must specify pull request index") + } + + idx, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return err + } + + client := ctx.Login.Client() + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("could not get pull request: %w", err) + } + + timeout := time.Duration(cmd.Int("timeout")) * time.Second + interval := time.Duration(cmd.Int("interval")) * time.Second + + return waitForChecks(requestCtx, client, ctx, pr, timeout, interval, cmd.Bool("fail-fast"), cmd.Bool("verbose")) +} + +// RunPullsChecks lists all checks for a PR +func RunPullsChecks(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if cmd.Args().Len() != 1 { + return fmt.Errorf("must specify pull request index") + } + + idx, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return err + } + + client := ctx.Login.Client() + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("could not get pull request: %w", err) + } + + return listChecks(requestCtx, client, ctx, pr, strings.ToLower(cmd.String("state")), cmd.Bool("show-urls"), cmd.String("format")) +} + +// showPullStatus displays the current CI/CD status for a PR +func showPullStatus(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, pr *gitea.PullRequest, verbose, failingOnly bool) error { + fmt.Printf("CI/CD Status for PR #%d: %s\n", pr.Index, pr.Title) + fmt.Printf("Branch: %s -> %s\n", pr.Head.Ref, pr.Base.Ref) + fmt.Printf("Commit: %s\n\n", pr.Head.Sha[:8]) + + combinedStatus, _, err := client.Repositories.GetCombinedStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha) + if err != nil { + return fmt.Errorf("could not get combined status: %w", err) + } + + fmt.Printf("Overall Status: %s %s\n", getStatusIcon(combinedStatus.State), strings.ToUpper(string(combinedStatus.State))) + + if combinedStatus.TotalCount == 0 { + fmt.Println("No CI/CD checks found for this pull request") + return nil + } + + fmt.Printf("Total Checks: %d\n\n", combinedStatus.TotalCount) + + fmt.Println("Individual Checks:") + fmt.Println("==================") + + for _, status := range combinedStatus.Statuses { + if failingOnly && status.State == gitea.StatusSuccess { + continue + } + + icon := getStatusIcon(status.State) + fmt.Printf("%s %s", icon, status.Context) + + if verbose { + fmt.Printf(" - %s", status.Description) + if status.TargetURL != "" { + fmt.Printf(" (%s)", status.TargetURL) + } + } + fmt.Println() + } + + return nil +} + +// watchPullStatus watches for status changes in real-time +func watchPullStatus(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, pr *gitea.PullRequest, timeout int, verbose bool) error { + fmt.Printf("Watching CI/CD status for PR #%d...\n", pr.Index) + fmt.Printf("Press Ctrl+C to stop watching\n\n") + + timeoutDuration := time.Duration(timeout) * time.Second + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + start := time.Now() + var lastState gitea.StatusState + + for { + select { + case <-ticker.C: + if time.Since(start) > timeoutDuration { + fmt.Println("Timeout reached") + return nil + } + + combinedStatus, _, err := client.Repositories.GetCombinedStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha) + if err != nil { + fmt.Printf("Error getting status: %v\n", err) + continue + } + + if combinedStatus.State != lastState { + timestamp := time.Now().Format("15:04:05") + icon := getStatusIcon(combinedStatus.State) + fmt.Printf("[%s] %s Status changed to: %s\n", timestamp, icon, strings.ToUpper(string(combinedStatus.State))) + + if verbose && len(combinedStatus.Statuses) > 0 { + for _, status := range combinedStatus.Statuses { + if status.State != gitea.StatusSuccess { + fmt.Printf(" %s %s: %s\n", getStatusIcon(status.State), status.Context, status.Description) + } + } + } + + lastState = combinedStatus.State + + if combinedStatus.State != gitea.StatusPending { + fmt.Printf("\nAll checks completed with status: %s\n", strings.ToUpper(string(combinedStatus.State))) + return nil + } + } + } + } +} + +// waitForChecks waits for all checks to complete +func waitForChecks(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, pr *gitea.PullRequest, timeout, interval time.Duration, failFast, verbose bool) error { + fmt.Printf("Waiting for CI/CD checks to complete for PR #%d...\n", pr.Index) + + start := time.Now() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if time.Since(start) > timeout { + return fmt.Errorf("timeout waiting for checks to complete") + } + + combinedStatus, _, err := client.Repositories.GetCombinedStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha) + if err != nil { + return fmt.Errorf("could not get combined status: %w", err) + } + + if verbose { + elapsed := time.Since(start).Round(time.Second) + fmt.Printf("[%s] Current status: %s (%d checks)\n", elapsed, strings.ToUpper(string(combinedStatus.State)), combinedStatus.TotalCount) + } + + if combinedStatus.State != gitea.StatusPending { + elapsed := time.Since(start).Round(time.Second) + fmt.Printf("\nAll checks completed after %s with status: %s\n", elapsed, strings.ToUpper(string(combinedStatus.State))) + + if combinedStatus.State == gitea.StatusSuccess { + fmt.Println("All checks passed!") + return nil + } + fmt.Printf("Some checks failed (status: %s)\n", combinedStatus.State) + if verbose { + for _, status := range combinedStatus.Statuses { + if status.State != gitea.StatusSuccess { + fmt.Printf(" %s: %s\n", status.Context, status.Description) + } + } + } + return fmt.Errorf("checks failed") + } + + if failFast { + for _, status := range combinedStatus.Statuses { + if status.State == gitea.StatusError || status.State == gitea.StatusFailure { + return fmt.Errorf("check failed (fail-fast enabled): %s - %s", status.Context, status.Description) + } + } + } + } + } +} + +// listChecks lists all checks for a PR +func listChecks(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, pr *gitea.PullRequest, stateFilter string, showUrls bool, format string) error { + statuses, _, err := client.Repositories.ListStatuses(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha, gitea.ListStatusesOption{ + ListOptions: gitea.ListOptions{Page: -1}, + }) + if err != nil { + return fmt.Errorf("could not get statuses: %w", err) + } + + var filteredStatuses []*gitea.Status + for _, status := range statuses { + if stateFilter == "all" || strings.ToLower(string(status.State)) == stateFilter { + filteredStatuses = append(filteredStatuses, status) + } + } + + switch format { + case "json": + return outputJSON(filteredStatuses) + case "yaml": + return outputYAML(filteredStatuses) + default: + return printChecksTable(filteredStatuses, showUrls) + } +} + +// printChecksTable prints checks in table format +func printChecksTable(statuses []*gitea.Status, showUrls bool) error { + if len(statuses) == 0 { + fmt.Println("No checks found") + return nil + } + + if showUrls { + fmt.Printf("%-8s %-30s %-50s %s\n", "STATUS", "CONTEXT", "DESCRIPTION", "URL") + fmt.Println(strings.Repeat("=", 140)) + } else { + fmt.Printf("%-8s %-30s %s\n", "STATUS", "CONTEXT", "DESCRIPTION") + fmt.Println(strings.Repeat("=", 90)) + } + + for _, status := range statuses { + icon := getStatusIcon(status.State) + ctx := truncateString(status.Context, 30) + desc := truncateString(status.Description, 50) + + if showUrls { + url := truncateString(status.TargetURL, 40) + fmt.Printf("%s %-6s %-30s %-50s %s\n", icon, strings.ToUpper(string(status.State)), ctx, desc, url) + } else { + fmt.Printf("%s %-6s %-30s %s\n", icon, strings.ToUpper(string(status.State)), ctx, desc) + } + } + + return nil +} + +// getStatusIcon returns an appropriate icon for the status state +func getStatusIcon(state gitea.StatusState) string { + switch state { + case gitea.StatusSuccess: + return "\u2705" + case gitea.StatusError, gitea.StatusFailure: + return "\u274c" + case gitea.StatusWarning: + return "\u26a0\ufe0f" + case gitea.StatusPending: + return "\U0001f504" + default: + return "\u2753" + } +} + +// truncateString truncates a string to the specified length with ellipsis +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +// outputJSON prints statuses in JSON format +func outputJSON(statuses []*gitea.Status) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(statuses) +} + +// outputYAML prints statuses in YAML format +func outputYAML(statuses []*gitea.Status) error { + encoder := yaml.NewEncoder(os.Stdout) + defer encoder.Close() + return encoder.Encode(statuses) +} diff --git a/cmd/pulls/trigger.go b/cmd/pulls/trigger.go new file mode 100644 index 00000000..5dc55d60 --- /dev/null +++ b/cmd/pulls/trigger.go @@ -0,0 +1,221 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "fmt" + "time" + + gitea "gitea.dev/sdk" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + "github.com/urfave/cli/v3" +) + +// CmdPullsTrigger represents a sub command to trigger CI/CD builds +var CmdPullsTrigger = cli.Command{ + Name: "trigger", + Aliases: []string{"build"}, + Usage: "Trigger CI/CD build for pull request", + Description: `Trigger or re-trigger CI/CD pipeline for a pull request`, + ArgsUsage: "", + Action: RunPullsTrigger, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "context", + Usage: "Specific CI context to trigger (optional)", + }, + &cli.BoolFlag{ + Name: "wait", + Usage: "Wait for the triggered build to complete", + }, + &cli.IntFlag{ + Name: "timeout", + Usage: "Timeout for waiting in seconds", + Value: 1800, // 30 minutes default + }, + &cli.BoolFlag{ + Name: "force", + Usage: "Force trigger even if checks are already running", + }, + }, +} + +// CmdPullsRetry represents a sub command to retry failed CI/CD checks +var CmdPullsRetry = cli.Command{ + Name: "retry", + Usage: "Retry failed CI/CD checks for pull request", + Description: `Retry all failed CI/CD checks for a pull request`, + ArgsUsage: "", + Action: RunPullsRetry, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "context", + Usage: "Specific failed check context to retry (optional)", + }, + &cli.BoolFlag{ + Name: "wait", + Usage: "Wait for the retried checks to complete", + }, + &cli.IntFlag{ + Name: "timeout", + Usage: "Timeout for waiting in seconds", + Value: 1800, + }, + }, +} + +// RunPullsTrigger triggers CI/CD build for a PR +func RunPullsTrigger(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if cmd.Args().Len() != 1 { + return fmt.Errorf("must specify pull request index") + } + + idx, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return err + } + + client := ctx.Login.Client() + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("could not get pull request: %w", err) + } + + return triggerBuild(requestCtx, client, ctx, pr, cmd.String("context"), cmd.Bool("force"), cmd.Bool("wait"), time.Duration(cmd.Int("timeout"))*time.Second) +} + +// RunPullsRetry retries failed CI/CD checks for a PR +func RunPullsRetry(requestCtx stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if cmd.Args().Len() != 1 { + return fmt.Errorf("must specify pull request index") + } + + idx, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return err + } + + client := ctx.Login.Client() + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("could not get pull request: %w", err) + } + + return retryFailedChecks(requestCtx, client, ctx, pr, cmd.String("context"), cmd.Bool("wait"), time.Duration(cmd.Int("timeout"))*time.Second) +} + +// triggerBuild triggers a CI/CD build for the PR +func triggerBuild(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, pr *gitea.PullRequest, triggerContext string, force, wait bool, timeout time.Duration) error { + fmt.Printf("Triggering CI/CD build for PR #%d: %s\n", pr.Index, pr.Title) + + if !force { + combinedStatus, _, err := client.Repositories.GetCombinedStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha) + if err == nil && combinedStatus.State == gitea.StatusPending { + fmt.Println("Checks are already running. Use --force to trigger anyway.") + return nil + } + } + + statusOpt := gitea.CreateStatusOption{ + State: gitea.StatusPending, + Context: triggerContext, + Description: "Manually triggered build", + TargetURL: "", + } + + if triggerContext == "" { + statusOpt.Context = "tea/manual-trigger" + } + + _, _, err := client.Repositories.CreateStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha, statusOpt) + if err != nil { + return fmt.Errorf("could not trigger build: %w", err) + } + + fmt.Printf("Build triggered for commit %s\n", pr.Head.Sha[:8]) + if triggerContext != "" { + fmt.Printf(" Context: %s\n", triggerContext) + } + + if wait { + fmt.Println("\nWaiting for build to complete...") + return waitForChecks(requestCtx, client, ctx, pr, timeout, 30*time.Second, false, true) + } + + return nil +} + +// retryFailedChecks retries all failed checks or a specific failed check +func retryFailedChecks(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, pr *gitea.PullRequest, triggerContext string, wait bool, timeout time.Duration) error { + fmt.Printf("Retrying failed checks for PR #%d: %s\n", pr.Index, pr.Title) + + statuses, _, err := client.Repositories.ListStatuses(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha, gitea.ListStatusesOption{ + ListOptions: gitea.ListOptions{Page: -1}, + }) + if err != nil { + return fmt.Errorf("could not get current statuses: %w", err) + } + + var failedContexts []string + for _, status := range statuses { + if status.State == gitea.StatusError || status.State == gitea.StatusFailure { + if triggerContext == "" || status.Context == triggerContext { + failedContexts = append(failedContexts, status.Context) + } + } + } + + if len(failedContexts) == 0 { + if triggerContext != "" { + fmt.Printf("No failed check found with context: %s\n", triggerContext) + } else { + fmt.Println("No failed checks found to retry") + } + return nil + } + + for _, failedContext := range failedContexts { + statusOpt := gitea.CreateStatusOption{ + State: gitea.StatusPending, + Context: failedContext, + Description: "Retrying failed check", + TargetURL: "", + } + + _, _, err := client.Repositories.CreateStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha, statusOpt) + if err != nil { + fmt.Printf("Could not retry check %s: %v\n", failedContext, err) + continue + } + + fmt.Printf("Retrying check: %s\n", failedContext) + } + + fmt.Printf("Triggered retry for %d failed check(s)\n", len(failedContexts)) + + if wait { + fmt.Println("\nWaiting for retried checks to complete...") + return waitForChecks(requestCtx, client, ctx, pr, timeout, 30*time.Second, false, true) + } + + return nil +}