mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
feat(run): show runs like gh, watch them live, and download artifacts
run list prints a table with a status icon, the workflow name, title, branch, event, elapsed time and age, and the total from the server; runs carry workflow, workflow_path and run_number in JSON. run view shows the run's state and trigger, its jobs with their steps and durations, the next steps and the run's URL; --json includes the jobs, --log prints their logs and --web opens the run. Run commands accept #N and run URLs, and ask for a recent run in a terminal when the argument is left out. run watch redraws the run on the alternate screen, then prints it with a summary; an expired --timeout names the run and suspects a missing runner when no job started. run cancel no longer asks for confirmation, rerun refuses unfinished runs up front, reruns a single job with --job and reports the new attempt, and delete names the run in its prompt. run logs follows several jobs with --follow, prefixing lines with the job's name, selects jobs by name or with --failed, and downloads the archive with --output or --dir. run download extracts the artifacts of a run by name, and artifact list shows sizes and expiry. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
19 files changed
+2636
-646
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,8 +22,22 @@ import (
|
||||
// Job identifies one workflow job without exposing server-specific metadata. StartedAt and
|
||||
// CompletedAt are nil until the job starts and completes.
|
||||
type Job struct {
|
||||
RunAttempt int64 `json:"run_attempt"`
|
||||
ID int64 `json:"id"`
|
||||
RunAttempt int64 `json:"run_attempt"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
URL string `json:"url"`
|
||||
Labels []string `json:"labels"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Steps []Step `json:"steps"`
|
||||
}
|
||||
|
||||
// Step is one step of a job. StartedAt and CompletedAt are nil until the step starts and completes.
|
||||
type Step struct {
|
||||
Number int64 `json:"number"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
@@ -30,14 +45,44 @@ type Job struct {
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
}
|
||||
|
||||
// Jobs lists every page of jobs in the selected run.
|
||||
// Completed reports whether the job finished, whatever its conclusion.
|
||||
func (j Job) Completed() bool { return completedActionStatus(j.Status) }
|
||||
|
||||
// Jobs lists every job in the selected run.
|
||||
func (s Actions) Jobs(ctx context.Context, run int64) ([]Job, error) {
|
||||
return listAll(func(page int) ([]Job, error) { return s.jobsPage(ctx, run, 100, page) })
|
||||
jobs := []Job{}
|
||||
for page := 1; page <= maxListPages; page++ {
|
||||
window, err := s.JobsPage(ctx, run, 100, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, window.Items...)
|
||||
if !window.More || len(window.Items) == 0 {
|
||||
return jobs, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("listing exceeded %d pages", maxListPages)
|
||||
}
|
||||
|
||||
// JobsPage lists a page of jobs in the selected run.
|
||||
func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) {
|
||||
return s.jobsPage(ctx, run, limit, page)
|
||||
func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) (Page[Job], error) {
|
||||
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.ActionsWorkflowJob, http.Header, error) {
|
||||
response, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) {
|
||||
owner, repo, err := splitRepository(s.Repository)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, headerOf(resp), err
|
||||
}
|
||||
return response.Jobs, headerOf(resp), nil
|
||||
})
|
||||
if err != nil {
|
||||
return Page[Job]{}, err
|
||||
}
|
||||
return Page[Job]{Items: convertAll(window.Items, jobFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil
|
||||
}
|
||||
|
||||
// WaitForFirstJob polls a freshly dispatched run until the server has created its first job.
|
||||
@@ -59,20 +104,18 @@ func (s Actions) WaitForFirstJob(ctx context.Context, run int64, interval time.D
|
||||
}
|
||||
}
|
||||
|
||||
func (s Actions) jobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) {
|
||||
response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) {
|
||||
return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertAll(response.Jobs, jobFrom), nil
|
||||
}
|
||||
|
||||
func jobFrom(job *gitea.ActionsWorkflowJob) Job {
|
||||
return Job{
|
||||
RunAttempt: job.RunAttempt, ID: job.ID, Name: job.Name, Status: job.Status, Conclusion: job.Conclusion,
|
||||
StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt),
|
||||
URL: job.HTMLURL, Labels: append([]string{}, job.Labels...), CreatedAt: timestamp(job.CreatedAt),
|
||||
StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt), Steps: convertAll(job.Steps, stepFrom),
|
||||
}
|
||||
}
|
||||
|
||||
func stepFrom(step *gitea.ActionsWorkflowStep) Step {
|
||||
return Step{
|
||||
Number: step.Number, Name: step.Name, Status: step.Status, Conclusion: step.Conclusion,
|
||||
StartedAt: actionTime(step.StartedAt), CompletedAt: actionTime(step.CompletedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,9 +230,11 @@ func (s Actions) FollowJobLogs(ctx context.Context, run, id int64, interval time
|
||||
}
|
||||
}
|
||||
|
||||
// RunLogsArchive streams the server's ZIP archive without extracting its entries.
|
||||
// RunLogsArchive streams the server's ZIP archive without extracting its entries. A run without
|
||||
// logs, such as one that never started, is reported as not found.
|
||||
func (s Actions) RunLogsArchive(ctx context.Context, run int64) (io.ReadCloser, error) {
|
||||
return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) {
|
||||
body, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) {
|
||||
return c.Actions.GetRepoRunLogsReader(ctx, owner, repo, run)
|
||||
})
|
||||
return body, Classify(err, "logs of run", strconv.FormatInt(run, 10), s.Repository)
|
||||
}
|
||||
@@ -110,11 +110,20 @@ func TestJobsPageMapsJobsAndSendsPagination(t *testing.T) {
|
||||
require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/jobs", r.URL.Path)
|
||||
require.Equal(t, "20", r.URL.Query().Get("limit"))
|
||||
require.Equal(t, "3", r.URL.Query().Get("page"))
|
||||
_, _ = w.Write([]byte(`{"total_count":1,"jobs":[{"id":9,"run_attempt":2,"name":"build","status":"completed","conclusion":"success","runner_name":"excluded"}]}`))
|
||||
w.Header().Set("X-Total-Count", "41")
|
||||
_, _ = w.Write([]byte(`{"total_count":41,"jobs":[{"id":9,"run_attempt":2,"name":"build","status":"completed","conclusion":"success","runner_name":"excluded",
|
||||
"labels":["ubuntu-latest"],"html_url":"https://gitea.test/owner/repo/actions/runs/7/jobs/9","created_at":"2028-01-01T00:00:00Z",
|
||||
"started_at":"2028-01-01T00:00:01Z","completed_at":"1970-01-01T00:00:00Z",
|
||||
"steps":[{"number":1,"name":"Set up job","status":"completed","conclusion":"success","started_at":"2028-01-01T00:00:01Z","completed_at":"2028-01-01T00:00:02Z"}]}]}`))
|
||||
}))
|
||||
jobs, err := (Actions{Client: client, Repository: "owner/repo"}).JobsPage(context.Background(), 7, 20, 3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []Job{{RunAttempt: 2, ID: 9, Name: "build", Status: "completed", Conclusion: "success"}}, jobs)
|
||||
started, stepStarted, stepCompleted := "2028-01-01T00:00:01Z", "2028-01-01T00:00:01Z", "2028-01-01T00:00:02Z"
|
||||
require.Equal(t, Page[Job]{Items: []Job{{
|
||||
RunAttempt: 2, ID: 9, Name: "build", Status: "completed", Conclusion: "success", URL: "https://gitea.test/owner/repo/actions/runs/7/jobs/9",
|
||||
Labels: []string{"ubuntu-latest"}, CreatedAt: "2028-01-01T00:00:00Z", StartedAt: &started,
|
||||
Steps: []Step{{Number: 1, Name: "Set up job", Status: "completed", Conclusion: "success", StartedAt: &stepStarted, CompletedAt: &stepCompleted}},
|
||||
}}, Total: 41, Counted: true}, jobs)
|
||||
}
|
||||
|
||||
func TestRunLogsArchiveStreams(t *testing.T) {
|
||||
@@ -151,7 +160,8 @@ func TestWaitForFirstJobPollsUntilJobsExist(t *testing.T) {
|
||||
defer cancel()
|
||||
job, err := service.WaitForFirstJob(ctx, 7, time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Job{ID: 11, Name: "build"}, job)
|
||||
require.Equal(t, int64(11), job.ID)
|
||||
require.Equal(t, "build", job.Name)
|
||||
_, err = service.WaitForFirstJob(ctx, 7, 0)
|
||||
require.ErrorContains(t, err, "interval must be positive")
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// Run is the stable command representation of an Actions run. StartedAt and CompletedAt are nil
|
||||
// until the run starts and completes. Workflow is the workflow's name and WorkflowPath its file
|
||||
// name; Jobs is only filled where commands show a single 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"`
|
||||
RunNumber int64 `json:"run_number"`
|
||||
Title string `json:"title"`
|
||||
Workflow string `json:"workflow"`
|
||||
WorkflowPath string `json:"workflow_path"`
|
||||
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"`
|
||||
Jobs []Job `json:"jobs,omitempty"`
|
||||
}
|
||||
|
||||
// Completed reports whether the run finished, whatever its conclusion.
|
||||
func (r Run) Completed() bool { return completedActionStatus(r.Status) }
|
||||
|
||||
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"`
|
||||
RunNumber int64 `json:"run_number"`
|
||||
DisplayTitle string `json:"display_title"`
|
||||
Path string `json:"path"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// result converts the response; until the run is named after its workflow, Workflow is the
|
||||
// workflow's file name without extension.
|
||||
func (r runResponse) result() Run {
|
||||
file, _, _ := strings.Cut(r.Path, "@")
|
||||
return Run{
|
||||
ID: r.ID, RunNumber: r.RunNumber, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL,
|
||||
Branch: r.HeadBranch, Workflow: strings.TrimSuffix(file, path.Ext(file)), WorkflowPath: file,
|
||||
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: runTime(r.StartedAt), CompletedAt: runTime(r.CompletedAt),
|
||||
Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt,
|
||||
}
|
||||
}
|
||||
|
||||
// runTime returns an Actions timestamp, or nil for the zero or Unix epoch time Gitea reports
|
||||
// for a run or job that has not started or completed.
|
||||
func runTime(raw string) *string {
|
||||
t, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if raw == "" || (err == nil && t.Unix() <= 0) {
|
||||
return nil
|
||||
}
|
||||
return &raw
|
||||
}
|
||||
|
||||
// RunListOptions supplies server-side run filters and pagination. Workflow is a workflow file
|
||||
// name.
|
||||
type RunListOptions struct {
|
||||
Since, Until *time.Time
|
||||
Limit, Page int
|
||||
Branch, Event, Actor, Commit, Workflow string
|
||||
Statuses []string
|
||||
}
|
||||
|
||||
// RunStatuses are the values Gitea accepts as run status filters.
|
||||
var RunStatuses = []string{"queued", "in_progress", "waiting", "requested", "pending", "action_required", "completed", "success", "failure", "cancelled", "skipped", "timed_out", "neutral"} //nolint:misspell // Gitea API spelling.
|
||||
|
||||
// Validate rejects unsupported status filters before performing API requests.
|
||||
func (o RunListOptions) Validate() error {
|
||||
if o.Limit < 1 || o.Limit > 1000 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit {
|
||||
return errors.New("invalid pagination")
|
||||
}
|
||||
for _, status := range o.Statuses {
|
||||
if !slices.Contains(RunStatuses, status) {
|
||||
return fmt.Errorf("unsupported run status %q; see tea run list --help", status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxRunScanPages = 50
|
||||
|
||||
// Runs lists a page of runs, newest first.
|
||||
func (s Actions) Runs(ctx context.Context, opts RunListOptions) ([]Run, error) {
|
||||
page, err := s.runs(ctx, opts)
|
||||
return page.Items, err
|
||||
}
|
||||
|
||||
// RunsPage lists a page of runs like Runs, named after their workflows, with the number of runs
|
||||
// the filters match when the server counts them.
|
||||
func (s Actions) RunsPage(ctx context.Context, opts RunListOptions) (Page[Run], error) {
|
||||
page, err := s.runs(ctx, opts)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
return page, s.nameRuns(ctx, page.Items)
|
||||
}
|
||||
|
||||
func (s Actions) runs(ctx context.Context, opts RunListOptions) (Page[Run], error) {
|
||||
if err := opts.Validate(); err != nil {
|
||||
return Page[Run]{}, err
|
||||
}
|
||||
if opts.Since == nil && opts.Until == nil {
|
||||
return s.runsPage(ctx, opts)
|
||||
}
|
||||
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
|
||||
window, err := s.runsPage(ctx, scan)
|
||||
if err != nil {
|
||||
return Page[Run]{}, err
|
||||
}
|
||||
for _, item := range window.Items {
|
||||
created, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
|
||||
if err != nil {
|
||||
return Page[Run]{}, errors.New("server returned an invalid run creation timestamp")
|
||||
}
|
||||
// Runs are listed newest first, so the rest were created earlier still.
|
||||
if opts.Since != nil && created.Before(*opts.Since) {
|
||||
return Page[Run]{Items: result}, nil
|
||||
}
|
||||
if opts.Until != nil && created.After(*opts.Until) {
|
||||
continue
|
||||
}
|
||||
if matched >= last {
|
||||
return Page[Run]{Items: result, More: true}, nil
|
||||
}
|
||||
if matched >= first {
|
||||
result = append(result, item)
|
||||
}
|
||||
matched++
|
||||
}
|
||||
if len(window.Items) == 0 || !window.More {
|
||||
return Page[Run]{Items: result}, nil
|
||||
}
|
||||
}
|
||||
return Page[Run]{}, fmt.Errorf("time filters scanned %d runs without exhausting the history; narrow the search with --branch, --workflow or --status", maxRunScanPages*100)
|
||||
}
|
||||
|
||||
func (s Actions) runsPage(ctx context.Context, opts RunListOptions) (Page[Run], error) {
|
||||
query := url.Values{}
|
||||
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"
|
||||
}
|
||||
return listPage(opts.Limit, opts.Page, func(limit, page int) ([]Run, http.Header, error) {
|
||||
query.Set("limit", strconv.Itoa(limit))
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
var response struct {
|
||||
Runs []runResponse `json:"workflow_runs"`
|
||||
}
|
||||
// TODO(sdk): ListRepoActionsRunsOptions takes one status and ActionsWorkflowRun lacks created_at/updated_at.
|
||||
headers, err := rawJSON(ctx, s.Client, http.MethodGet, endpoint+"?"+query.Encode(), nil, &response)
|
||||
runs := make([]Run, len(response.Runs))
|
||||
for i, item := range response.Runs {
|
||||
runs[i] = item.result()
|
||||
}
|
||||
return runs, headers, err
|
||||
})
|
||||
}
|
||||
|
||||
// nameRuns names runs after their workflows.
|
||||
func (s Actions) nameRuns(ctx context.Context, runs []Run) error {
|
||||
if len(runs) == 0 {
|
||||
return nil
|
||||
}
|
||||
names, err := s.workflowNames(ctx)
|
||||
for i := range runs {
|
||||
if name := names[runs[i].WorkflowPath]; name != "" {
|
||||
runs[i].Workflow = name
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Actions) workflowNames(ctx context.Context) (map[string]string, error) {
|
||||
workflows, err := s.Workflows(ctx)
|
||||
names := make(map[string]string, len(workflows))
|
||||
for _, workflow := range workflows {
|
||||
names[workflow.ID] = workflow.Name
|
||||
}
|
||||
return names, err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// RunDetails retrieves one run with its jobs, named after its workflow.
|
||||
func (s Actions) RunDetails(ctx context.Context, id int64) (Run, error) {
|
||||
names, err := s.workflowNames(ctx)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return s.runDetails(ctx, id, names)
|
||||
}
|
||||
|
||||
func (s Actions) runDetails(ctx context.Context, id int64, names map[string]string) (Run, error) {
|
||||
run, err := s.Run(ctx, id)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if run.Jobs, err = s.Jobs(ctx, id); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if name := names[run.WorkflowPath]; name != "" {
|
||||
run.Workflow = name
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// Rerun schedules another attempt of a run, restricted to its failed jobs with failedOnly, or of
|
||||
// one job when job is not 0.
|
||||
func (s Actions) Rerun(ctx context.Context, id, job int64, failedOnly bool) error {
|
||||
err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
|
||||
switch {
|
||||
case job != 0:
|
||||
_, resp, err := c.Actions.RerunRepoRunJob(ctx, owner, repo, id, job)
|
||||
return resp, err
|
||||
case failedOnly:
|
||||
return c.Actions.RerunRepoRunFailedJobs(ctx, owner, repo, id)
|
||||
}
|
||||
_, resp, err := c.Actions.RerunRepoRun(ctx, owner, repo, id)
|
||||
return resp, err
|
||||
})
|
||||
return Classify(err, "run", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
|
||||
// DeleteRun removes the selected run.
|
||||
func (s Actions) DeleteRun(ctx context.Context, id int64) error {
|
||||
err := 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)
|
||||
})
|
||||
return Classify(err, "run", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
|
||||
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 a run with its jobs every interval until it completes, reporting each poll to
|
||||
// update, and returns the completed run. When it fails, such as when ctx expires, it returns the
|
||||
// run as last polled.
|
||||
func (s Actions) Watch(ctx context.Context, id int64, interval time.Duration, update func(Run) error) (Run, error) {
|
||||
if interval <= 0 {
|
||||
return Run{}, errors.New("watch interval must be positive")
|
||||
}
|
||||
names, err := s.workflowNames(ctx)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
var last Run
|
||||
for {
|
||||
current, err := s.runDetails(ctx, id, names)
|
||||
if err != nil {
|
||||
return last, err
|
||||
}
|
||||
last = current
|
||||
if update != nil {
|
||||
if err := update(current); err != nil {
|
||||
return last, err
|
||||
}
|
||||
}
|
||||
if current.Completed() {
|
||||
return current, nil
|
||||
}
|
||||
if err := sleepCtx(ctx, interval); err != nil {
|
||||
return last, 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)
|
||||
if err != nil {
|
||||
return Run{}, Classify(err, "run", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
return response.result(), nil
|
||||
}
|
||||
+154
-228
@@ -5,82 +5,43 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/internal/transport"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Workflow is the stable command representation of an Actions workflow.
|
||||
// Workflow is the stable command representation of an Actions workflow. ID is its file name,
|
||||
// such as ci.yml.
|
||||
type Workflow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
State string `json:"state"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func workflowFrom(workflow *gitea.ActionsWorkflow) Workflow {
|
||||
return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State}
|
||||
return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State, URL: workflow.HTMLURL}
|
||||
}
|
||||
|
||||
// Run is the stable command representation of an Actions run. StartedAt and CompletedAt are nil
|
||||
// until the run starts and completes.
|
||||
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"`
|
||||
}
|
||||
// Enabled reports whether the workflow runs; Gitea reports a disabled one as disabled_manually.
|
||||
func (w Workflow) Enabled() bool { return !strings.HasPrefix(w.State, "disabled") }
|
||||
|
||||
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: runTime(r.StartedAt), CompletedAt: runTime(r.CompletedAt),
|
||||
Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt,
|
||||
// Label names the workflow for people: "CI (ci.yml)".
|
||||
func (w Workflow) Label() string {
|
||||
if w.Name == "" || w.Name == w.ID {
|
||||
return w.ID
|
||||
}
|
||||
}
|
||||
|
||||
// runTime returns an Actions timestamp, or nil for the zero or Unix epoch time Gitea reports
|
||||
// for a run or job that has not started or completed.
|
||||
func runTime(raw string) *string {
|
||||
t, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if raw == "" || (err == nil && t.Unix() <= 0) {
|
||||
return nil
|
||||
}
|
||||
return &raw
|
||||
return w.Name + " (" + w.ID + ")"
|
||||
}
|
||||
|
||||
// DispatchResult identifies a workflow run created by dispatch when the server returns details.
|
||||
@@ -108,20 +69,62 @@ func (s Actions) Workflows(ctx context.Context) ([]Workflow, error) {
|
||||
return convertAll(response.Workflows, workflowFrom), nil
|
||||
}
|
||||
|
||||
// Workflow retrieves one workflow definition.
|
||||
// Workflow retrieves one workflow definition by its file name.
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// FindWorkflow returns the workflow that raw names in the repository: its file name, path, file
|
||||
// name without extension, or name, matched exactly and then case-insensitively.
|
||||
func (s Actions) FindWorkflow(ctx context.Context, raw string) (Workflow, error) {
|
||||
items, err := s.Workflows(ctx)
|
||||
if err != nil {
|
||||
return Workflow{}, err
|
||||
}
|
||||
return FindWorkflow(items, raw, s.Repository)
|
||||
}
|
||||
|
||||
// FindWorkflow returns the workflow among items that raw names, like Actions.FindWorkflow. It
|
||||
// fails with a *NotFoundError when none matches and lists the files when several names match.
|
||||
func FindWorkflow(items []Workflow, raw, scope string) (Workflow, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
keys := func(w Workflow) []string {
|
||||
return []string{w.ID, w.Path, strings.TrimSuffix(w.ID, path.Ext(w.ID)), w.Name}
|
||||
}
|
||||
for _, fold := range []bool{false, true} {
|
||||
var matches []Workflow
|
||||
for _, item := range items {
|
||||
if slices.ContainsFunc(keys(item), func(key string) bool { return key == raw || (fold && strings.EqualFold(key, raw)) }) {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
continue
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
}
|
||||
files := make([]string, len(matches))
|
||||
for i, match := range matches {
|
||||
files[i] = match.ID
|
||||
}
|
||||
return Workflow{}, fmt.Errorf("workflow %q is ambiguous in %s (%s); pass the file name", raw, scope, strings.Join(files, ", "))
|
||||
}
|
||||
return Workflow{}, NotFound("workflow", strconv.Quote(raw), scope)
|
||||
}
|
||||
|
||||
// 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 api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound && strings.Contains(api.Message, "ref") {
|
||||
return DispatchResult{}, &NotFoundError{Kind: "ref", Key: strconv.Quote(ref), Scope: s.Repository, Err: err}
|
||||
}
|
||||
if err != nil {
|
||||
return DispatchResult{}, err
|
||||
return DispatchResult{}, Classify(err, "workflow", id, s.Repository)
|
||||
}
|
||||
return DispatchResult{ID: details.WorkflowRunID, URL: details.HTMLURL}, nil
|
||||
}
|
||||
@@ -136,181 +139,104 @@ func (s Actions) SetWorkflowEnabled(ctx context.Context, id string, enabled bool
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// WorkflowFile returns the workflow's YAML definition at ref, a branch, tag or commit, or on the
|
||||
// default branch when ref is empty.
|
||||
func (s Actions) WorkflowFile(ctx context.Context, workflow Workflow, ref string) ([]byte, error) {
|
||||
contents, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ContentsResponse, *gitea.Response, error) {
|
||||
return c.Repositories.GetContents(ctx, owner, repo, ref, workflow.Path)
|
||||
})
|
||||
if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound {
|
||||
// Gitea reports an unknown ref as "object does not exist [id: REF, rel_path: ]".
|
||||
if ref != "" && strings.Contains(api.Message, "[id: "+ref+",") {
|
||||
return nil, &NotFoundError{Kind: "ref", Key: strconv.Quote(ref), Scope: s.Repository, Err: err}
|
||||
}
|
||||
return nil, &NotFoundError{Kind: "workflow file", Key: workflow.Path, Scope: s.Repository, Err: err}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contents.Content == nil {
|
||||
return nil, fmt.Errorf("%s is not a file in %s", workflow.Path, s.Repository)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(*contents.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid contents of %s: %w", workflow.Path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
// WorkflowInput is one input that a workflow's workflow_dispatch trigger declares.
|
||||
type WorkflowInput struct {
|
||||
Name, Description string
|
||||
// Type is string, choice, boolean, number or environment; empty means string.
|
||||
Type, Default string
|
||||
Required bool
|
||||
Options []string
|
||||
}
|
||||
|
||||
// ErrNoDispatch reports a workflow that has no workflow_dispatch trigger.
|
||||
var ErrNoDispatch = errors.New("workflow has no workflow_dispatch trigger")
|
||||
|
||||
// DispatchInputs parses the inputs that the workflow_dispatch trigger of a workflow definition
|
||||
// declares, in their order. It fails with ErrNoDispatch when the workflow cannot be dispatched.
|
||||
func DispatchInputs(definition []byte) ([]WorkflowInput, error) {
|
||||
var workflow struct {
|
||||
On yaml.Node `yaml:"on"`
|
||||
}
|
||||
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)
|
||||
if err := yaml.Unmarshal(definition, &workflow); err != nil {
|
||||
return nil, fmt.Errorf("invalid workflow definition: %w", err)
|
||||
}
|
||||
on := workflow.On
|
||||
switch on.Kind {
|
||||
case yaml.ScalarNode:
|
||||
if on.Value == "workflow_dispatch" {
|
||||
return []WorkflowInput{}, nil
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
if slices.ContainsFunc(on.Content, func(event *yaml.Node) bool { return event.Value == "workflow_dispatch" }) {
|
||||
return []WorkflowInput{}, nil
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
if trigger := mappingValue(&on, "workflow_dispatch"); trigger != nil {
|
||||
return dispatchInputs(mappingValue(trigger, "inputs"))
|
||||
}
|
||||
}
|
||||
return nil, ErrNoDispatch
|
||||
}
|
||||
|
||||
func dispatchInputs(node *yaml.Node) ([]WorkflowInput, error) {
|
||||
inputs := []WorkflowInput{}
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return inputs, nil
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
var spec struct {
|
||||
Description string `yaml:"description"`
|
||||
Type string `yaml:"type"`
|
||||
Default yaml.Node `yaml:"default"`
|
||||
Required bool `yaml:"required"`
|
||||
Options []string `yaml:"options"`
|
||||
}
|
||||
if err := node.Content[i+1].Decode(&spec); err != nil {
|
||||
return nil, fmt.Errorf("invalid workflow input %s: %w", node.Content[i].Value, err)
|
||||
}
|
||||
inputs = append(inputs, WorkflowInput{
|
||||
Name: node.Content[i].Value, Description: spec.Description, Type: spec.Type,
|
||||
Default: spec.Default.Value, Required: spec.Required, Options: spec.Options,
|
||||
})
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
// mappingValue returns the value of key in a YAML mapping, or nil.
|
||||
func mappingValue(node *yaml.Node, key string) *yaml.Node {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
return node.Content[i+1]
|
||||
}
|
||||
}
|
||||
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 {
|
||||
created, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, errors.New("server returned an invalid run creation timestamp")
|
||||
}
|
||||
// Runs are listed newest first, so the rest were created earlier still.
|
||||
if opts.Since != nil && created.Before(*opts.Since) {
|
||||
return result, nil
|
||||
}
|
||||
if opts.Until != nil && created.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
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -75,22 +76,34 @@ func TestWorkflowsMapDefinitions(t *testing.T) {
|
||||
require.Equal(t, "disabled_manually", item.State)
|
||||
}
|
||||
|
||||
func TestWatchChangesAndFailureConclusion(t *testing.T) {
|
||||
calls := 0
|
||||
func TestWatchReportsEveryPollWithJobsUntilTheRunCompletes(t *testing.T) {
|
||||
polls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls <= 2 {
|
||||
_, _ = w.Write([]byte(`{"id":1,"status":"in_progress"}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"id":1,"status":"completed","conclusion":"failure"}`))
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/repo/actions/workflows":
|
||||
_, _ = w.Write([]byte(`{"workflows":[{"id":"ci.yml","name":"CI"}]}`))
|
||||
case "/api/v1/repos/owner/repo/actions/runs/1":
|
||||
polls++
|
||||
if polls < 3 {
|
||||
_, _ = w.Write([]byte(`{"id":1,"path":"ci.yml@refs/heads/main","status":"in_progress"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":1,"path":"ci.yml@refs/heads/main","status":"completed","conclusion":"failure"}`))
|
||||
case "/api/v1/repos/owner/repo/actions/runs/1/jobs":
|
||||
_, _ = w.Write([]byte(`{"jobs":[{"id":2,"name":"build","status":"in_progress"}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
changes := []Run{}
|
||||
var updates []string
|
||||
service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}
|
||||
final, err := service.Watch(context.Background(), 1, time.Millisecond, func(run Run) error { changes = append(changes, run); return nil })
|
||||
final, err := service.Watch(context.Background(), 1, time.Millisecond, func(run Run) error {
|
||||
updates = append(updates, run.Workflow+" "+run.Status+" "+run.Jobs[0].Name)
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes, 2)
|
||||
require.Equal(t, []string{"CI in_progress build", "CI in_progress build", "CI completed build"}, updates)
|
||||
require.False(t, SuccessfulRun(final))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -137,10 +150,10 @@ func TestActionMutationsUseTheirEndpoints(t *testing.T) {
|
||||
return s.SetWorkflowEnabled(context.Background(), "ci.yml", false)
|
||||
}},
|
||||
{"rerun", "/api/v1/repos/owner/repo/actions/runs/7/rerun", http.MethodPost, func(s Actions) error {
|
||||
return s.Rerun(context.Background(), 7, false)
|
||||
return s.Rerun(context.Background(), 7, 0, false)
|
||||
}},
|
||||
{"rerun-failed", "/api/v1/repos/owner/repo/actions/runs/7/rerun-failed-jobs", http.MethodPost, func(s Actions) error {
|
||||
return s.Rerun(context.Background(), 7, true)
|
||||
return s.Rerun(context.Background(), 7, 0, true)
|
||||
}},
|
||||
{"delete", "/api/v1/repos/owner/repo/actions/runs/7", http.MethodDelete, func(s Actions) error {
|
||||
return s.DeleteRun(context.Background(), 7)
|
||||
@@ -209,6 +222,7 @@ func TestRunTimeFilteringPaginatesMatchingResults(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("page") {
|
||||
case "1":
|
||||
w.Header().Set("Link", `<`+r.URL.Path+`?page=2>; rel="next"`)
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":4,"created_at":"2028-01-03T00:00:00Z"},{"id":3,"created_at":"2028-01-01T12:00:00Z"}]}`))
|
||||
case "2":
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":2,"created_at":"2028-01-01T00:00:00Z"}]}`))
|
||||
@@ -254,7 +268,9 @@ func TestRunTimeFilteringStopsAtScanCap(t *testing.T) {
|
||||
pages := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pages++
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":1,"created_at":"2028-12-31T00:00:00Z"}]}`))
|
||||
w.Header().Set("Link", `<`+r.URL.Path+`?page=2>; rel="next"`)
|
||||
run := `{"id":1,"created_at":"2028-12-31T00:00:00Z"}`
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[` + strings.Repeat(run+",", 99) + run + `]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}
|
||||
@@ -262,3 +278,78 @@ func TestRunTimeFilteringStopsAtScanCap(t *testing.T) {
|
||||
require.ErrorContains(t, err, "narrow the search")
|
||||
require.Equal(t, maxRunScanPages, pages)
|
||||
}
|
||||
|
||||
func TestFindWorkflowByFileStemPathOrName(t *testing.T) {
|
||||
workflows := []Workflow{
|
||||
{ID: "ci.yml", Name: "CI", Path: ".gitea/workflows/ci.yml"},
|
||||
{ID: "nightly.yaml", Name: "Nightly build", Path: ".gitea/workflows/nightly.yaml"},
|
||||
{ID: "deploy.yml", Name: "Deploy", Path: ".gitea/workflows/deploy.yml"},
|
||||
{ID: "deploy.yaml", Name: "Deploy staging", Path: ".gitea/workflows/deploy.yaml"},
|
||||
}
|
||||
for raw, want := range map[string]string{
|
||||
"ci.yml": "ci.yml", "ci": "ci.yml", "CI": "ci.yml", ".gitea/workflows/nightly.yaml": "nightly.yaml",
|
||||
"nightly build": "nightly.yaml", "Deploy": "deploy.yml", "deploy.yaml": "deploy.yaml",
|
||||
} {
|
||||
workflow, err := FindWorkflow(workflows, raw, "alice/x")
|
||||
require.NoError(t, err, raw)
|
||||
require.Equal(t, want, workflow.ID, raw)
|
||||
}
|
||||
_, err := FindWorkflow(workflows, "deploy", "alice/x")
|
||||
require.EqualError(t, err, `workflow "deploy" is ambiguous in alice/x (deploy.yml, deploy.yaml); pass the file name`)
|
||||
_, err = FindWorkflow(workflows, "release", "alice/x")
|
||||
require.EqualError(t, err, `workflow "release" not found in alice/x`)
|
||||
}
|
||||
|
||||
func TestDispatchInputsReadTheWorkflowDispatchTrigger(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, definition string
|
||||
want []WorkflowInput
|
||||
err error
|
||||
}{
|
||||
{"event name", "on: workflow_dispatch\n", []WorkflowInput{}, nil},
|
||||
{"event list", "on: [push, workflow_dispatch]\n", []WorkflowInput{}, nil},
|
||||
{"trigger without inputs", "on:\n workflow_dispatch:\n", []WorkflowInput{}, nil},
|
||||
{"inputs in order", `on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: Deploy target
|
||||
required: true
|
||||
default: staging
|
||||
type: choice
|
||||
options: [staging, production]
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: true
|
||||
`, []WorkflowInput{
|
||||
{Name: "target", Description: "Deploy target", Type: "choice", Default: "staging", Required: true, Options: []string{"staging", "production"}},
|
||||
{Name: "dry_run", Type: "boolean", Default: "true"},
|
||||
}, nil},
|
||||
{"no trigger", "on:\n push:\n", nil, ErrNoDispatch},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inputs, err := DispatchInputs([]byte(tc.definition))
|
||||
require.ErrorIs(t, err, tc.err)
|
||||
require.Equal(t, tc.want, inputs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowFileTellsAMissingRefFromAMissingFile(t *testing.T) {
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v1/repos/owner/repo/contents/.gitea/workflows/ci.yml", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
ref := r.URL.Query().Get("ref")
|
||||
if ref == "gone" {
|
||||
_, _ = fmt.Fprintf(w, `{"message":"object does not exist [id: %s, rel_path: ]"}`, ref)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"message":"object does not exist [id: 0123abc, rel_path: .gitea/workflows/ci.yml]"}`))
|
||||
}))
|
||||
service := Actions{Client: client, Repository: "owner/repo"}
|
||||
workflow := Workflow{ID: "ci.yml", Path: ".gitea/workflows/ci.yml"}
|
||||
_, err := service.WorkflowFile(context.Background(), workflow, "gone")
|
||||
require.EqualError(t, err, `ref "gone" not found in owner/repo`)
|
||||
_, err = service.WorkflowFile(context.Background(), workflow, "main")
|
||||
require.EqualError(t, err, "workflow file .gitea/workflows/ci.yml not found in owner/repo")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
)
|
||||
@@ -30,15 +31,40 @@ func artifactFrom(artifact *gitea.ActionsArtifact) Artifact {
|
||||
}
|
||||
}
|
||||
|
||||
// Artifacts lists one page of artifacts belonging to a run.
|
||||
func (s Actions) Artifacts(ctx context.Context, run int64, limit, page int) ([]Artifact, error) {
|
||||
// Artifacts lists one page of artifacts belonging to a run, with their total number.
|
||||
func (s Actions) Artifacts(ctx context.Context, run int64, limit, page int) (Page[Artifact], error) {
|
||||
response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsArtifactsResponse, *gitea.Response, error) {
|
||||
return c.Actions.ListRepoRunArtifacts(ctx, owner, repo, run, gitea.ListActionsArtifactsOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Page[Artifact]{}, Classify(err, "run", strconv.FormatInt(run, 10), s.Repository)
|
||||
}
|
||||
return convertAll(response.Artifacts, artifactFrom), nil
|
||||
total := int(response.TotalCount)
|
||||
return Page[Artifact]{Items: convertAll(response.Artifacts, artifactFrom), More: total > page*limit, Total: total, Counted: true}, nil
|
||||
}
|
||||
|
||||
// AllArtifacts lists every artifact belonging to a run.
|
||||
func (s Actions) AllArtifacts(ctx context.Context, run int64) ([]Artifact, error) {
|
||||
artifacts := []Artifact{}
|
||||
for page := 1; page <= maxListPages; page++ {
|
||||
window, err := s.Artifacts(ctx, run, 100, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifacts = append(artifacts, window.Items...)
|
||||
if !window.More || len(window.Items) == 0 {
|
||||
return artifacts, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("listing exceeded %d pages", maxListPages)
|
||||
}
|
||||
|
||||
// Artifact retrieves one artifact.
|
||||
func (s Actions) Artifact(ctx context.Context, id int64) (Artifact, error) {
|
||||
artifact, err := repoConvert(ctx, s.Client, s.Repository, artifactFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsArtifact, *gitea.Response, error) {
|
||||
return c.Actions.GetRepoArtifact(ctx, owner, repo, id)
|
||||
})
|
||||
return artifact, Classify(err, "artifact", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
|
||||
// DownloadArtifact streams an artifact ZIP, accepting a scoped signed redirect.
|
||||
@@ -49,7 +75,7 @@ func (s Actions) DownloadArtifact(ctx context.Context, id int64) (io.ReadCloser,
|
||||
}
|
||||
response, err := s.Client.DownloadSigned(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, Classify(err, "artifact", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
return response.Body, nil
|
||||
}
|
||||
@@ -25,16 +25,16 @@ func TestArtifactListingProjectsMetadataAndEmptyArrays(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"artifacts":[{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":false,"expires_at":null,"workflow_run":{"head_sha":"excluded"},"archive_download_url":"excluded"}]}`))
|
||||
}))
|
||||
service := Actions{Client: client, Repository: "owner/repo"}
|
||||
items, err := service.Artifacts(context.Background(), 7, 20, 1)
|
||||
page, err := service.Artifacts(context.Background(), 7, 20, 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, items, 1)
|
||||
data, err := json.Marshal(items)
|
||||
require.Len(t, page.Items, 1)
|
||||
data, err := json.Marshal(page.Items)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `[{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":false,"created_at":"","updated_at":"","expires_at":null}]`, string(data))
|
||||
items, err = service.Artifacts(context.Background(), 7, 20, 2)
|
||||
page, err = service.Artifacts(context.Background(), 7, 20, 2)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, items)
|
||||
require.NotNil(t, items)
|
||||
require.Empty(t, page.Items)
|
||||
require.NotNil(t, page.Items)
|
||||
}
|
||||
|
||||
func TestArtifactFromMapsServerFields(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user