mirror of
https://gitea.com/gitea/tea.git
synced 2026-09-10 07:26:33 -04:00
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:
parent
58931b5d17
commit
d50c9f75dc
|
|
@ -82,6 +82,7 @@ var CmdPulls = cli.Command{
|
|||
&pulls.CmdPullsReviewComments,
|
||||
&pulls.CmdPullsResolve,
|
||||
&pulls.CmdPullsUnresolve,
|
||||
&pulls.CmdPullsCICD,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
319
cmd/pulls/cicd.go
Normal file
319
cmd/pulls/cicd.go
Normal 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
438
cmd/pulls/status.go
Normal 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
221
cmd/pulls/trigger.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue