// 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 } }