mirror of
https://gitea.com/gitea/tea.git
synced 2026-09-10 07:26:33 -04:00
528 lines
16 KiB
Go
528 lines
16 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package task
|
|
|
|
import (
|
|
stdctx "context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
gitea "gitea.dev/sdk"
|
|
|
|
"gitea.dev/tea/modules/print"
|
|
)
|
|
|
|
const (
|
|
// ActionRunWatchExitSuccess means every observed run succeeded.
|
|
ActionRunWatchExitSuccess = 0
|
|
// ActionRunWatchExitError means observation failed.
|
|
ActionRunWatchExitError = 1
|
|
// ActionRunWatchExitUnsuccessful means at least one run was unsuccessful.
|
|
ActionRunWatchExitUnsuccessful = 2
|
|
// ActionRunWatchExitHeadMismatch means an expected head did not match.
|
|
ActionRunWatchExitHeadMismatch = 3
|
|
// ActionRunWatchExitTimeout means observation exceeded its timeout.
|
|
ActionRunWatchExitTimeout = 4
|
|
)
|
|
|
|
// ActionRunWatchClient is the read-only Actions API used by the observer.
|
|
type ActionRunWatchClient interface {
|
|
GetRepoRun(stdctx.Context, string, string, int64) (*gitea.ActionWorkflowRun, *gitea.Response, error)
|
|
ListRepoJobsByRun(stdctx.Context, string, string, int64, gitea.ListRepoActionsJobsOptions) (*gitea.ActionWorkflowJobsResponse, *gitea.Response, error)
|
|
}
|
|
|
|
// ActionRunWatchClientFactory creates an authenticated Actions API client for
|
|
// an observation cycle. Long-running watches use it before every poll so an
|
|
// OAuth token refresh is reflected by the SDK client that issues the request.
|
|
type ActionRunWatchClientFactory func() (ActionRunWatchClient, error)
|
|
|
|
// ActionRunWatchOptions configures workflow-run observation.
|
|
type ActionRunWatchOptions struct {
|
|
RunIDs []int64
|
|
ExpectedHead string
|
|
Interval time.Duration
|
|
StallAfter time.Duration
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// ActionRunWatchResult describes the observer process exit.
|
|
type ActionRunWatchResult struct {
|
|
ExitCode int
|
|
Message string
|
|
}
|
|
|
|
type (
|
|
// ActionRunWatchRun is the concise observed run state.
|
|
ActionRunWatchRun = print.ActionRunWatchRun
|
|
// ActionRunWatchJob is the concise observed job state.
|
|
ActionRunWatchJob = print.ActionRunWatchJob
|
|
// ActionRunWatchChange describes one observed transition.
|
|
ActionRunWatchChange = print.ActionRunWatchChange
|
|
// ActionRunWatchEvent is emitted only for meaningful observer events.
|
|
ActionRunWatchEvent = print.ActionRunWatchEvent
|
|
)
|
|
|
|
type actionRunWatchTrackedRun struct {
|
|
run ActionRunWatchRun
|
|
jobs map[int64]ActionRunWatchJob
|
|
lastChange time.Time
|
|
nextJobProbe time.Time
|
|
stalled bool
|
|
}
|
|
|
|
type actionRunWatchSession struct {
|
|
clientFactory ActionRunWatchClientFactory
|
|
client ActionRunWatchClient
|
|
owner string
|
|
repo string
|
|
opts ActionRunWatchOptions
|
|
emit func(ActionRunWatchEvent) error
|
|
runs map[int64]*actionRunWatchTrackedRun
|
|
}
|
|
|
|
// WatchActionRuns observes runs until all are terminal, timeout, or cancellation.
|
|
func WatchActionRuns(
|
|
ctx stdctx.Context,
|
|
client ActionRunWatchClient,
|
|
owner, repo string,
|
|
opts ActionRunWatchOptions,
|
|
emit func(ActionRunWatchEvent) error,
|
|
) (ActionRunWatchResult, error) {
|
|
return WatchActionRunsWithClientFactory(ctx, func() (ActionRunWatchClient, error) {
|
|
return client, nil
|
|
}, owner, repo, opts, emit)
|
|
}
|
|
|
|
// WatchActionRunsWithClientFactory observes runs until all are terminal,
|
|
// timeout, or cancellation. It creates a client for the initial observation
|
|
// and every later polling cycle.
|
|
func WatchActionRunsWithClientFactory(
|
|
ctx stdctx.Context,
|
|
clientFactory ActionRunWatchClientFactory,
|
|
owner, repo string,
|
|
opts ActionRunWatchOptions,
|
|
emit func(ActionRunWatchEvent) error,
|
|
) (ActionRunWatchResult, error) {
|
|
if err := validateActionRunWatchOptions(opts); err != nil {
|
|
return ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
if clientFactory == nil {
|
|
return ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, fmt.Errorf("action run watch client factory is required")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
|
|
watchCtx, cancel := stdctx.WithTimeout(ctx, opts.Timeout)
|
|
defer cancel()
|
|
|
|
session := newActionRunWatchSessionWithClientFactory(clientFactory, owner, repo, opts, emit)
|
|
done, result, err := session.initialize(watchCtx, time.Now().UTC())
|
|
if err != nil || done {
|
|
return result, err
|
|
}
|
|
|
|
ticker := time.NewTicker(opts.Interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-watchCtx.Done():
|
|
now := time.Now().UTC()
|
|
if errors.Is(watchCtx.Err(), stdctx.DeadlineExceeded) {
|
|
if err := session.emitSummary("timeout", now); err != nil {
|
|
return ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
return ActionRunWatchResult{
|
|
ExitCode: ActionRunWatchExitTimeout,
|
|
Message: "workflow run watch timed out",
|
|
}, nil
|
|
}
|
|
if err := session.emitSummary("canceled", now); err != nil {
|
|
return ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
return ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, watchCtx.Err()
|
|
case now := <-ticker.C:
|
|
done, result, err := session.poll(watchCtx, now.UTC())
|
|
if err != nil || done {
|
|
return result, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateActionRunWatchOptions(opts ActionRunWatchOptions) error {
|
|
if len(opts.RunIDs) == 0 {
|
|
return fmt.Errorf("at least one run ID is required")
|
|
}
|
|
if opts.Interval <= 0 {
|
|
return fmt.Errorf("interval must be greater than zero")
|
|
}
|
|
if opts.StallAfter < 0 {
|
|
return fmt.Errorf("stall duration cannot be negative")
|
|
}
|
|
if opts.Timeout <= 0 {
|
|
return fmt.Errorf("timeout must be greater than zero")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newActionRunWatchSession(
|
|
client ActionRunWatchClient,
|
|
owner, repo string,
|
|
opts ActionRunWatchOptions,
|
|
emit func(ActionRunWatchEvent) error,
|
|
) *actionRunWatchSession {
|
|
return newActionRunWatchSessionWithClientFactory(func() (ActionRunWatchClient, error) {
|
|
return client, nil
|
|
}, owner, repo, opts, emit)
|
|
}
|
|
|
|
func newActionRunWatchSessionWithClientFactory(
|
|
clientFactory ActionRunWatchClientFactory,
|
|
owner, repo string,
|
|
opts ActionRunWatchOptions,
|
|
emit func(ActionRunWatchEvent) error,
|
|
) *actionRunWatchSession {
|
|
return &actionRunWatchSession{
|
|
clientFactory: clientFactory,
|
|
owner: owner,
|
|
repo: repo,
|
|
opts: opts,
|
|
emit: emit,
|
|
runs: make(map[int64]*actionRunWatchTrackedRun, len(opts.RunIDs)),
|
|
}
|
|
}
|
|
|
|
func (s *actionRunWatchSession) initialize(ctx stdctx.Context, now time.Time) (bool, ActionRunWatchResult, error) {
|
|
if err := s.refreshClient(); err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
|
|
headMismatch := false
|
|
for _, runID := range s.opts.RunIDs {
|
|
run, err := s.fetchRun(ctx, runID)
|
|
if err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
jobs, err := s.fetchJobs(ctx, runID)
|
|
if err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
tracked := &actionRunWatchTrackedRun{
|
|
run: run,
|
|
jobs: jobs,
|
|
lastChange: now,
|
|
}
|
|
tracked.resetJobProbe(now, s.opts.StallAfter)
|
|
s.runs[runID] = tracked
|
|
|
|
matches := s.opts.ExpectedHead == "" || strings.EqualFold(run.HeadSHA, s.opts.ExpectedHead)
|
|
if !matches {
|
|
headMismatch = true
|
|
}
|
|
eventRun := run
|
|
if err := s.emit(ActionRunWatchEvent{
|
|
Type: "binding",
|
|
ObservedAt: now,
|
|
RunID: runID,
|
|
ExpectedHead: s.opts.ExpectedHead,
|
|
HeadMatches: matches,
|
|
Run: &eventRun,
|
|
Jobs: sortedActionRunWatchJobs(jobs),
|
|
}); err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
}
|
|
|
|
if headMismatch {
|
|
if err := s.emitSummary("head_mismatch", now); err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
return true, ActionRunWatchResult{
|
|
ExitCode: ActionRunWatchExitHeadMismatch,
|
|
Message: "one or more workflow runs do not match --expect-head",
|
|
}, nil
|
|
}
|
|
if s.allTerminal() {
|
|
return s.finish(now)
|
|
}
|
|
return false, ActionRunWatchResult{}, nil
|
|
}
|
|
|
|
func (s *actionRunWatchSession) poll(ctx stdctx.Context, now time.Time) (bool, ActionRunWatchResult, error) {
|
|
if err := s.refreshClient(); err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
|
|
for _, runID := range s.opts.RunIDs {
|
|
previous := s.runs[runID]
|
|
if isActionRunTerminal(previous.run) {
|
|
continue
|
|
}
|
|
|
|
run, err := s.fetchRun(ctx, runID)
|
|
if err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
changes := diffActionRunWatchRuns(previous.run, run)
|
|
runChanged := len(changes) > 0
|
|
stallProbe := previous.jobProbeDue(now, s.opts.StallAfter)
|
|
jobs := previous.jobs
|
|
if runChanged || isActionRunTerminal(run) || stallProbe {
|
|
jobs, err = s.fetchJobs(ctx, runID)
|
|
if err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
changes = append(changes, diffActionRunWatchJobs(previous.jobs, jobs)...)
|
|
}
|
|
|
|
current := &actionRunWatchTrackedRun{
|
|
run: run,
|
|
jobs: jobs,
|
|
lastChange: previous.lastChange,
|
|
nextJobProbe: previous.nextJobProbe,
|
|
stalled: previous.stalled,
|
|
}
|
|
if len(changes) > 0 {
|
|
if previous.stalled {
|
|
if err := s.emit(ActionRunWatchEvent{
|
|
Type: "recovery",
|
|
ObservedAt: now,
|
|
RunID: runID,
|
|
}); err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
}
|
|
current.lastChange = now
|
|
current.stalled = false
|
|
current.resetJobProbe(now, s.opts.StallAfter)
|
|
if err := s.emit(ActionRunWatchEvent{
|
|
Type: "transition",
|
|
ObservedAt: now,
|
|
RunID: runID,
|
|
Changes: changes,
|
|
}); err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
} else if stallProbe {
|
|
current.resetJobProbe(now, s.opts.StallAfter)
|
|
if !previous.stalled {
|
|
current.stalled = true
|
|
if err := s.emit(ActionRunWatchEvent{
|
|
Type: "stall",
|
|
ObservedAt: now,
|
|
RunID: runID,
|
|
UnchangedFor: now.Sub(previous.lastChange),
|
|
}); err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
}
|
|
}
|
|
s.runs[runID] = current
|
|
}
|
|
|
|
if s.allTerminal() {
|
|
return s.finish(now)
|
|
}
|
|
return false, ActionRunWatchResult{}, nil
|
|
}
|
|
|
|
func (s *actionRunWatchSession) refreshClient() error {
|
|
client, err := s.clientFactory()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create action run watch client: %w", err)
|
|
}
|
|
if client == nil {
|
|
return fmt.Errorf("failed to create action run watch client: client is nil")
|
|
}
|
|
s.client = client
|
|
return nil
|
|
}
|
|
|
|
func (s *actionRunWatchSession) fetchRun(ctx stdctx.Context, runID int64) (ActionRunWatchRun, error) {
|
|
run, _, err := s.client.GetRepoRun(ctx, s.owner, s.repo, runID)
|
|
if err != nil {
|
|
return ActionRunWatchRun{}, fmt.Errorf("failed to get run %d: %w", runID, err)
|
|
}
|
|
workflow := run.DisplayTitle
|
|
if workflow == "" {
|
|
workflow = run.Path
|
|
}
|
|
return ActionRunWatchRun{
|
|
RunID: runID,
|
|
HeadSHA: run.HeadSha,
|
|
Workflow: workflow,
|
|
Path: run.Path,
|
|
Status: run.Status,
|
|
Conclusion: run.Conclusion,
|
|
}, nil
|
|
}
|
|
|
|
func (s *actionRunWatchSession) fetchJobs(ctx stdctx.Context, runID int64) (map[int64]ActionRunWatchJob, error) {
|
|
response, _, err := s.client.ListRepoJobsByRun(ctx, s.owner, s.repo, runID, gitea.ListRepoActionsJobsOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get jobs for run %d: %w", runID, err)
|
|
}
|
|
jobs := make(map[int64]ActionRunWatchJob)
|
|
if response == nil {
|
|
return jobs, nil
|
|
}
|
|
for _, job := range response.Jobs {
|
|
jobs[job.ID] = ActionRunWatchJob{
|
|
JobID: job.ID,
|
|
Name: job.Name,
|
|
Status: job.Status,
|
|
Conclusion: job.Conclusion,
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
func (s *actionRunWatchSession) finish(now time.Time) (bool, ActionRunWatchResult, error) {
|
|
outcome := "success"
|
|
for _, run := range s.runs {
|
|
if !isActionRunSuccessful(run.run) {
|
|
outcome = "unsuccessful"
|
|
break
|
|
}
|
|
}
|
|
if err := s.emitSummary(outcome, now); err != nil {
|
|
return false, ActionRunWatchResult{ExitCode: ActionRunWatchExitError}, err
|
|
}
|
|
if outcome == "unsuccessful" {
|
|
return true, ActionRunWatchResult{
|
|
ExitCode: ActionRunWatchExitUnsuccessful,
|
|
Message: "one or more workflow runs were unsuccessful",
|
|
}, nil
|
|
}
|
|
return true, ActionRunWatchResult{ExitCode: ActionRunWatchExitSuccess}, nil
|
|
}
|
|
|
|
func (s *actionRunWatchSession) emitSummary(outcome string, now time.Time) error {
|
|
ids := make([]int64, 0, len(s.runs))
|
|
for runID := range s.runs {
|
|
ids = append(ids, runID)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
runs := make([]ActionRunWatchRun, 0, len(ids))
|
|
for _, runID := range ids {
|
|
runs = append(runs, s.runs[runID].run)
|
|
}
|
|
return s.emit(ActionRunWatchEvent{
|
|
Type: "summary",
|
|
ObservedAt: now,
|
|
Outcome: outcome,
|
|
Runs: runs,
|
|
})
|
|
}
|
|
|
|
func (s *actionRunWatchSession) allTerminal() bool {
|
|
for _, run := range s.runs {
|
|
if !isActionRunTerminal(run.run) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (r *actionRunWatchTrackedRun) resetJobProbe(now time.Time, stallAfter time.Duration) {
|
|
if stallAfter <= 0 {
|
|
r.nextJobProbe = time.Time{}
|
|
return
|
|
}
|
|
r.nextJobProbe = now.Add(stallAfter)
|
|
}
|
|
|
|
func (r *actionRunWatchTrackedRun) jobProbeDue(now time.Time, stallAfter time.Duration) bool {
|
|
return stallAfter > 0 && !r.nextJobProbe.IsZero() && !now.Before(r.nextJobProbe)
|
|
}
|
|
|
|
func diffActionRunWatchRuns(before, after ActionRunWatchRun) []ActionRunWatchChange {
|
|
changes := make([]ActionRunWatchChange, 0, 2)
|
|
if before.Status != after.Status {
|
|
changes = append(changes, ActionRunWatchChange{Scope: "run", Field: "status", Before: before.Status, After: after.Status})
|
|
}
|
|
if before.Conclusion != after.Conclusion {
|
|
changes = append(changes, ActionRunWatchChange{Scope: "run", Field: "conclusion", Before: before.Conclusion, After: after.Conclusion})
|
|
}
|
|
return changes
|
|
}
|
|
|
|
func diffActionRunWatchJobs(before, after map[int64]ActionRunWatchJob) []ActionRunWatchChange {
|
|
idsByValue := make(map[int64]struct{}, len(before)+len(after))
|
|
for jobID := range before {
|
|
idsByValue[jobID] = struct{}{}
|
|
}
|
|
for jobID := range after {
|
|
idsByValue[jobID] = struct{}{}
|
|
}
|
|
ids := make([]int64, 0, len(idsByValue))
|
|
for jobID := range idsByValue {
|
|
ids = append(ids, jobID)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
|
|
changes := make([]ActionRunWatchChange, 0)
|
|
for _, jobID := range ids {
|
|
oldJob := before[jobID]
|
|
newJob := after[jobID]
|
|
name := newJob.Name
|
|
if name == "" {
|
|
name = oldJob.Name
|
|
}
|
|
if oldJob.Status != newJob.Status {
|
|
changes = append(changes, ActionRunWatchChange{
|
|
Scope: "job", JobID: jobID, Name: name, Field: "status", Before: oldJob.Status, After: newJob.Status,
|
|
})
|
|
}
|
|
if oldJob.Conclusion != newJob.Conclusion {
|
|
changes = append(changes, ActionRunWatchChange{
|
|
Scope: "job", JobID: jobID, Name: name, Field: "conclusion", Before: oldJob.Conclusion, After: newJob.Conclusion,
|
|
})
|
|
}
|
|
}
|
|
return changes
|
|
}
|
|
|
|
func sortedActionRunWatchJobs(jobs map[int64]ActionRunWatchJob) []ActionRunWatchJob {
|
|
ids := make([]int64, 0, len(jobs))
|
|
for jobID := range jobs {
|
|
ids = append(ids, jobID)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
result := make([]ActionRunWatchJob, 0, len(ids))
|
|
for _, jobID := range ids {
|
|
result = append(result, jobs[jobID])
|
|
}
|
|
return result
|
|
}
|
|
|
|
func isActionRunTerminal(run ActionRunWatchRun) bool {
|
|
if run.Conclusion != "" {
|
|
return true
|
|
}
|
|
switch strings.ToLower(run.Status) {
|
|
case "success", "failure", "canceled", "cancel" + "led", "skipped", "neutral", "timed_out", "completed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isActionRunSuccessful(run ActionRunWatchRun) bool {
|
|
result := run.Conclusion
|
|
if result == "" {
|
|
result = run.Status
|
|
}
|
|
switch strings.ToLower(result) {
|
|
case "success", "neutral", "skipped":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|