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.
This commit is contained in:
Ross Golder 2026-07-30 08:57:11 +07:00
parent 58931b5d17
commit d50c9f75dc
No known key found for this signature in database
GPG key ID: 253A7E508D2D59CD
4 changed files with 979 additions and 0 deletions

View file

@ -82,6 +82,7 @@ var CmdPulls = cli.Command{
&pulls.CmdPullsReviewComments,
&pulls.CmdPullsResolve,
&pulls.CmdPullsUnresolve,
&pulls.CmdPullsCICD,
},
}

319
cmd/pulls/cicd.go Normal file
View file

@ -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: "<pull index>",
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)
}
}

438
cmd/pulls/status.go Normal file
View file

@ -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: "<pull index>",
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: "<pull index>",
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: "<pull index>",
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)
}

221
cmd/pulls/trigger.go Normal file
View file

@ -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: "<pull index>",
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: "<pull index>",
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
}