Files
gitea-tea/internal/services/actions.go
T
bircni 12e415a7a7 refactor: add services layer on the Gitea Go SDK (#1125)
- `internal/services`: typed Gitea workflows built on `gitea.dev/sdk`, independent of CLI flags and terminal I/O: issues, pull requests, reviews, comments, labels, milestones, releases and assets, repositories, branches, wiki, Actions (workflows, runs, jobs, logs, artifacts, secrets, variables), organizations, teams, webhooks, notifications, SSH keys, tracked time, admin users, search and status.
- SDK types are mapped to tea's own output types, so `--json` stays a stable contract. Requests the SDK cannot express yet (e.g. pull list filters, multi-status run lists, org label archiving) go through small raw helpers, each marked `TODO(sdk)` with the missing SDK piece.
- Targets Gitea 28 or newer: no version or API-schema probing. List helpers fill pages capped by `MAX_RESPONSE_ITEMS`.

Nothing imports this package yet.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1125
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
2026-10-11 17:15:41 +00:00

305 lines
10 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package services
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
)
// Workflow is the stable command representation of an Actions workflow.
type Workflow struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
State string `json:"state"`
}
func workflowFrom(workflow *gitea.ActionsWorkflow) Workflow {
return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State}
}
// Run is the stable command representation of an Actions run.
type Run struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
ID int64 `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
URL string `json:"url"`
Branch string `json:"branch"`
Event string `json:"event,omitempty"`
Actor string `json:"actor,omitempty"`
Commit string `json:"commit,omitempty"`
Attempt int64 `json:"attempt,omitempty"`
}
type runResponse struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
ID int64 `json:"id"`
DisplayTitle string `json:"display_title"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
HTMLURL string `json:"html_url"`
HeadBranch string `json:"head_branch"`
Event string `json:"event"`
HeadSHA string `json:"head_sha"`
RunAttempt int64 `json:"run_attempt"`
Actor struct {
Login string `json:"login"`
} `json:"actor"`
}
func (r runResponse) result() Run {
return Run{
ID: r.ID, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL, Branch: r.HeadBranch,
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: r.StartedAt, CompletedAt: r.CompletedAt,
Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt,
}
}
// DispatchResult identifies a workflow run created by dispatch when the server returns details.
type DispatchResult struct {
ID int64 `json:"id"`
URL string `json:"url,omitempty"`
}
// Actions implements repository workflow and run operations.
type Actions struct {
Client *transport.Client
Repository string
}
func (s Actions) endpoint() string { return "repos/" + s.Repository + "/actions/" }
// Workflows lists the repository workflow definitions.
func (s Actions) Workflows(ctx context.Context) ([]Workflow, error) {
response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflowResponse, *gitea.Response, error) {
return c.Actions.ListRepoWorkflows(ctx, owner, repo)
})
if err != nil {
return nil, err
}
return convertAll(response.Workflows, workflowFrom), nil
}
// Workflow retrieves one workflow definition.
func (s Actions) Workflow(ctx context.Context, id string) (Workflow, error) {
return repoConvert(ctx, s.Client, s.Repository, workflowFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflow, *gitea.Response, error) {
return c.Actions.GetRepoWorkflow(ctx, owner, repo, id)
})
}
// Dispatch triggers a workflow and reports the run it created.
func (s Actions) Dispatch(ctx context.Context, id, ref string, inputs map[string]string) (DispatchResult, error) {
details, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.RunDetails, *gitea.Response, error) {
return c.Actions.DispatchRepoWorkflow(ctx, owner, repo, id, gitea.CreateActionsWorkflowDispatchOption{Ref: ref, Inputs: inputs}, true)
})
if err != nil {
return DispatchResult{}, err
}
return DispatchResult{ID: details.WorkflowRunID, URL: details.HTMLURL}, nil
}
// SetWorkflowEnabled updates whether a workflow can run.
func (s Actions) SetWorkflowEnabled(ctx context.Context, id string, enabled bool) error {
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
if enabled {
return c.Actions.EnableRepoWorkflow(ctx, owner, repo, id)
}
return c.Actions.DisableRepoWorkflow(ctx, owner, repo, id)
})
}
// RunListOptions supplies server-side run filters and pagination.
type RunListOptions struct {
Since, Until *time.Time
Limit, Page int
Branch, Event, Actor, Commit, Workflow string
Statuses []string
}
// Validate rejects unsupported status filters before performing API requests.
func (o RunListOptions) Validate() error {
if o.Limit < 1 || o.Limit > 100 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit {
return errors.New("invalid pagination")
}
for _, status := range o.Statuses {
switch status {
case "pending", "waiting", "requested", "action_required", "queued", "in_progress", "completed", "failure", "success", "skipped", "neutral", "cancelled", "timed_out": //nolint:misspell // Gitea API spelling.
default:
return fmt.Errorf("unsupported run status %q; see tea run list --help", status)
}
}
return nil
}
const maxRunScanPages = 50
// Runs lists a page of runs.
func (s Actions) Runs(ctx context.Context, opts RunListOptions) ([]Run, error) {
if err := opts.Validate(); err != nil {
return nil, err
}
if opts.Since != nil || opts.Until != nil {
first, last := (opts.Page-1)*opts.Limit, opts.Page*opts.Limit
result := []Run{}
matched := 0
for page := 1; page <= maxRunScanPages; page++ {
scan := opts
scan.Limit = 100
scan.Page = page
items, err := s.runsPage(ctx, scan)
if err != nil {
return nil, err
}
if len(items) == 0 {
return result, nil
}
for _, item := range items {
if item.StartedAt == "" {
continue
}
started, err := time.Parse(time.RFC3339Nano, item.StartedAt)
if err != nil {
return nil, errors.New("server returned an invalid run start timestamp")
}
if opts.Since != nil && started.Before(*opts.Since) || opts.Until != nil && started.After(*opts.Until) {
continue
}
if matched >= first {
result = append(result, item)
}
matched++
if matched >= last {
return result, nil
}
}
}
return nil, fmt.Errorf("time filters scanned %d runs without exhausting the history; narrow the search with --branch, --workflow or --status", maxRunScanPages*100)
}
return s.runsPage(ctx, opts)
}
func (s Actions) runsPage(ctx context.Context, opts RunListOptions) ([]Run, error) {
var response struct {
Runs []runResponse `json:"workflow_runs"`
}
query := url.Values{"limit": {strconv.Itoa(opts.Limit)}, "page": {strconv.Itoa(opts.Page)}}
for key, value := range map[string]string{"branch": opts.Branch, "event": opts.Event, "actor": opts.Actor, "head_sha": opts.Commit} {
if value != "" {
query.Set(key, value)
}
}
for _, status := range opts.Statuses {
query.Add("status", status)
}
endpoint := s.endpoint() + "runs"
if opts.Workflow != "" {
endpoint = s.endpoint() + "workflows/" + url.PathEscape(opts.Workflow) + "/runs"
}
// TODO(sdk): ListRepoActionsRunsOptions takes one status and ActionsWorkflowRun lacks created_at/updated_at.
if err := getJSON(ctx, s.Client, endpoint+"?"+query.Encode(), &response); err != nil {
return nil, err
}
result := make([]Run, 0, len(response.Runs))
for _, item := range response.Runs {
result = append(result, item.result())
}
return result, nil
}
// Run retrieves one Actions run.
func (s Actions) Run(ctx context.Context, id int64) (Run, error) {
var response runResponse
// TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at.
err := getJSON(ctx, s.Client, fmt.Sprintf("%sruns/%d", s.endpoint(), id), &response)
return response.result(), err
}
// Rerun schedules another attempt, optionally restricted to failed jobs.
func (s Actions) Rerun(ctx context.Context, id int64, failedOnly bool) error {
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
if failedOnly {
return c.Actions.RerunRepoRunFailedJobs(ctx, owner, repo, id)
}
_, resp, err := c.Actions.RerunRepoRun(ctx, owner, repo, id)
return resp, err
})
}
// DeleteRun removes the selected run.
func (s Actions) DeleteRun(ctx context.Context, id int64) error {
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
return c.Actions.DeleteRepoRun(ctx, owner, repo, id)
})
}
func completedActionStatus(status string) bool {
switch status {
case "completed", "success", "failure", "canceled", "cancelled", "skipped": //nolint:misspell // Gitea API spelling.
return true
}
return false
}
// SuccessfulRun recognizes a successful terminal run result.
func SuccessfulRun(run Run) bool {
return completedActionStatus(run.Status) && (run.Conclusion == "success" || run.Status == "success")
}
// Watch polls with cancellation and reports only changed run state.
func (s Actions) Watch(ctx context.Context, id int64, interval time.Duration, changed func(Run) error) (Run, error) {
if interval <= 0 {
return Run{}, errors.New("watch interval must be positive")
}
last := Run{}
for {
current, err := s.Run(ctx, id)
if err != nil {
return Run{}, err
}
if current != last {
if changed != nil {
if err := changed(current); err != nil {
return Run{}, err
}
}
last = current
}
if completedActionStatus(current.Status) {
return current, nil
}
if err := sleepCtx(ctx, interval); err != nil {
return Run{}, err
}
}
}
// CancelRun requests cancellation without deleting the run or its history.
// Force cancellation is an explicit operation for unresponsive runners.
func (s Actions) CancelRun(ctx context.Context, id int64, force bool) (Run, error) {
action := "cancel"
if force {
action = "force-cancel"
}
var response runResponse
// TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at.
err := sendJSON(ctx, s.Client, http.MethodPost, fmt.Sprintf("%sruns/%d/%s", s.endpoint(), id, action), nil, &response)
return response.result(), err
}