mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 14:03:24 +09:00
feat(actions): Add Actions API endpoints for workflow run management and logs (#35382)
Implements the missing REST API endpoints for Actions workflow run
management:
1. `POST /actions/runs/{run}/cancel` cancels a run and its jobs, `409`
when it already finished
1. `POST /actions/runs/{run}/approve` approves a run awaiting approval,
idempotent, `409` when it never awaited one
1. `GET /actions/runs/{run}/logs` downloads the latest attempt's job
logs as a zip archive
`ActionWorkflowRun` gains `created_at`, `updated_at` and the `jobs_url`,
`logs_url`, `artifacts_url`, `cancel_url` and `rerun_url` fields, and
now always emits `conclusion` and `head_branch`.
Cancellation is shared with the web handler in `services/actions`.
Fixes https://github.com/go-gitea/gitea/issues/35176
Fixes https://github.com/go-gitea/gitea/issues/36554
---------
Co-authored-by: Claude Sonnet 4.6 <claude-sonnet-4-6@anthropic.com>
Co-authored-by: OpenCode Agent <opencode@rossgolder.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
OpenCode Agent
Nicolas
silverwind
wxiaoguang
parent
c5b6e044d7
commit
a65f422b89
@@ -92,6 +92,13 @@ func (run *ActionRun) Link() string {
|
|||||||
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.ID)
|
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (run *ActionRun) APIURL(ctx context.Context) string {
|
||||||
|
if run.Repo == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/actions/runs/%d", run.Repo.APIURL(ctx), run.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (run *ActionRun) WorkflowLink() string {
|
func (run *ActionRun) WorkflowLink() string {
|
||||||
if run.Repo == nil {
|
if run.Repo == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -261,12 +261,16 @@ func GetLatestAttemptJobsByRepoAndRunID(ctx context.Context, repoID, runID int64
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return GetLatestAttemptJobsByRun(ctx, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLatestAttemptJobsByRun(ctx context.Context, run *ActionRun) (ActionJobList, error) {
|
||||||
if run.LatestAttemptID > 0 {
|
if run.LatestAttemptID > 0 {
|
||||||
return GetRunJobsByRunAndAttemptID(ctx, runID, run.LatestAttemptID)
|
return GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var jobs []*ActionRunJob
|
var jobs []*ActionRunJob
|
||||||
if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=0", repoID, runID).OrderBy("id").Find(&jobs); err != nil {
|
if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=0", run.RepoID, run.ID).OrderBy("id").Find(&jobs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return jobs, nil
|
return jobs, nil
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
|||||||
func (opts FindRunOptions) ToJoins() []db.JoinFunc {
|
func (opts FindRunOptions) ToJoins() []db.JoinFunc {
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID > 0 {
|
||||||
return []db.JoinFunc{func(sess db.Engine) error {
|
return []db.JoinFunc{func(sess db.Engine) error {
|
||||||
sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID)
|
sess.Join("INNER", "repository", "repository.id = action_run.repo_id AND repository.owner_id = ?", opts.OwnerID)
|
||||||
return nil
|
return nil
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,15 @@ func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) {
|
|||||||
return &task, nil
|
return &task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTasksMapByIDs returns the found tasks keyed by ID, silently omitting IDs that no longer exist.
|
||||||
|
func GetTasksMapByIDs(ctx context.Context, ids []int64) (map[int64]*ActionTask, error) {
|
||||||
|
tasks := make(map[int64]*ActionTask, len(ids))
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
return tasks, db.GetEngine(ctx).In("id", ids).Find(&tasks)
|
||||||
|
}
|
||||||
|
|
||||||
func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) {
|
func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) {
|
||||||
errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist)
|
errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|||||||
+11
-5
@@ -192,16 +192,22 @@ func OpenLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeek
|
|||||||
return nil, fmt.Errorf("storage open %q: %w", filename, err)
|
return nil, fmt.Errorf("storage open %q: %w", filename, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var reader io.ReadSeekCloser = f
|
|
||||||
if strings.HasSuffix(filename, ".zst") {
|
if strings.HasSuffix(filename, ".zst") {
|
||||||
r, err := zstd.NewSeekableReader(f)
|
reader, err := zstd.NewSeekableReader(f) // reads the seek table, so a lazily opened object already fails here
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("zstd NewSeekableReader: %w", err)
|
f.Close()
|
||||||
|
return nil, fmt.Errorf("zstd NewSeekableReader %q: %w", filename, err)
|
||||||
}
|
}
|
||||||
reader = r
|
return reader, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return reader, nil
|
// object storage opens lazily, force a missing object to surface before the caller commits a response
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, fmt.Errorf("storage open %q: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatLog(timestamp time.Time, content string) string {
|
func FormatLog(timestamp time.Time, content string) string {
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ func (m *minioObject) Stat() (os.FileInfo, error) {
|
|||||||
return &minioFileInfo{oi}, nil
|
return &minioFileInfo{oi}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// minio reports a missing key on the first Read, ReadAt or Seek rather than on Open, so all
|
||||||
|
// of them convert it like Stat does.
|
||||||
|
func (m *minioObject) Read(p []byte) (int, error) {
|
||||||
|
n, err := m.Object.Read(p)
|
||||||
|
return n, convertMinioErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *minioObject) ReadAt(p []byte, off int64) (int, error) {
|
||||||
|
n, err := m.Object.ReadAt(p, off)
|
||||||
|
return n, convertMinioErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *minioObject) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
n, err := m.Object.Seek(offset, whence)
|
||||||
|
return n, convertMinioErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
// MinioStorage returns a minio bucket storage
|
// MinioStorage returns a minio bucket storage
|
||||||
type MinioStorage struct {
|
type MinioStorage struct {
|
||||||
cfg *setting.MinioStorageConfig
|
cfg *setting.MinioStorageConfig
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ type ActionWorkflowRun struct {
|
|||||||
// It is set only when the current attempt is > 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null.
|
// It is set only when the current attempt is > 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null.
|
||||||
PreviousAttemptURL *string `json:"previous_attempt_url"`
|
PreviousAttemptURL *string `json:"previous_attempt_url"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
JobsURL string `json:"jobs_url"`
|
||||||
|
LogsURL string `json:"logs_url"`
|
||||||
|
ArtifactsURL string `json:"artifacts_url"`
|
||||||
|
CancelURL string `json:"cancel_url"`
|
||||||
|
RerunURL string `json:"rerun_url"`
|
||||||
DisplayTitle string `json:"display_title"`
|
DisplayTitle string `json:"display_title"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
@@ -130,6 +135,10 @@ type ActionWorkflowRun struct {
|
|||||||
Conclusion string `json:"conclusion,omitempty"`
|
Conclusion string `json:"conclusion,omitempty"`
|
||||||
PullRequests []*PullRequestMinimal `json:"pull_requests"`
|
PullRequests []*PullRequestMinimal `json:"pull_requests"`
|
||||||
// swagger:strfmt date-time
|
// swagger:strfmt date-time
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
// swagger:strfmt date-time
|
// swagger:strfmt date-time
|
||||||
CompletedAt time.Time `json:"completed_at"`
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileNameJoinFields joins the fields by separator "-" as a safe filename,
|
||||||
|
// the last field is used as extname (no sep is added before it).
|
||||||
|
// This function does its best to avoid the filename exceeding the max filename length (255 bytes on Linux),
|
||||||
|
// Each field needs at least about 3 bytes, if the caller passes too many fields (e.g.: 100 fields),
|
||||||
|
// then it's unavoidable, just do not write such code in real world.
|
||||||
|
func FileNameJoinFields(fields ...any) string {
|
||||||
|
// linux max filename length is 255, we leave some space for the separators and extname
|
||||||
|
const maxFilenameLen = 210
|
||||||
|
return fileNameJoinFields(maxFilenameLen, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileNameJoinFields(stemNameLimit int, fields ...any) string {
|
||||||
|
// stemNameLimit is just a suggested limit, when adding more fields, the length might still exceed a little
|
||||||
|
sb := strings.Builder{}
|
||||||
|
for i, f := range fields {
|
||||||
|
var field string
|
||||||
|
switch v := f.(type) {
|
||||||
|
case string:
|
||||||
|
field = v
|
||||||
|
case int64:
|
||||||
|
field = strconv.FormatInt(v, 10)
|
||||||
|
default:
|
||||||
|
field = fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
field = PathNameValidator().InvalidChars.ReplaceAllString(field, "_")
|
||||||
|
if i < len(fields)-1 {
|
||||||
|
field = strings.ReplaceAll(strings.ReplaceAll(field, ".", "_"), "-", "_")
|
||||||
|
estimatedRemainingLen := (len(fields) - 1 - i) * 3
|
||||||
|
estimatedLimit := stemNameLimit - estimatedRemainingLen
|
||||||
|
if sb.Len()+len(field) > estimatedLimit {
|
||||||
|
field = TruncateStringBytes(field, estimatedLimit-sb.Len()-2) + "__"
|
||||||
|
}
|
||||||
|
sb.WriteString(field)
|
||||||
|
if i < len(fields)-2 {
|
||||||
|
sb.WriteString("-")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// last field is extname, no need to do more processing
|
||||||
|
sb.WriteString(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileNameJoinFields(t *testing.T) {
|
||||||
|
assert.Equal(t, "a_b-1.txt", FileNameJoinFields("a-b", 1, ".txt"))
|
||||||
|
assert.Equal(t, "a-____-b.txt", FileNameJoinFields("a", "/../", "b", ".txt"))
|
||||||
|
assert.Equal(t, "a-____-b.txt", FileNameJoinFields("a", "\\..\\", "b", ".txt"))
|
||||||
|
|
||||||
|
assert.Equal(t, "🌞-🌛.txt", fileNameJoinFields(14, "🌞", "🌛", ".txt"))
|
||||||
|
assert.Equal(t, "🌞-🌛__.txt", fileNameJoinFields(14, "🌞", "🌛🌛🌛🌛", ".txt"))
|
||||||
|
assert.Equal(t, "🌞__-__.txt", fileNameJoinFields(14, "🌞🌞🌞🌞", "🌛🌛🌛🌛", ".txt"))
|
||||||
|
}
|
||||||
@@ -10,9 +10,27 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var PathNameValidator = sync.OnceValue(func() (ret struct {
|
||||||
|
InvalidChars *regexp.Regexp
|
||||||
|
InvalidNames *regexp.Regexp
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
ret.InvalidChars = regexp.MustCompile(`(?i)[<>:"/\\|?*\x{0000}-\x{001F}]`)
|
||||||
|
// invalid filename contents, based on https://github.com/sindresorhus/filename-reserved-regex
|
||||||
|
// "COM10" needs to be opened with UNC "\\.\COM10" on Windows, so itself is valid
|
||||||
|
ret.InvalidNames = regexp.MustCompile(`(?i)^(con|prn|aux|nul|com\d|lpt\d)$`)
|
||||||
|
return ret
|
||||||
|
})
|
||||||
|
|
||||||
|
func PathBaseStem(s string) string {
|
||||||
|
return strings.TrimSuffix(path.Base(s), path.Ext(s))
|
||||||
|
}
|
||||||
|
|
||||||
// PathJoinRel joins the path elements into a single path, each element is cleaned by path.Clean separately.
|
// PathJoinRel joins the path elements into a single path, each element is cleaned by path.Clean separately.
|
||||||
// It only returns the following values (like path.Join), any redundant part (empty, relative dots, slashes) is removed.
|
// It only returns the following values (like path.Join), any redundant part (empty, relative dots, slashes) is removed.
|
||||||
// It's caller's duty to make every element not bypass its own directly level, to avoid security issues.
|
// It's caller's duty to make every element not bypass its own directly level, to avoid security issues.
|
||||||
|
|||||||
@@ -130,3 +130,17 @@ func TruncateRunes(str string, limit int) string {
|
|||||||
}
|
}
|
||||||
return string([]rune(str)[:limit])
|
return string([]rune(str)[:limit])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TruncateStringBytes returns a truncated string with given byte limit,
|
||||||
|
// it returns input string if its byte length doesn't exceed the limit.
|
||||||
|
func TruncateStringBytes(str string, limit int) string {
|
||||||
|
l := 0
|
||||||
|
for i, r := range str {
|
||||||
|
rl := utf8.RuneLen(r)
|
||||||
|
if l+rl > limit {
|
||||||
|
return str[:i]
|
||||||
|
}
|
||||||
|
l += rl
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|||||||
@@ -1362,8 +1362,13 @@ func Routes() *web.Router {
|
|||||||
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
|
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
|
||||||
m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun)
|
m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun)
|
||||||
m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun)
|
m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun)
|
||||||
m.Get("/jobs", repo.ListWorkflowRunJobs)
|
m.Post("/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelWorkflowRun)
|
||||||
m.Post("/jobs/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob)
|
m.Post("/approve", reqToken(), reqRepoWriter(unit.TypeActions), repo.ApproveWorkflowRun)
|
||||||
|
m.Group("/jobs", func() {
|
||||||
|
m.Get("", repo.ListWorkflowRunJobs)
|
||||||
|
m.Post("/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob)
|
||||||
|
})
|
||||||
|
m.Get("/logs", reqToken(), repo.GetWorkflowRunLogs)
|
||||||
m.Get("/artifacts", repo.GetArtifactsOfRun)
|
m.Get("/artifacts", repo.GetArtifactsOfRun)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1291,7 +1291,7 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
jobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -1315,6 +1315,16 @@ func getCurrentRepoActionRunAttemptByNumber(ctx *context.APIContext) (*actions_m
|
|||||||
return run, attempt
|
return run, attempt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func respondRepoActionWorkflowRun(ctx *context.APIContext, run *actions_model.ActionRun) {
|
||||||
|
run.Repo = ctx.Repo.Repository
|
||||||
|
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, convertedRun)
|
||||||
|
}
|
||||||
|
|
||||||
// GetWorkflowRun Gets a specific workflow run.
|
// GetWorkflowRun Gets a specific workflow run.
|
||||||
func GetWorkflowRun(ctx *context.APIContext) {
|
func GetWorkflowRun(ctx *context.APIContext) {
|
||||||
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun
|
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun
|
||||||
@@ -1351,12 +1361,7 @@ func GetWorkflowRun(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false)
|
respondRepoActionWorkflowRun(ctx, run)
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.JSON(http.StatusOK, convertedRun)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkflowRunAttempt Gets a specific workflow run attempt.
|
// GetWorkflowRunAttempt Gets a specific workflow run attempt.
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/routers/common"
|
"gitea.dev/routers/common"
|
||||||
|
actions_service "gitea.dev/services/actions"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,13 +48,157 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err = curJob.LoadRepo(ctx); err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob)
|
err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CancelWorkflowRun(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/cancel repository cancelWorkflowRun
|
||||||
|
// ---
|
||||||
|
// summary: Cancel a workflow run and its jobs
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repository
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: run
|
||||||
|
// in: path
|
||||||
|
// description: run ID
|
||||||
|
// type: integer
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/WorkflowRun"
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "409":
|
||||||
|
// "$ref": "#/responses/conflict"
|
||||||
|
|
||||||
|
run, jobs := getCurrentRepoActionRunJobsByID(ctx)
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancelling a finished run would change nothing, so report a conflict instead of a false success.
|
||||||
|
if run.Status.IsDone() {
|
||||||
|
ctx.APIError(http.StatusConflict, "run is already completed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
run, err := actions_service.CancelRun(ctx, run, jobs)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorAuto(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondRepoActionWorkflowRun(ctx, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApproveWorkflowRun(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/approve repository approveWorkflowRun
|
||||||
|
// ---
|
||||||
|
// summary: Approve a workflow run that requires approval
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repository
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: run
|
||||||
|
// in: path
|
||||||
|
// description: run ID
|
||||||
|
// type: integer
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/WorkflowRun"
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "409":
|
||||||
|
// "$ref": "#/responses/conflict"
|
||||||
|
|
||||||
|
run := getCurrentRepoActionRunByID(ctx)
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !run.NeedApproval {
|
||||||
|
// Approving twice is idempotent, but a run that never awaited approval gets 409 rather
|
||||||
|
// than GitHub's 403, which would be indistinguishable from a permission denial.
|
||||||
|
if run.ApprovedBy == 0 {
|
||||||
|
ctx.APIError(http.StatusConflict, "run does not require approval")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondRepoActionWorkflowRun(ctx, run)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
approvedRuns, err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID})
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorAuto(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondRepoActionWorkflowRun(ctx, approvedRuns[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWorkflowRunLogs(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogs
|
||||||
|
// ---
|
||||||
|
// summary: Download workflow run logs as archive
|
||||||
|
// produces:
|
||||||
|
// - application/zip
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repository
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: run
|
||||||
|
// in: path
|
||||||
|
// description: run ID
|
||||||
|
// type: integer
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// description: Logs archive
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
run := getCurrentRepoActionRunByID(ctx)
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := common.DownloadActionsRunAllJobLogs(ctx.Base, run); err != nil {
|
||||||
|
ctx.APIErrorAuto(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
res := new(api.ActionWorkflowRunsResponse)
|
res := new(api.ActionWorkflowRunsResponse)
|
||||||
res.TotalCount = total
|
|
||||||
|
|
||||||
runList := actions_model.RunList(runs)
|
runList := actions_model.RunList(runs)
|
||||||
if err := runList.LoadTriggerUser(ctx); err != nil {
|
if err := runList.LoadTriggerUser(ctx); err != nil {
|
||||||
@@ -225,16 +224,23 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.Entries = make([]*api.ActionWorkflowRun, len(runs))
|
res.Entries = make([]*api.ActionWorkflowRun, 0, len(runs))
|
||||||
for i := range runs {
|
for _, run := range runs {
|
||||||
|
if run.Repo == nil {
|
||||||
|
// Orphaned row: drop it rather than failing the page, so total stays an upper bound
|
||||||
|
// until "doctor check --run check-db-consistency" removes it.
|
||||||
|
total--
|
||||||
|
continue
|
||||||
|
}
|
||||||
// TODO: load run attempts in batch
|
// TODO: load run attempts in batch
|
||||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i], nil, excludePullRequests)
|
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, excludePullRequests)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res.Entries[i] = convertedRun
|
res.Entries = append(res.Entries, convertedRun)
|
||||||
}
|
}
|
||||||
|
res.TotalCount = total
|
||||||
ctx.SetLinkHeader(total, listOptions.PageSize)
|
ctx.SetLinkHeader(total, listOptions.PageSize)
|
||||||
ctx.SetTotalCountHeader(total)
|
ctx.SetTotalCountHeader(total)
|
||||||
ctx.JSON(http.StatusOK, &res)
|
ctx.JSON(http.StatusOK, &res)
|
||||||
|
|||||||
+82
-24
@@ -4,68 +4,126 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"strings"
|
|
||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/modules/actions"
|
"gitea.dev/modules/actions"
|
||||||
|
"gitea.dev/modules/container"
|
||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/services/context"
|
context_module "gitea.dev/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error {
|
func openTaskLogs(ctx context.Context, task *actions_model.ActionTask) (io.ReadSeekCloser, error) {
|
||||||
|
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
// convert fs err to our own error type so that the API can return a 404 instead of a 500
|
||||||
|
return nil, util.NewNotExistErrorf("unable to open task logs: %s", task.LogFilename)
|
||||||
|
}
|
||||||
|
return reader, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadActionsRunJobLogsWithID(ctx *context_module.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error {
|
||||||
job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID)
|
job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := job.LoadRepo(ctx); err != nil {
|
|
||||||
return fmt.Errorf("LoadRepo: %w", err)
|
|
||||||
}
|
|
||||||
return DownloadActionsRunJobLogs(ctx, ctxRepo, job)
|
return DownloadActionsRunJobLogs(ctx, ctxRepo, job)
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error {
|
// DownloadActionsRunAllJobLogs assumes the run was already resolved against the requesting repository.
|
||||||
if curJob.Repo.ID != ctxRepo.ID {
|
func DownloadActionsRunAllJobLogs(ctx *context_module.Base, run *actions_model.ActionRun) error {
|
||||||
return util.NewNotExistErrorf("job not found")
|
runJobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GetLatestAttemptJobsByRun: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
taskID := curJob.EffectiveTaskID()
|
tasks, err := actions_model.GetTasksMapByIDs(ctx, container.FilterSlice(runJobs, func(job *actions_model.ActionRunJob) (int64, bool) {
|
||||||
if taskID == 0 {
|
taskID := job.EffectiveTaskID()
|
||||||
return util.NewNotExistErrorf("job not started")
|
return taskID, taskID != 0
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GetTasksMapByIDs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay the headers until the first log opens, afterwards a failure can no longer reach
|
||||||
|
// the client, so the remaining entries are best-effort.
|
||||||
|
var zipWriter *zip.Writer
|
||||||
|
for _, job := range runJobs {
|
||||||
|
task := tasks[job.EffectiveTaskID()]
|
||||||
|
if task == nil || task.LogExpired {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := openTaskLogs(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to open logs of job %d: %v", job.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if zipWriter == nil {
|
||||||
|
ctx.SetServeHeaders(context_module.ServeHeaderOptions{
|
||||||
|
Filename: util.FileNameJoinFields(util.PathBaseStem(run.WorkflowID), "run", run.ID, "logs", ".zip"),
|
||||||
|
ContentType: "application/zip",
|
||||||
|
ContentDisposition: httplib.ContentDispositionAttachment,
|
||||||
|
})
|
||||||
|
zipWriter = zip.NewWriter(ctx.Resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
zipFile, err := zipWriter.Create(util.FileNameJoinFields(util.PathBaseStem(run.WorkflowID), job.Name, task.ID, ".log"))
|
||||||
|
if err == nil {
|
||||||
|
_, err = io.Copy(zipFile, reader)
|
||||||
|
}
|
||||||
|
reader.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to add logs of job %d to zip: %v", job.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zipWriter == nil {
|
||||||
|
return util.NewNotExistErrorf("logs not found")
|
||||||
|
}
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
log.Error("Failed to finalize logs zip of run %d: %v", run.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadActionsRunJobLogs(ctx *context_module.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error {
|
||||||
|
if curJob.RepoID != ctxRepo.ID {
|
||||||
|
return util.NewNotExistErrorf("job not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := curJob.LoadRun(ctx); err != nil {
|
if err := curJob.LoadRun(ctx); err != nil {
|
||||||
return fmt.Errorf("LoadRun: %w", err)
|
return fmt.Errorf("LoadRun: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
taskID := curJob.EffectiveTaskID()
|
||||||
|
if taskID == 0 {
|
||||||
|
return util.NewNotExistErrorf("job not started")
|
||||||
|
}
|
||||||
task, err := actions_model.GetTaskByID(ctx, taskID)
|
task, err := actions_model.GetTaskByID(ctx, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("GetTaskByID: %w", err)
|
return fmt.Errorf("GetTaskByID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.LogExpired {
|
if task.LogExpired {
|
||||||
return util.NewNotExistErrorf("logs have been cleaned up")
|
return util.NewNotExistErrorf("logs have been cleaned up")
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
|
reader, err := openTaskLogs(ctx, task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
return err
|
||||||
return util.NewNotExistErrorf("logs not found")
|
|
||||||
}
|
|
||||||
return fmt.Errorf("OpenLogs: %w", err)
|
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|
||||||
workflowName := curJob.Run.WorkflowID
|
ctx.ServeContent(reader, context_module.ServeHeaderOptions{
|
||||||
if p := strings.Index(workflowName, "."); p > 0 {
|
Filename: util.FileNameJoinFields(util.PathBaseStem(curJob.Run.WorkflowID), curJob.Name, task.ID, ".log"),
|
||||||
workflowName = workflowName[0:p]
|
|
||||||
}
|
|
||||||
ctx.ServeContent(reader, context.ServeHeaderOptions{
|
|
||||||
Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, curJob.Name, task.ID),
|
|
||||||
ContentLength: &task.LogSize,
|
ContentLength: &task.LogSize,
|
||||||
ContentType: "text/plain; charset=utf-8",
|
ContentType: "text/plain; charset=utf-8",
|
||||||
ContentDisposition: httplib.ContentDispositionAttachment,
|
ContentDisposition: httplib.ContentDispositionAttachment,
|
||||||
|
|||||||
@@ -1050,27 +1050,10 @@ func Cancel(ctx *context_module.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var updatedJobs []*actions_model.ActionRunJob
|
if _, err := actions_service.CancelRun(ctx, run, jobs); err != nil {
|
||||||
|
ctx.ServerError("CancelRun", err)
|
||||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
cancelledJobs, err := actions_model.CancelJobs(ctx, jobs)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cancel jobs: %w", err)
|
|
||||||
}
|
|
||||||
updatedJobs = append(updatedJobs, cancelledJobs...)
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
ctx.ServerError("StopTask", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...)
|
|
||||||
actions_service.EmitJobsIfReadyByJobs(updatedJobs)
|
|
||||||
|
|
||||||
actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
|
|
||||||
if len(updatedJobs) > 0 {
|
|
||||||
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID)
|
|
||||||
}
|
|
||||||
ctx.JSONOK()
|
ctx.JSONOK()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,7 +1062,7 @@ func Approve(ctx *context_module.Context) {
|
|||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil {
|
if _, err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil {
|
||||||
ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool {
|
ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool {
|
||||||
return errors.Is(err, util.ErrNotExist)
|
return errors.Is(err, util.ErrNotExist)
|
||||||
}, err)
|
}, err)
|
||||||
@@ -1345,7 +1328,7 @@ func ApproveAllChecks(ctx *context_module.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := actions_service.ApproveRuns(ctx, repo, ctx.Doer, runIDs); err != nil {
|
if _, err := actions_service.ApproveRuns(ctx, repo, ctx.Doer, runIDs); err != nil {
|
||||||
ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool {
|
ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool {
|
||||||
return errors.Is(err, util.ErrNotExist)
|
return errors.Is(err, util.ErrNotExist)
|
||||||
}, err)
|
}, err)
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/container"
|
"gitea.dev/modules/container"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) error {
|
// ApproveRuns returns the approved runs in the same order as runIDs.
|
||||||
|
func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) ([]*actions_model.ActionRun, error) {
|
||||||
updatedJobs := make([]*actions_model.ActionRunJob, 0)
|
updatedJobs := make([]*actions_model.ActionRunJob, 0)
|
||||||
cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0)
|
cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0)
|
||||||
// Track runs whose reusable callers were just expanded so we can re-emit after the tx commits.
|
// Track runs whose reusable callers were just expanded so we can re-emit after the tx commits.
|
||||||
@@ -36,7 +38,7 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
|||||||
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
|
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID)
|
jobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -107,7 +109,7 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-emit AFTER the tx commits so the newly inserted callee rows transition Blocked -> Waiting.
|
// Re-emit AFTER the tx commits so the newly inserted callee rows transition Blocked -> Waiting.
|
||||||
@@ -122,5 +124,26 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
|||||||
|
|
||||||
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
||||||
|
|
||||||
return nil
|
// The batches above already notified every run whose jobs changed, which is the only way
|
||||||
|
// approving alters a run's status, so reload purely to answer the caller.
|
||||||
|
reloaded, err := actions_model.GetRunsByRepoAndID(ctx, repo.ID, runIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetRunsByRepoAndID: %w", err)
|
||||||
|
}
|
||||||
|
runsByID := make(map[int64]*actions_model.ActionRun, len(reloaded))
|
||||||
|
for _, run := range reloaded {
|
||||||
|
run.Repo = repo // the caller resolved runIDs against this repo, so spare every consumer a reload
|
||||||
|
runsByID[run.ID] = run
|
||||||
|
}
|
||||||
|
|
||||||
|
approvedRuns := make([]*actions_model.ActionRun, 0, len(runIDs))
|
||||||
|
for _, runID := range runIDs {
|
||||||
|
run := runsByID[runID]
|
||||||
|
if run == nil {
|
||||||
|
return nil, util.NewNotExistErrorf("run %d no longer exists after approval", runID)
|
||||||
|
}
|
||||||
|
approvedRuns = append(approvedRuns, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
return approvedRuns, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,36 +32,64 @@ func TestApproveRuns(t *testing.T) {
|
|||||||
require.NoError(t, db.Insert(t.Context(), run))
|
require.NoError(t, db.Insert(t.Context(), run))
|
||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
insertJob := func(run *actions_model.ActionRun, status actions_model.Status) *actions_model.ActionRunJob {
|
insertJob := func(run *actions_model.ActionRun, status actions_model.Status, needs ...string) *actions_model.ActionRunJob {
|
||||||
job := &actions_model.ActionRunJob{
|
job := &actions_model.ActionRunJob{
|
||||||
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
|
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
|
||||||
Name: "job1", Attempt: 1, JobID: "job1", Status: status,
|
Name: "job1", Attempt: 1, JobID: "job1", Status: status,
|
||||||
RunsOn: []string{"ubuntu-latest"},
|
RunsOn: []string{"ubuntu-latest"}, Needs: needs,
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Insert(t.Context(), job))
|
require.NoError(t, db.Insert(t.Context(), job))
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("approve blocked run", func(t *testing.T) {
|
t.Run("approve unblocks a job with no dependencies", func(t *testing.T) {
|
||||||
run := insertRun(1001, actions_model.StatusBlocked, true, 0)
|
run := insertRun(1001, actions_model.StatusBlocked, true, 0)
|
||||||
job := insertJob(run, actions_model.StatusBlocked)
|
job := insertJob(run, actions_model.StatusBlocked)
|
||||||
|
|
||||||
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
|
approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, approved, 1)
|
||||||
|
assert.False(t, approved[0].NeedApproval)
|
||||||
|
assert.Equal(t, doer.ID, approved[0].ApprovedBy)
|
||||||
|
|
||||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
|
||||||
assert.False(t, run.NeedApproval)
|
|
||||||
assert.Equal(t, doer.ID, run.ApprovedBy)
|
|
||||||
assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
|
assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("a job with unmet dependencies stays blocked", func(t *testing.T) {
|
||||||
|
run := insertRun(1002, actions_model.StatusBlocked, true, 0)
|
||||||
|
job := insertJob(run, actions_model.StatusBlocked, "some-other-job")
|
||||||
|
|
||||||
|
approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, approved, 1)
|
||||||
|
assert.False(t, approved[0].NeedApproval)
|
||||||
|
|
||||||
|
assert.Equal(t, actions_model.StatusBlocked, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("re-approving an approved run is a no-op", func(t *testing.T) {
|
t.Run("re-approving an approved run is a no-op", func(t *testing.T) {
|
||||||
run := insertRun(1002, actions_model.StatusRunning, false, 4)
|
run := insertRun(1005, actions_model.StatusRunning, false, 4)
|
||||||
job := insertJob(run, actions_model.StatusRunning)
|
job := insertJob(run, actions_model.StatusRunning)
|
||||||
|
|
||||||
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
|
approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
require.Len(t, approved, 1)
|
||||||
assert.EqualValues(t, 4, run.ApprovedBy, "approver must not be overwritten")
|
assert.EqualValues(t, 4, approved[0].ApprovedBy, "approver must not be overwritten")
|
||||||
assert.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status, "started job must not be reset to waiting")
|
assert.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status, "started job must not be reset to waiting")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("approving several runs returns them in the requested order", func(t *testing.T) {
|
||||||
|
run1 := insertRun(1003, actions_model.StatusBlocked, true, 0)
|
||||||
|
insertJob(run1, actions_model.StatusBlocked)
|
||||||
|
run2 := insertRun(1004, actions_model.StatusBlocked, true, 0)
|
||||||
|
insertJob(run2, actions_model.StatusBlocked)
|
||||||
|
|
||||||
|
approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run2.ID, run1.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, approved, 2)
|
||||||
|
assert.Equal(t, run2.ID, approved[0].ID)
|
||||||
|
assert.Equal(t, run1.ID, approved[1].ID)
|
||||||
|
assert.False(t, approved[0].NeedApproval)
|
||||||
|
assert.False(t, approved[1].NeedApproval)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
actions_model "gitea.dev/models/actions"
|
||||||
|
"gitea.dev/models/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state.
|
||||||
|
func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (*actions_model.ActionRun, error) {
|
||||||
|
var updatedJobs []*actions_model.ActionRunJob
|
||||||
|
if err := db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||||
|
updatedJobs, err = actions_model.CancelJobs(ctx, jobs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CancelJobs: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateCommitStatusForRunJobs(ctx, run, jobs...)
|
||||||
|
EmitJobsIfReadyByJobs(updatedJobs)
|
||||||
|
NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
|
||||||
|
if len(updatedJobs) == 0 {
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, run.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetRunByRepoAndID: %w", err)
|
||||||
|
}
|
||||||
|
NotifyWorkflowRunStatusUpdate(ctx, reloaded)
|
||||||
|
return reloaded, nil
|
||||||
|
}
|
||||||
@@ -155,7 +155,8 @@ func TestApproveRuns_MaxParallel(t *testing.T) {
|
|||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
|
||||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
|
_, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, map[actions_model.Status]int{
|
assert.Equal(t, map[actions_model.Status]int{
|
||||||
actions_model.StatusWaiting: 2,
|
actions_model.StatusWaiting: 2,
|
||||||
@@ -203,7 +204,8 @@ func TestApproveRuns_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
|
|||||||
run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, true)
|
run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, true)
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
|
||||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
|
_, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, map[actions_model.Status]int{
|
assert.Equal(t, map[actions_model.Status]int{
|
||||||
actions_model.StatusWaiting: 1,
|
actions_model.StatusWaiting: 1,
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte
|
|||||||
completedAt = attempt.Stopped.AsLocalTime()
|
completedAt = attempt.Stopped.AsLocalTime()
|
||||||
triggerUser = attempt.TriggerUser
|
triggerUser = attempt.TriggerUser
|
||||||
if attempt.Attempt > 1 {
|
if attempt.Attempt > 1 {
|
||||||
url := fmt.Sprintf("%s/actions/runs/%d/attempts/%d", run.Repo.APIURL(ctx), run.ID, attempt.Attempt-1)
|
url := fmt.Sprintf("%s/attempts/%d", run.APIURL(ctx), attempt.Attempt-1)
|
||||||
previousAttemptURL = &url
|
previousAttemptURL = &url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,13 +305,21 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runURL := run.APIURL(ctx)
|
||||||
return &api.ActionWorkflowRun{
|
return &api.ActionWorkflowRun{
|
||||||
ID: run.ID,
|
ID: run.ID,
|
||||||
URL: fmt.Sprintf("%s/actions/runs/%d", run.Repo.APIURL(ctx), run.ID),
|
URL: runURL,
|
||||||
PreviousAttemptURL: previousAttemptURL,
|
PreviousAttemptURL: previousAttemptURL,
|
||||||
HTMLURL: run.HTMLURL(ctx),
|
HTMLURL: run.HTMLURL(ctx),
|
||||||
|
JobsURL: runURL + "/jobs",
|
||||||
|
LogsURL: runURL + "/logs",
|
||||||
|
ArtifactsURL: runURL + "/artifacts",
|
||||||
|
CancelURL: runURL + "/cancel",
|
||||||
|
RerunURL: runURL + "/rerun",
|
||||||
RunNumber: run.Index,
|
RunNumber: run.Index,
|
||||||
RunAttempt: runAttempt,
|
RunAttempt: runAttempt,
|
||||||
|
CreatedAt: run.Created.AsLocalTime(),
|
||||||
|
UpdatedAt: run.Updated.AsLocalTime(),
|
||||||
StartedAt: startedAt,
|
StartedAt: startedAt,
|
||||||
CompletedAt: completedAt,
|
CompletedAt: completedAt,
|
||||||
Event: run.TriggerEvent,
|
Event: run.TriggerEvent,
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ func prepareDBConsistencyChecks() []consistencyCheck {
|
|||||||
// find action without repository
|
// find action without repository
|
||||||
genericOrphanCheck("Action entries without existing repository",
|
genericOrphanCheck("Action entries without existing repository",
|
||||||
"action", "repository", "action.repo_id=repository.id"),
|
"action", "repository", "action.repo_id=repository.id"),
|
||||||
|
// find action runs without repository
|
||||||
|
genericOrphanCheck("Action runs without existing repository",
|
||||||
|
"action_run", "repository", "action_run.repo_id=repository.id"),
|
||||||
// find action without user
|
// find action without user
|
||||||
genericOrphanCheck("Action entries without existing user",
|
genericOrphanCheck("Action entries without existing user",
|
||||||
"action", "user", "action.act_user_id=`user`.id"),
|
"action", "user", "action.act_user_id=`user`.id"),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -42,8 +41,7 @@ type expansion struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var globalVars = sync.OnceValue(func() (ret struct {
|
var globalVars = sync.OnceValue(func() (ret struct {
|
||||||
defaultTransformers []transformer
|
defaultTransformers []transformer
|
||||||
fileNameSanitizeRegexp *regexp.Regexp
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
ret.defaultTransformers = []transformer{
|
ret.defaultTransformers = []transformer{
|
||||||
@@ -55,10 +53,6 @@ var globalVars = sync.OnceValue(func() (ret struct {
|
|||||||
{Name: "UPPER", Transform: strings.ToUpper},
|
{Name: "UPPER", Transform: strings.ToUpper},
|
||||||
{Name: "TITLE", Transform: util.ToTitleCase},
|
{Name: "TITLE", Transform: util.ToTitleCase},
|
||||||
}
|
}
|
||||||
|
|
||||||
// invalid filename contents, based on https://github.com/sindresorhus/filename-reserved-regex
|
|
||||||
// "COM10" needs to be opened with UNC "\\.\COM10" on Windows, so itself is valid
|
|
||||||
ret.fileNameSanitizeRegexp = regexp.MustCompile(`(?i)[<>:"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`)
|
|
||||||
return ret
|
return ret
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -340,7 +334,9 @@ func (gro GenerateRepoOptions) IsValid() bool {
|
|||||||
func filePathSanitize(s string) string {
|
func filePathSanitize(s string) string {
|
||||||
fields := strings.Split(filepath.ToSlash(s), "/")
|
fields := strings.Split(filepath.ToSlash(s), "/")
|
||||||
for i, field := range fields {
|
for i, field := range fields {
|
||||||
field = strings.TrimSpace(strings.TrimSpace(globalVars().fileNameSanitizeRegexp.ReplaceAllString(field, "_")))
|
field = util.PathNameValidator().InvalidChars.ReplaceAllString(field, "_")
|
||||||
|
field = util.PathNameValidator().InvalidNames.ReplaceAllString(field, "_")
|
||||||
|
field = strings.TrimSpace(field)
|
||||||
if strings.HasPrefix(field, "..") {
|
if strings.HasPrefix(field, "..") {
|
||||||
field = "__" + field[2:]
|
field = "__" + field[2:]
|
||||||
}
|
}
|
||||||
|
|||||||
+191
@@ -2237,6 +2237,16 @@
|
|||||||
"actor": {
|
"actor": {
|
||||||
"$ref": "#/components/schemas/User"
|
"$ref": "#/components/schemas/User"
|
||||||
},
|
},
|
||||||
|
"artifacts_url": {
|
||||||
|
"format": "uri",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "ArtifactsURL"
|
||||||
|
},
|
||||||
|
"cancel_url": {
|
||||||
|
"format": "uri",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "CancelURL"
|
||||||
|
},
|
||||||
"completed_at": {
|
"completed_at": {
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -2246,6 +2256,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Conclusion"
|
"x-go-name": "Conclusion"
|
||||||
},
|
},
|
||||||
|
"created_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "CreatedAt"
|
||||||
|
},
|
||||||
"display_title": {
|
"display_title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "DisplayTitle"
|
"x-go-name": "DisplayTitle"
|
||||||
@@ -2275,6 +2290,16 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"x-go-name": "ID"
|
"x-go-name": "ID"
|
||||||
},
|
},
|
||||||
|
"jobs_url": {
|
||||||
|
"format": "uri",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "JobsURL"
|
||||||
|
},
|
||||||
|
"logs_url": {
|
||||||
|
"format": "uri",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "LogsURL"
|
||||||
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Path"
|
"x-go-name": "Path"
|
||||||
@@ -2300,6 +2325,11 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"x-go-name": "RepositoryID"
|
"x-go-name": "RepositoryID"
|
||||||
},
|
},
|
||||||
|
"rerun_url": {
|
||||||
|
"format": "uri",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "RerunURL"
|
||||||
|
},
|
||||||
"run_attempt": {
|
"run_attempt": {
|
||||||
"description": "RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.\nA value of 0 is a legacy-only sentinel for runs created before attempts existed\nand indicates no corresponding /attempts/{n} resource is available.",
|
"description": "RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.\nA value of 0 is a legacy-only sentinel for runs created before attempts existed\nand indicates no corresponding /attempts/{n} resource is available.",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
@@ -2323,6 +2353,11 @@
|
|||||||
"trigger_actor": {
|
"trigger_actor": {
|
||||||
"$ref": "#/components/schemas/User"
|
"$ref": "#/components/schemas/User"
|
||||||
},
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "UpdatedAt"
|
||||||
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"format": "uri",
|
"format": "uri",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -16455,6 +16490,61 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/actions/runs/{run}/approve": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "approveWorkflowRun",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"in": "path",
|
||||||
|
"name": "owner",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "name of the repository",
|
||||||
|
"in": "path",
|
||||||
|
"name": "repo",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "run ID",
|
||||||
|
"in": "path",
|
||||||
|
"name": "run",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/WorkflowRun"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/error"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/components/responses/notFound"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"$ref": "#/components/responses/conflict"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Approve a workflow run that requires approval",
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
|
"/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getArtifactsOfRun",
|
"operationId": "getArtifactsOfRun",
|
||||||
@@ -16652,6 +16742,61 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/actions/runs/{run}/cancel": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "cancelWorkflowRun",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"in": "path",
|
||||||
|
"name": "owner",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "name of the repository",
|
||||||
|
"in": "path",
|
||||||
|
"name": "repo",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "run ID",
|
||||||
|
"in": "path",
|
||||||
|
"name": "run",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/WorkflowRun"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/components/responses/error"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/components/responses/notFound"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"$ref": "#/components/responses/conflict"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Cancel a workflow run and its jobs",
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
|
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listWorkflowRunJobs",
|
"operationId": "listWorkflowRunJobs",
|
||||||
@@ -16811,6 +16956,52 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/actions/runs/{run}/logs": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getWorkflowRunLogs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"in": "path",
|
||||||
|
"name": "owner",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "name of the repository",
|
||||||
|
"in": "path",
|
||||||
|
"name": "repo",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "run ID",
|
||||||
|
"in": "path",
|
||||||
|
"name": "run",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Logs archive"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/components/responses/notFound"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Download workflow run logs as archive",
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/actions/runs/{run}/rerun": {
|
"/repos/{owner}/{repo}/actions/runs/{run}/rerun": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "rerunWorkflowRun",
|
"operationId": "rerunWorkflowRun",
|
||||||
|
|||||||
+177
@@ -5457,6 +5457,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/actions/runs/{run}/approve": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "Approve a workflow run that requires approval",
|
||||||
|
"operationId": "approveWorkflowRun",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repository",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "run ID",
|
||||||
|
"name": "run",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/WorkflowRun"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"$ref": "#/responses/conflict"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
|
"/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@@ -5633,6 +5685,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/actions/runs/{run}/cancel": {
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "Cancel a workflow run and its jobs",
|
||||||
|
"operationId": "cancelWorkflowRun",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repository",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "run ID",
|
||||||
|
"name": "run",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/WorkflowRun"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"$ref": "#/responses/conflict"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
|
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@@ -5774,6 +5878,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/actions/runs/{run}/logs": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/zip"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "Download workflow run logs as archive",
|
||||||
|
"operationId": "getWorkflowRunLogs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repository",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "run ID",
|
||||||
|
"name": "run",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Logs archive"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/actions/runs/{run}/rerun": {
|
"/repos/{owner}/{repo}/actions/runs/{run}/rerun": {
|
||||||
"post": {
|
"post": {
|
||||||
"produces": [
|
"produces": [
|
||||||
@@ -22518,6 +22665,14 @@
|
|||||||
"actor": {
|
"actor": {
|
||||||
"$ref": "#/definitions/User"
|
"$ref": "#/definitions/User"
|
||||||
},
|
},
|
||||||
|
"artifacts_url": {
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "ArtifactsURL"
|
||||||
|
},
|
||||||
|
"cancel_url": {
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "CancelURL"
|
||||||
|
},
|
||||||
"completed_at": {
|
"completed_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
@@ -22527,6 +22682,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Conclusion"
|
"x-go-name": "Conclusion"
|
||||||
},
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"x-go-name": "CreatedAt"
|
||||||
|
},
|
||||||
"display_title": {
|
"display_title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "DisplayTitle"
|
"x-go-name": "DisplayTitle"
|
||||||
@@ -22555,6 +22715,14 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"x-go-name": "ID"
|
"x-go-name": "ID"
|
||||||
},
|
},
|
||||||
|
"jobs_url": {
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "JobsURL"
|
||||||
|
},
|
||||||
|
"logs_url": {
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "LogsURL"
|
||||||
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "Path"
|
"x-go-name": "Path"
|
||||||
@@ -22579,6 +22747,10 @@
|
|||||||
"format": "int64",
|
"format": "int64",
|
||||||
"x-go-name": "RepositoryID"
|
"x-go-name": "RepositoryID"
|
||||||
},
|
},
|
||||||
|
"rerun_url": {
|
||||||
|
"type": "string",
|
||||||
|
"x-go-name": "RerunURL"
|
||||||
|
},
|
||||||
"run_attempt": {
|
"run_attempt": {
|
||||||
"description": "RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.\nA value of 0 is a legacy-only sentinel for runs created before attempts existed\nand indicates no corresponding /attempts/{n} resource is available.",
|
"description": "RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.\nA value of 0 is a legacy-only sentinel for runs created before attempts existed\nand indicates no corresponding /attempts/{n} resource is available.",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -22602,6 +22774,11 @@
|
|||||||
"trigger_actor": {
|
"trigger_actor": {
|
||||||
"$ref": "#/definitions/User"
|
"$ref": "#/definitions/User"
|
||||||
},
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"x-go-name": "UpdatedAt"
|
||||||
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "URL"
|
"x-go-name": "URL"
|
||||||
|
|||||||
@@ -4,23 +4,30 @@
|
|||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/actions"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAPIActionsWorkflowRun(t *testing.T) {
|
func TestAPIActionsWorkflowRun(t *testing.T) {
|
||||||
@@ -31,16 +38,13 @@ func TestAPIActionsWorkflowRun(t *testing.T) {
|
|||||||
t.Run("ListRepoWorkflows", testAPIActionsListRepoWorkflows)
|
t.Run("ListRepoWorkflows", testAPIActionsListRepoWorkflows)
|
||||||
t.Run("DeleteRunCheckPermission", testAPIActionsDeleteRunCheckPermission)
|
t.Run("DeleteRunCheckPermission", testAPIActionsDeleteRunCheckPermission)
|
||||||
t.Run("DeleteRunRunning", testAPIActionsDeleteRunRunning)
|
t.Run("DeleteRunRunning", testAPIActionsDeleteRunRunning)
|
||||||
|
t.Run("GetWorkflowRunLogsNotFound", testAPIActionsGetWorkflowRunLogsNotFound)
|
||||||
|
t.Run("GetWorkflowJobLogsNotFound", testAPIActionsGetWorkflowJobLogsNotFound)
|
||||||
|
// finishes run 793, so it must come after everything that needs it still running
|
||||||
|
t.Run("CancelWorkflowRun", testAPIActionsCancelWorkflowRun)
|
||||||
|
t.Run("ApproveWorkflowRun", testAPIActionsApproveWorkflowRun)
|
||||||
|
// deletes run 795, so it must come after everything that reads it
|
||||||
t.Run("DeleteRunGeneral", testAPIActionsDeleteRunGeneral)
|
t.Run("DeleteRunGeneral", testAPIActionsDeleteRunGeneral)
|
||||||
|
|
||||||
t.Run("RerunWorkflowRun", func(t *testing.T) {
|
|
||||||
defer tests.PrepareTestEnv(t)()
|
|
||||||
testAPIActionsRerunWorkflowRun(t)
|
|
||||||
})
|
|
||||||
t.Run("RerunWorkflowJob", func(t *testing.T) {
|
|
||||||
defer tests.PrepareTestEnv(t)()
|
|
||||||
testAPIActionsRerunWorkflowJob(t)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAPIActionsGetWorkflowRun(t *testing.T) {
|
func testAPIActionsGetWorkflowRun(t *testing.T) {
|
||||||
@@ -74,8 +78,10 @@ func testAPIActionsGetWorkflowRun(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())).AddTokenAuth(token)
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())).
|
||||||
|
AddTokenAuth(token)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
jobList := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
jobList := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
||||||
|
|
||||||
job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 })
|
job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 })
|
||||||
@@ -95,11 +101,9 @@ func testAPIActionsGetWorkflowJob(t *testing.T) {
|
|||||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198198", repo.FullName())).
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198198", repo.FullName())).
|
||||||
AddTokenAuth(token)
|
AddTokenAuth(token)
|
||||||
MakeRequest(t, req, http.StatusNotFound)
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198", repo.FullName())).
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198", repo.FullName())).AddTokenAuth(token)
|
||||||
AddTokenAuth(token)
|
|
||||||
MakeRequest(t, req, http.StatusOK)
|
MakeRequest(t, req, http.StatusOK)
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/196", repo.FullName())).
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/196", repo.FullName())).AddTokenAuth(token)
|
||||||
AddTokenAuth(token)
|
|
||||||
MakeRequest(t, req, http.StatusNotFound)
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +130,7 @@ func testAPIActionsDeleteRunGeneral(t *testing.T) {
|
|||||||
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
|
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// needs run 793 still running, so it must come before CancelWorkflowRun
|
||||||
func testAPIActionsDeleteRunRunning(t *testing.T) {
|
func testAPIActionsDeleteRunRunning(t *testing.T) {
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
@@ -144,7 +149,8 @@ func testAPIActionsDeleteRun(t *testing.T, repo *repo_model.Repository, token st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testAPIActionsDeleteRunListArtifacts(t *testing.T, repo *repo_model.Repository, token string, artifacts int) {
|
func testAPIActionsDeleteRunListArtifacts(t *testing.T, repo *repo_model.Repository, token string, artifacts int) {
|
||||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())).AddTokenAuth(token)
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())).
|
||||||
|
AddTokenAuth(token)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
listResp := DecodeJSON(t, resp, &api.ActionArtifactsResponse{})
|
listResp := DecodeJSON(t, resp, &api.ActionArtifactsResponse{})
|
||||||
assert.Len(t, listResp.Entries, artifacts)
|
assert.Len(t, listResp.Entries, artifacts)
|
||||||
@@ -154,7 +160,6 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository,
|
|||||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())).AddTokenAuth(token)
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())).AddTokenAuth(token)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
listResp := DecodeJSON(t, resp, &api.ActionTaskResponse{})
|
listResp := DecodeJSON(t, resp, &api.ActionTaskResponse{})
|
||||||
|
|
||||||
findTask1 := false
|
findTask1 := false
|
||||||
findTask2 := false
|
findTask2 := false
|
||||||
for _, entry := range listResp.Entries {
|
for _, entry := range listResp.Entries {
|
||||||
@@ -171,7 +176,12 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository,
|
|||||||
assert.Equal(t, expected, findTask2)
|
assert.Equal(t, expected, findTask2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAPIActionsRerunWorkflowRun(t *testing.T) {
|
// TestAPIActionsRerunWorkflowRun covers everything that mutates run 795, in a fixed order so
|
||||||
|
// they can share one fixture load: the log download has to see the original tasks, the job
|
||||||
|
// rerun needs the run still done, and the full rerun re-arms it by cancelling first.
|
||||||
|
func TestAPIActionsRerunWorkflowRun(t *testing.T) {
|
||||||
|
defer prepareTestEnvActionsArtifacts(t)()
|
||||||
|
|
||||||
t.Run("NotDone", func(t *testing.T) {
|
t.Run("NotDone", func(t *testing.T) {
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
@@ -181,6 +191,10 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) {
|
|||||||
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/rerun", repo.FullName())).
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/rerun", repo.FullName())).
|
||||||
AddTokenAuth(writeToken)
|
AddTokenAuth(writeToken)
|
||||||
MakeRequest(t, req, http.StatusBadRequest)
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
|
||||||
|
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/jobs/194/rerun", repo.FullName())).
|
||||||
|
AddTokenAuth(writeToken)
|
||||||
|
MakeRequest(t, req, http.StatusBadRequest)
|
||||||
})
|
})
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
@@ -190,16 +204,89 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) {
|
|||||||
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
||||||
|
|
||||||
t.Run("Success", func(t *testing.T) {
|
t.Run("RunLogs", func(t *testing.T) {
|
||||||
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())).AddTokenAuth(writeToken)
|
// run 795 (workflow "test.yaml") has job 198 "job_1" on task 53 and job 199 "job_2" on task 54
|
||||||
resp := MakeRequest(t, req, http.StatusCreated)
|
seedTaskLogs(t, 53, "hello from job_1")
|
||||||
rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowRun{})
|
seedTaskLogs(t, 54, "hello from job_2")
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())).
|
||||||
|
AddTokenAuth(writeToken)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
assert.Equal(t, "application/zip", resp.Header().Get("Content-Type"))
|
||||||
|
assert.Contains(t, resp.Header().Get("Content-Disposition"), "test-run-795-logs.zip")
|
||||||
|
assert.Equal(t, "Content-Disposition", resp.Header().Get("Access-Control-Expose-Headers"))
|
||||||
|
|
||||||
|
body := resp.Body.Bytes()
|
||||||
|
archive, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
contents := make(map[string]string, len(archive.File))
|
||||||
|
for _, file := range archive.File {
|
||||||
|
r, err := file.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
content, err := io.ReadAll(r)
|
||||||
|
require.NoError(t, r.Close())
|
||||||
|
require.NoError(t, err)
|
||||||
|
contents[file.Name] = string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, contents, 2)
|
||||||
|
assert.Contains(t, contents["test-job_1-53.log"], "hello from job_1")
|
||||||
|
assert.Contains(t, contents["test-job_2-54.log"], "hello from job_2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JobSuccess", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())).
|
||||||
|
AddTokenAuth(writeToken)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowJob{})
|
||||||
|
job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
||||||
|
assert.Equal(t, job199Rerun.ID, rerunResp.ID)
|
||||||
|
assert.Equal(t, "queued", rerunResp.Status)
|
||||||
|
|
||||||
|
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
||||||
|
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, hasLatestAttempt)
|
||||||
|
|
||||||
|
job198Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 198)
|
||||||
|
assert.Equal(t, actions_model.StatusSuccess, job198Rerun.Status)
|
||||||
|
assert.Equal(t, latestAttempt.Attempt, job198Rerun.Attempt)
|
||||||
|
assert.Equal(t, int64(0), job198Rerun.TaskID)
|
||||||
|
assert.Equal(t, int64(53), job198Rerun.SourceTaskID)
|
||||||
|
|
||||||
|
job199Rerun = getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
||||||
|
assert.Equal(t, actions_model.StatusWaiting, job199Rerun.Status)
|
||||||
|
assert.Equal(t, latestAttempt.Attempt, job199Rerun.Attempt)
|
||||||
|
assert.Equal(t, int64(0), job199Rerun.TaskID)
|
||||||
|
assert.Equal(t, int64(0), job199Rerun.SourceTaskID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
// JobSuccess above leaves the run waiting, so finish it to make it rerunnable again.
|
||||||
|
// Run on its own the fixture run is still done and needs no cancelling.
|
||||||
|
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if !run.Status.IsDone() {
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/cancel", repo.FullName())).
|
||||||
|
AddTokenAuth(writeToken)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())).
|
||||||
|
AddTokenAuth(writeToken)
|
||||||
|
resp := MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowRun{})
|
||||||
assert.Equal(t, int64(795), rerunResp.ID)
|
assert.Equal(t, int64(795), rerunResp.ID)
|
||||||
assert.Equal(t, "queued", rerunResp.Status)
|
assert.Equal(t, "queued", rerunResp.Status)
|
||||||
assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", rerunResp.HeadSha)
|
assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", rerunResp.HeadSha)
|
||||||
|
|
||||||
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
run, err = actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
||||||
assert.Equal(t, timeutil.TimeStamp(0), run.Started)
|
assert.Equal(t, timeutil.TimeStamp(0), run.Started)
|
||||||
@@ -230,67 +317,136 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) {
|
|||||||
AddTokenAuth(writeToken)
|
AddTokenAuth(writeToken)
|
||||||
MakeRequest(t, req, http.StatusNotFound)
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
func testAPIActionsRerunWorkflowJob(t *testing.T) {
|
|
||||||
t.Run("NotDone", func(t *testing.T) {
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
||||||
session := loginUser(t, user.Name)
|
|
||||||
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
||||||
|
|
||||||
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/jobs/194/rerun", repo.FullName())).
|
|
||||||
AddTokenAuth(writeToken)
|
|
||||||
MakeRequest(t, req, http.StatusBadRequest)
|
|
||||||
})
|
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
||||||
session := loginUser(t, user.Name)
|
|
||||||
|
|
||||||
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
||||||
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
|
||||||
|
|
||||||
t.Run("Success", func(t *testing.T) {
|
|
||||||
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())).AddTokenAuth(writeToken)
|
|
||||||
resp := MakeRequest(t, req, http.StatusCreated)
|
|
||||||
rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowJob{})
|
|
||||||
|
|
||||||
job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
|
||||||
assert.Equal(t, job199Rerun.ID, rerunResp.ID)
|
|
||||||
assert.Equal(t, "queued", rerunResp.Status)
|
|
||||||
|
|
||||||
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
|
||||||
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, hasLatestAttempt)
|
|
||||||
|
|
||||||
job198Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 198)
|
|
||||||
assert.Equal(t, actions_model.StatusSuccess, job198Rerun.Status)
|
|
||||||
assert.Equal(t, latestAttempt.Attempt, job198Rerun.Attempt)
|
|
||||||
assert.Equal(t, int64(0), job198Rerun.TaskID)
|
|
||||||
assert.Equal(t, int64(53), job198Rerun.SourceTaskID)
|
|
||||||
|
|
||||||
job199Rerun = getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
|
||||||
assert.Equal(t, actions_model.StatusWaiting, job199Rerun.Status)
|
|
||||||
assert.Equal(t, latestAttempt.Attempt, job199Rerun.Attempt)
|
|
||||||
assert.Equal(t, int64(0), job199Rerun.TaskID)
|
|
||||||
assert.Equal(t, int64(0), job199Rerun.SourceTaskID)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ForbiddenWithoutWriteScope", func(t *testing.T) {
|
|
||||||
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())).
|
|
||||||
AddTokenAuth(readToken)
|
|
||||||
MakeRequest(t, req, http.StatusForbidden)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NotFoundJob", func(t *testing.T) {
|
t.Run("NotFoundJob", func(t *testing.T) {
|
||||||
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/999999/rerun", repo.FullName())).
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/999999/rerun", repo.FullName())).
|
||||||
AddTokenAuth(writeToken)
|
AddTokenAuth(writeToken)
|
||||||
MakeRequest(t, req, http.StatusNotFound)
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("NoLogsAfterRerun", func(t *testing.T) {
|
||||||
|
// the full rerun above cleared both TaskID and SourceTaskID on every latest-attempt job
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())).
|
||||||
|
AddTokenAuth(writeToken)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPIActionsCancelWorkflowRun(t *testing.T) {
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||||
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
ownerSession := loginUser(t, owner.Name)
|
||||||
|
ownerToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())).
|
||||||
|
AddTokenAuth(ownerToken)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
cancelledRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{})
|
||||||
|
assert.Equal(t, int64(793), cancelledRun.ID)
|
||||||
|
assert.Equal(t, "completed", cancelledRun.Status)
|
||||||
|
assert.Equal(t, "cancelled", cancelledRun.Conclusion)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AlreadyCompleted", func(t *testing.T) {
|
||||||
|
// run 791 already succeeded, so there is nothing left to cancel
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/791/cancel", repo.FullName())).
|
||||||
|
AddTokenAuth(ownerToken)
|
||||||
|
MakeRequest(t, req, http.StatusConflict)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NotFound", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/cancel", repo.FullName())).
|
||||||
|
AddTokenAuth(ownerToken)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ForbiddenWithoutPermission", func(t *testing.T) {
|
||||||
|
// user2 is not the owner of repo4 (owned by user5)
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
user2Session := loginUser(t, user2.Name)
|
||||||
|
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())).
|
||||||
|
AddTokenAuth(user2Token)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPIActionsApproveWorkflowRun(t *testing.T) {
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||||
|
// user5 owns repo4, user4 is a write collaborator on it, user2 has no access at all
|
||||||
|
ownerToken := getTokenForLoggedInUser(t, loginUser(t, "user5"), auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
writerToken := getTokenForLoggedInUser(t, loginUser(t, "user4"), auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
strangerToken := getTokenForLoggedInUser(t, loginUser(t, "user2"), auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
// a fork PR from a first-time contributor is what produces these in practice, which
|
||||||
|
// actions_approve_test.go already covers end to end
|
||||||
|
insertBlockedRun := func(index int64) *actions_model.ActionRun {
|
||||||
|
run := &actions_model.ActionRun{
|
||||||
|
Title: "needs approval", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index,
|
||||||
|
TriggerUserID: 4, Ref: "refs/heads/main", CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||||
|
Event: "pull_request", TriggerEvent: "pull_request",
|
||||||
|
Status: actions_model.StatusBlocked, NeedApproval: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), run))
|
||||||
|
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{
|
||||||
|
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
|
||||||
|
Name: "job1", Attempt: 1, JobID: "job1", Status: actions_model.StatusBlocked, RunsOn: []string{"ubuntu-latest"},
|
||||||
|
}))
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
assertApproved := func(t *testing.T, runID, approverID int64) {
|
||||||
|
t.Helper()
|
||||||
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
||||||
|
assert.False(t, run.NeedApproval)
|
||||||
|
assert.Equal(t, approverID, run.ApprovedBy)
|
||||||
|
jobs, err := actions_model.GetLatestAttemptJobsByRun(t.Context(), run)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, job := range jobs {
|
||||||
|
assert.Equal(t, actions_model.StatusWaiting, job.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run := insertBlockedRun(2001)
|
||||||
|
|
||||||
|
t.Run("ForbiddenWithoutPermission", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)).
|
||||||
|
AddTokenAuth(strangerToken)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AsOwner", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)).
|
||||||
|
AddTokenAuth(ownerToken)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
apiRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{})
|
||||||
|
assert.Equal(t, run.ID, apiRun.ID)
|
||||||
|
assertApproved(t, run.ID, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AgainIsIdempotent", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)).
|
||||||
|
AddTokenAuth(ownerToken)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
assertApproved(t, run.ID, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AsWriterNonAdmin", func(t *testing.T) {
|
||||||
|
writerRun := insertBlockedRun(2002)
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), writerRun.ID)).
|
||||||
|
AddTokenAuth(writerToken)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
assertApproved(t, writerRun.ID, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NotRequired", func(t *testing.T) {
|
||||||
|
// run 791 succeeded without ever awaiting approval
|
||||||
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/791/approve", repo.FullName())).
|
||||||
|
AddTokenAuth(ownerToken)
|
||||||
|
MakeRequest(t, req, http.StatusConflict)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAPIActionsListUserWorkflows(t *testing.T) {
|
func testAPIActionsListUserWorkflows(t *testing.T) {
|
||||||
@@ -373,6 +529,73 @@ func testAPIActionsListRepoWorkflows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAPIActionsGetWorkflowRunLogsNotFound(t *testing.T) {
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
session := loginUser(t, user.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
t.Run("NoLogs", func(t *testing.T) {
|
||||||
|
// Run 795 has jobs but fixture tasks have no log output in storage.
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RunNotFound", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/logs", repo.FullName())).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedTaskLogs writes task logs the way the runner does, in DBFS to stay independent of the object storage fixture.
|
||||||
|
func seedTaskLogs(t *testing.T, taskID int64, lines ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
task, err := actions_model.GetTaskByID(t.Context(), taskID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
task.LogInStorage = false
|
||||||
|
task.LogFilename = fmt.Sprintf("test-logs/%d.log", task.ID)
|
||||||
|
|
||||||
|
rows := make([]*runnerv1.LogRow, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
rows = append(rows, &runnerv1.LogRow{Time: timestamppb.New(time.Unix(1683636528, 0)), Content: line})
|
||||||
|
}
|
||||||
|
ns, err := actions.WriteLogs(t.Context(), task.LogFilename, 0, rows)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
task.LogLength = int64(len(rows))
|
||||||
|
for _, n := range ns {
|
||||||
|
task.LogIndexes = append(task.LogIndexes, task.LogSize)
|
||||||
|
task.LogSize += int64(n)
|
||||||
|
}
|
||||||
|
require.NoError(t, actions_model.UpdateTask(t.Context(), task,
|
||||||
|
"log_filename", "log_in_storage", "log_indexes", "log_length", "log_size"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// the success path is covered against real runner logs by TestDownloadTaskLogs
|
||||||
|
func testAPIActionsGetWorkflowJobLogsNotFound(t *testing.T) {
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
session := loginUser(t, user.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||||
|
|
||||||
|
t.Run("NoLogFile", func(t *testing.T) {
|
||||||
|
// job 199 exists but its task has no log file in the test fixture
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/199/logs", repo.FullName())).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JobNotFound", func(t *testing.T) {
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/999999/logs", repo.FullName())).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestAPIOrgActionsRunsAccessControl ensures the org-level Actions run/job listing does not
|
// TestAPIOrgActionsRunsAccessControl ensures the org-level Actions run/job listing does not
|
||||||
// leak runs/jobs from repos the caller cannot access.
|
// leak runs/jobs from repos the caller cannot access.
|
||||||
func TestAPIOrgActionsRunsAccessControl(t *testing.T) {
|
func TestAPIOrgActionsRunsAccessControl(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user