gitea.tea/modules/task/actions_run_watch.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
}
}