0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-10 05:21:54 +02:00

Merge 0200bf674c23be00d641b660446749e10b2500d0 into a5d81d9ce230aaa6e1021b6236ca01cb6d2b56c3

This commit is contained in:
Ross Golder 2026-05-09 02:43:51 -04:00 committed by GitHub
commit 20016d6fe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1634 additions and 81 deletions

View File

@ -129,6 +129,8 @@ type ActionWorkflowRun struct {
HeadRepository *Repository `json:"head_repository,omitempty"`
Conclusion string `json:"conclusion,omitempty"`
// swagger:strfmt date-time
CreatedAt time.Time `json:"created_at"`
// swagger:strfmt date-time
StartedAt time.Time `json:"started_at"`
// swagger:strfmt date-time
CompletedAt time.Time `json:"completed_at"`
@ -226,3 +228,35 @@ type RunDetails struct {
RunURL string `json:"run_url"`
HTMLURL string `json:"html_url"`
}
// ActionLogCursor represents a cursor position within a step's log
type ActionLogCursor struct {
Step int `json:"step"`
Cursor int64 `json:"cursor"`
Expanded bool `json:"expanded"`
}
// ActionLogRequest is the request body for the streaming log endpoint
type ActionLogRequest struct {
LogCursors []ActionLogCursor `json:"logCursors"`
}
// ActionLogStepLine represents a single log line within a step
type ActionLogStepLine struct {
Index int64 `json:"index"`
Message string `json:"message"`
Timestamp float64 `json:"timestamp"`
}
// ActionLogStep represents log lines for a single step with cursor state
type ActionLogStep struct {
Step int `json:"step"`
Cursor int64 `json:"cursor"`
Lines []*ActionLogStepLine `json:"lines"`
Started int64 `json:"started"`
}
// ActionLogResponse is the response body for the streaming log endpoint
type ActionLogResponse struct {
StepsLog []*ActionLogStep `json:"stepsLog"`
}

View File

@ -1261,8 +1261,17 @@ func Routes() *web.Router {
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun)
m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun)
m.Get("/jobs", repo.ListWorkflowRunJobs)
m.Post("/jobs/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob)
m.Post("/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelWorkflowRun)
m.Post("/approve", reqToken(), reqRepoWriter(unit.TypeActions), repo.ApproveWorkflowRun)
m.Group("/jobs", func() {
m.Get("", repo.ListWorkflowRunJobs)
m.Get("/{job_id}/logs", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowJobLogs)
m.Post("/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob)
})
m.Group("/logs", func() {
m.Get("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogs)
m.Post("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogsStream)
})
m.Get("/artifacts", repo.GetArtifactsOfRun)
})
})
@ -1272,7 +1281,7 @@ func Routes() *web.Router {
m.Delete("", reqRepoWriter(unit.TypeActions), repo.DeleteArtifact)
})
m.Get("/artifacts/{artifact_id}/zip", repo.DownloadArtifact)
}, reqRepoReader(unit.TypeActions))
}, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true))
m.Group("/keys", func() {
m.Combo("").Get(repo.ListDeployKeys).
Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey)

View File

@ -1233,7 +1233,7 @@ func GetWorkflowRun(ctx *context.APIContext) {
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil)
if err != nil {
ctx.APIErrorInternal(err)
return
@ -1282,7 +1282,7 @@ func GetWorkflowRunAttempt(ctx *context.APIContext) {
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, attempt)
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, attempt)
if err != nil {
ctx.APIErrorInternal(err)
return
@ -1337,7 +1337,7 @@ func RerunWorkflowRun(ctx *context.APIContext) {
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, run, nil)
if err != nil {
ctx.APIErrorInternal(err)
return

View File

@ -5,11 +5,19 @@ package repo
import (
"errors"
"io"
"net/http"
"os"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/json"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/common"
actions_service "code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
)
func DownloadActionsRunJobLogs(ctx *context.APIContext) {
@ -66,3 +74,439 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
}
}
}
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":
// description: success
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
_, run, err := getRunID(ctx)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
jobs, err := getRunJobs(ctx, run)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if err := actions_service.CancelRun(ctx, run, jobs); err != nil {
ctx.APIErrorInternal(err)
return
}
updatedRun, has, err := db.GetByID[actions_model.ActionRun](ctx, run.ID)
if err != nil || !has {
ctx.APIErrorInternal(err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, updatedRun, nil)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convertedRun)
}
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":
// description: success
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
runID, run, err := getRunID(ctx)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
if !run.NeedApproval {
ctx.APIError(http.StatusBadRequest, "Run does not require approval")
return
}
if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{runID}); err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
// Reload run to reflect post-approval state.
updatedRun, has, err := db.GetByID[actions_model.ActionRun](ctx, runID)
if err != nil || !has {
ctx.APIErrorInternal(err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, updatedRun, nil)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, convertedRun)
}
func getRunID(ctx *context.APIContext) (int64, *actions_model.ActionRun, error) {
runID := ctx.PathParamInt64("run")
run, has, err := db.GetByID[actions_model.ActionRun](ctx, runID)
if err != nil {
return 0, nil, err
}
if !has || run.RepoID != ctx.Repo.Repository.ID {
return 0, nil, util.ErrNotExist
}
return runID, run, nil
}
func getRunJobs(ctx *context.APIContext, run *actions_model.ActionRun) ([]*actions_model.ActionRunJob, error) {
run.Repo = ctx.Repo.Repository
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
return nil, err
}
for _, v := range jobs {
v.Run = run
}
return jobs, nil
}
func getRunJobsAndCurrent(ctx *context.APIContext, run *actions_model.ActionRun, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, error) {
jobs, err := getRunJobs(ctx, run)
if err != nil {
return nil, nil, err
}
if len(jobs) == 0 {
return nil, nil, util.ErrNotExist
}
if jobIndex >= 0 {
if jobIndex >= int64(len(jobs)) {
return nil, nil, util.ErrNotExist
}
return jobs[jobIndex], jobs, nil
}
return jobs[0], jobs, nil
}
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, err := getRunID(ctx)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
if err = common.DownloadActionsRunAllJobLogs(ctx.Base, ctx.Repo.Repository, run.ID); err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
}
func GetWorkflowJobLogs(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/logs repository getWorkflowJobLogs
// ---
// summary: Download job logs as plain text
// produces:
// - text/plain
// 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
// - name: job_id
// in: path
// description: id of the job
// type: integer
// required: true
// responses:
// "200":
// description: Job logs
// "404":
// "$ref": "#/responses/notFound"
runID, _, err := getRunID(ctx)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
jobID := ctx.PathParamInt64("job_id")
job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
job.Repo = ctx.Repo.Repository
if err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, job); err != nil {
if errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
}
func GetWorkflowRunLogsStream(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogsStream
// ---
// summary: Get streaming workflow run logs with cursor support
// consumes:
// - application/json
// 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
// - name: job
// in: query
// description: job index (0-based), defaults to first job
// type: integer
// required: false
// - name: body
// in: body
// schema:
// type: object
// properties:
// logCursors:
// type: array
// items:
// type: object
// properties:
// step:
// type: integer
// cursor:
// type: integer
// expanded:
// type: boolean
// responses:
// "200":
// description: Streaming logs
// schema:
// type: object
// properties:
// stepsLog:
// type: array
// items:
// type: object
// properties:
// step:
// type: integer
// cursor:
// type: integer
// lines:
// type: array
// items:
// type: object
// properties:
// index:
// type: integer
// message:
// type: string
// timestamp:
// type: number
// started:
// type: integer
// "404":
// "$ref": "#/responses/notFound"
_, run, err := getRunID(ctx)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
jobIndex := int64(-1)
if ctx.FormString("job") != "" {
jobIndex = int64(ctx.FormInt("job"))
}
var req api.ActionLogRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
if errors.Is(err, io.EOF) {
req = api.ActionLogRequest{LogCursors: []api.ActionLogCursor{}}
} else {
ctx.APIError(http.StatusBadRequest, "Invalid request body")
return
}
}
current, _, err := getRunJobsAndCurrent(ctx, run, jobIndex)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.APIErrorNotFound(err)
} else {
ctx.APIErrorInternal(err)
}
return
}
var task *actions_model.ActionTask
if current.TaskID > 0 {
task, err = actions_model.GetTaskByID(ctx, current.TaskID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
task.Job = current
if err := task.LoadAttributes(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
}
response := &api.ActionLogResponse{
StepsLog: make([]*api.ActionLogStep, 0),
}
if task != nil {
logs, err := actions_service.ReadStepLogs(ctx, req.LogCursors, task, "Log has expired and is no longer available")
if err != nil {
ctx.APIErrorInternal(err)
return
}
response.StepsLog = append(response.StepsLog, logs...)
}
ctx.JSON(http.StatusOK, response)
}

View File

@ -197,7 +197,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64) {
res.Entries = make([]*api.ActionWorkflowRun, len(runs))
for i := range runs {
// TODO: load run attempts in batch
convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i], nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i].Repo, runs[i], nil)
if err != nil {
ctx.APIErrorInternal(err)
return

View File

@ -4,7 +4,11 @@
package common
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
@ -26,6 +30,76 @@ func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repo
return DownloadActionsRunJobLogs(ctx, ctxRepo, job)
}
func DownloadActionsRunAllJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, runID int64) error {
runJobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, ctxRepo.ID, runID)
if err != nil {
return fmt.Errorf("GetLatestAttemptJobsByRepoAndRunID: %w", err)
}
if len(runJobs) == 0 {
return util.NewNotExistErrorf("no jobs found for run %d", runID)
}
// Load run for workflow name
if err := runJobs[0].LoadRun(ctx); err != nil {
return fmt.Errorf("LoadRun: %w", err)
}
workflowName := runJobs[0].Run.WorkflowID
if p := strings.Index(workflowName, "."); p > 0 {
workflowName = workflowName[0:p]
}
safeWorkflowName := strings.NewReplacer(`"`, "", "\r", "", "\n", "", "/", "-", `\`, "-").Replace(workflowName)
// Set headers for zip download
ctx.Resp.Header().Set("Content-Type", "application/zip")
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-run-%d-logs.zip"`, safeWorkflowName, runID))
// Create zip writer
zipWriter := zip.NewWriter(ctx.Resp)
defer zipWriter.Close()
// Add each job's logs to the zip
for _, job := range runJobs {
if job.TaskID == 0 {
continue // Skip jobs that haven't started
}
task, err := actions_model.GetTaskByID(ctx, job.TaskID)
if err != nil {
return fmt.Errorf("GetTaskByID for job %d: %w", job.ID, err)
}
if task.LogExpired || task.LogLength == 0 {
continue
}
// Create file in zip with job name and task ID; sanitize to prevent Zip Slip
safeJobName := strings.NewReplacer("/", "-", `\`, "-", "..", "__").Replace(job.Name)
fileName := fmt.Sprintf("%s-%s-%d.log", safeWorkflowName, safeJobName, task.ID)
if err := func() error {
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
if err != nil {
return err
}
defer reader.Close()
zipFile, err := zipWriter.Create(fileName)
if err != nil {
return err
}
_, err = io.Copy(zipFile, reader)
return err
}(); err != nil {
return fmt.Errorf("job %d: %w", job.ID, err)
}
}
return nil
}
func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error {
if curJob.Repo.ID != ctxRepo.ID {
return util.NewNotExistErrorf("job not found")
@ -49,8 +123,15 @@ func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository
return util.NewNotExistErrorf("logs have been cleaned up")
}
if task.LogLength == 0 {
return util.NewNotExistErrorf("logs not found")
}
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
if err != nil {
if errors.Is(err, os.ErrNotExist) || errors.Is(err, util.ErrNotExist) {
return util.NewNotExistErrorf("logs not found")
}
return fmt.Errorf("OpenLogs: %w", err)
}
defer reader.Close()

View File

@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
)
// CancelRun cancels all cancellable jobs in a run, updates commit statuses,
// and fires downstream notifications including job-emitter queue entries.
func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) error {
var updatedJobs []*actions_model.ActionRunJob
if err := db.WithTx(ctx, func(ctx context.Context) error {
cancelled, err := actions_model.CancelJobs(ctx, jobs)
if err != nil {
return fmt.Errorf("CancelJobs: %w", err)
}
updatedJobs = cancelled
return nil
}); err != nil {
return err
}
CreateCommitStatusForRunJobs(ctx, run, jobs...)
EmitJobsIfReadyByJobs(updatedJobs)
NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
if len(updatedJobs) > 0 {
NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID)
}
return nil
}

80
services/actions/log.go Normal file
View File

@ -0,0 +1,80 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/actions"
api "code.gitea.io/gitea/modules/structs"
)
// ReadStepLogs reads log lines for the given cursor positions from a task.
// expiredMessage is used as the log content when the task's logs have expired.
func ReadStepLogs(ctx context.Context, cursors []api.ActionLogCursor, task *actions_model.ActionTask, expiredMessage string) ([]*api.ActionLogStep, error) {
var logs []*api.ActionLogStep
steps := actions.FullSteps(task)
for _, cursor := range cursors {
if !cursor.Expanded {
continue
}
if cursor.Step >= len(steps) {
continue
}
step := steps[cursor.Step]
if task.LogExpired {
if cursor.Cursor == 0 {
logs = append(logs, &api.ActionLogStep{
Step: cursor.Step,
Cursor: 1,
Lines: []*api.ActionLogStepLine{{
Index: 1,
Message: expiredMessage,
Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second),
}},
Started: int64(step.Started),
})
}
continue
}
logLines := make([]*api.ActionLogStepLine, 0)
index := step.LogIndex + cursor.Cursor
validCursor := cursor.Cursor >= 0 &&
// !(cursor.Cursor < step.LogLength) when the frontend tries to fetch the next
// line before it's ready — return same cursor and empty lines to let caller retry.
cursor.Cursor < step.LogLength &&
// !(index < len(task.LogIndexes)) when task data is older than step data.
index < int64(len(task.LogIndexes))
if validCursor {
length := step.LogLength - cursor.Cursor
offset := task.LogIndexes[index]
logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length)
if err != nil {
return nil, fmt.Errorf("actions.ReadLogs: %w", err)
}
for i, row := range logRows {
logLines = append(logLines, &api.ActionLogStepLine{
Index: cursor.Cursor + int64(i) + 1, // 1-based
Message: row.Content,
Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second),
})
}
}
logs = append(logs, &api.ActionLogStep{
Step: cursor.Step,
Cursor: cursor.Cursor + int64(len(logLines)),
Lines: logLines,
Started: int64(step.Started),
})
}
return logs, nil
}

View File

@ -815,8 +815,7 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
log.Error("GetActionWorkflow: %v", err)
return
}
run.Repo = repo
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, nil)
if err != nil {
log.Error("ToActionWorkflowRun: %v", err)
return

View File

@ -116,12 +116,11 @@ func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 803})
run.Repo = repo
// Scheduled runs keep Event as the registration event (push) and use TriggerEvent as the real trigger.
run.Event = "push"
run.TriggerEvent = "schedule"
apiRun, err := ToActionWorkflowRun(t.Context(), run, nil)
apiRun, err := ToActionWorkflowRun(t.Context(), repo, run, nil)
require.NoError(t, err)
assert.Equal(t, "schedule", apiRun.Event)
}

View File

@ -28,7 +28,6 @@ import (
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
@ -222,18 +221,14 @@ func ToTag(repo *repo_model.Repository, t *git.Tag) *api.Tag {
}
}
// ToActionTask convert an actions_model.ActionTask to an api.ActionTask
// ToActionTask convert a actions_model.ActionTask to an api.ActionTask
func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.ActionTask, error) {
// don't need Steps here, only need to load job and its run
if err := t.LoadJob(ctx); err != nil {
return nil, err
}
if err := t.Job.LoadRun(ctx); err != nil {
return nil, err
}
if err := t.Job.Run.LoadRepo(ctx); err != nil {
if err := t.LoadAttributes(ctx); err != nil {
return nil, err
}
url := strings.TrimSuffix(setting.AppURL, "/") + t.GetRunLink()
return &api.ActionTask{
ID: t.ID,
Name: t.Job.Name,
@ -244,25 +239,23 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action
DisplayTitle: t.Job.Run.Title,
Status: t.Status.String(),
WorkflowID: t.Job.Run.WorkflowID,
URL: httplib.MakeAbsoluteURL(ctx, t.Job.Run.Link()),
URL: url,
CreatedAt: t.Created.AsLocalTime(),
UpdatedAt: t.Updated.AsLocalTime(),
RunStartedAt: t.Started.AsLocalTime(),
}, nil
}
func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (_ *api.ActionWorkflowRun, err error) {
if err := run.LoadRepo(ctx); err != nil {
return nil, err
}
if err := run.LoadTriggerUser(ctx); err != nil {
func ToActionWorkflowRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) (*api.ActionWorkflowRun, error) {
if err := run.LoadAttributes(ctx); err != nil {
return nil, err
}
if attempt == nil {
attempt, _, err = run.GetLatestAttempt(ctx)
if err != nil {
if latestAttempt, has, err := run.GetLatestAttempt(ctx); err != nil {
return nil, err
} else if has {
attempt = latestAttempt
}
}
@ -278,7 +271,6 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte
var previousAttemptURL *string
if attempt != nil {
attempt.Run = run
if err := attempt.LoadAttributes(ctx); err != nil {
return nil, err
}
@ -288,17 +280,19 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte
completedAt = attempt.Stopped.AsLocalTime()
triggerUser = attempt.TriggerUser
if attempt.Attempt > 1 {
previousAttemptURL = new(fmt.Sprintf("%s/actions/runs/%d/attempts/%d", run.Repo.APIURL(ctx), run.ID, attempt.Attempt-1))
url := fmt.Sprintf("%s/actions/runs/%d/attempts/%d", repo.APIURL(), run.ID, attempt.Attempt-1)
previousAttemptURL = &url
}
}
return &api.ActionWorkflowRun{
ID: run.ID,
URL: fmt.Sprintf("%s/actions/runs/%d", run.Repo.APIURL(ctx), run.ID),
URL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), run.ID),
PreviousAttemptURL: previousAttemptURL,
HTMLURL: run.HTMLURL(ctx),
HTMLURL: run.HTMLURL(),
RunNumber: run.Index,
RunAttempt: runAttempt,
CreatedAt: run.Created.AsLocalTime(),
StartedAt: startedAt,
CompletedAt: completedAt,
Event: run.TriggerEvent,
@ -308,7 +302,7 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte
Status: status,
Conclusion: conclusion,
Path: fmt.Sprintf("%s@%s", run.WorkflowID, run.Ref),
Repository: ToRepo(ctx, run.Repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
Repository: ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
TriggerActor: ToUser(ctx, triggerUser, nil),
Actor: ToUser(ctx, actor, nil),
}, nil
@ -406,11 +400,11 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
return &api.ActionWorkflowJob{
ID: job.ID,
// missing api endpoint for this location
URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(ctx), job.ID),
HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(ctx), job.ID),
URL: fmt.Sprintf("%s/actions/jobs/%d", repo.APIURL(), job.ID),
HTMLURL: fmt.Sprintf("%s/jobs/%d", job.Run.HTMLURL(), job.ID),
RunID: job.RunID,
// Missing api endpoint for this location, artifacts are available under a nested url
RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(ctx), job.RunID),
RunURL: fmt.Sprintf("%s/actions/runs/%d", repo.APIURL(), job.RunID),
Name: job.Name,
Labels: job.RunsOn,
RunAttempt: job.Attempt,

View File

@ -1043,8 +1043,7 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
return
}
run.Repo = repo
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, nil)
if err != nil {
log.Error("ToActionWorkflowRun: %v", err)
return

View File

@ -5421,6 +5421,55 @@
}
}
},
"/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": {
"description": "success"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
"get": {
"produces": [
@ -5597,6 +5646,55 @@
}
}
},
"/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": {
"description": "success"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
"get": {
"produces": [
@ -5661,6 +5759,56 @@
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/logs": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"repository"
],
"summary": "Download job logs as plain text",
"operationId": "getWorkflowJobLogs",
"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
},
{
"type": "integer",
"description": "id of the job",
"name": "job_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Job logs"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun": {
"post": {
"produces": [
@ -5723,6 +5871,164 @@
}
}
},
"/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"
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get streaming workflow run logs with cursor support",
"operationId": "getWorkflowRunLogsStream",
"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
},
{
"type": "integer",
"description": "job index (0-based), defaults to first job",
"name": "job",
"in": "query"
},
{
"name": "body",
"in": "body",
"schema": {
"type": "object",
"properties": {
"logCursors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cursor": {
"type": "integer"
},
"expanded": {
"type": "boolean"
},
"step": {
"type": "integer"
}
}
}
}
}
}
}
],
"responses": {
"200": {
"description": "Streaming logs",
"schema": {
"type": "object",
"properties": {
"stepsLog": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cursor": {
"type": "integer"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"index": {
"type": "integer"
},
"message": {
"type": "string"
},
"timestamp": {
"type": "number"
}
}
}
},
"started": {
"type": "integer"
},
"step": {
"type": "integer"
}
}
}
}
}
}
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/rerun": {
"post": {
"produces": [
@ -22066,6 +22372,11 @@
"type": "string",
"x-go-name": "Conclusion"
},
"created_at": {
"type": "string",
"format": "date-time",
"x-go-name": "CreatedAt"
},
"display_title": {
"type": "string",
"x-go-name": "DisplayTitle"

View File

@ -2305,6 +2305,11 @@
"type": "string",
"x-go-name": "Conclusion"
},
"created_at": {
"format": "date-time",
"type": "string",
"x-go-name": "CreatedAt"
},
"display_title": {
"type": "string",
"x-go-name": "DisplayTitle"
@ -16270,6 +16275,58 @@
]
}
},
"/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": {
"description": "success"
},
"400": {
"$ref": "#/components/responses/error"
},
"403": {
"$ref": "#/components/responses/forbidden"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "Approve a workflow run that requires approval",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
"get": {
"operationId": "getArtifactsOfRun",
@ -16467,6 +16524,58 @@
]
}
},
"/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": {
"description": "success"
},
"400": {
"$ref": "#/components/responses/error"
},
"403": {
"$ref": "#/components/responses/forbidden"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "Cancel a workflow run and its jobs",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
"get": {
"operationId": "listWorkflowRunJobs",
@ -16540,6 +16649,61 @@
]
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/logs": {
"get": {
"operationId": "getWorkflowJobLogs",
"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"
}
},
{
"description": "id of the job",
"in": "path",
"name": "job_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Job logs"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "Download job logs as plain text",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun": {
"post": {
"operationId": "rerunWorkflowJob",
@ -16607,6 +16771,176 @@
]
}
},
"/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"
]
},
"post": {
"operationId": "getWorkflowRunLogsStream",
"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"
}
},
{
"description": "job index (0-based), defaults to first job",
"in": "query",
"name": "job",
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"logCursors": {
"items": {
"properties": {
"cursor": {
"type": "integer"
},
"expanded": {
"type": "boolean"
},
"step": {
"type": "integer"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
}
}
},
"x-originalParamName": "body"
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"stepsLog": {
"items": {
"properties": {
"cursor": {
"type": "integer"
},
"lines": {
"items": {
"properties": {
"index": {
"type": "integer"
},
"message": {
"type": "string"
},
"timestamp": {
"type": "number"
}
},
"type": "object"
},
"type": "array"
},
"started": {
"type": "integer"
},
"step": {
"type": "integer"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
}
}
},
"description": "Streaming logs"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "Get streaming workflow run logs with cursor support",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/actions/runs/{run}/rerun": {
"post": {
"operationId": "rerunWorkflowRun",

View File

@ -4,10 +4,14 @@
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
@ -15,35 +19,17 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIActionsWorkflowRun(t *testing.T) {
func TestAPIActionsGetWorkflowRun(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
t.Run("GetWorkflowRun", testAPIActionsGetWorkflowRun)
t.Run("GetWorkflowJob", testAPIActionsGetWorkflowJob)
t.Run("ListUserWorkflows", testAPIActionsListUserWorkflows)
t.Run("ListRepoWorkflows", testAPIActionsListRepoWorkflows)
t.Run("DeleteRunCheckPermission", testAPIActionsDeleteRunCheckPermission)
t.Run("DeleteRunRunning", testAPIActionsDeleteRunRunning)
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) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, user.Name)
@ -74,9 +60,13 @@ func testAPIActionsGetWorkflowRun(t *testing.T) {
})
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)
jobList := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
var jobList api.ActionWorkflowJobsResponse
err = json.Unmarshal(resp.Body.Bytes(), &jobList)
require.NoError(t, err)
job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 })
require.NotEqual(t, -1, job198Idx, "expected to find job 198 in run 795 jobs list")
@ -86,7 +76,9 @@ func testAPIActionsGetWorkflowRun(t *testing.T) {
})
}
func testAPIActionsGetWorkflowJob(t *testing.T) {
func TestAPIActionsGetWorkflowJob(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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)
@ -103,7 +95,9 @@ func testAPIActionsGetWorkflowJob(t *testing.T) {
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIActionsDeleteRunCheckPermission(t *testing.T) {
func TestAPIActionsDeleteRunCheckPermission(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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)
@ -111,7 +105,9 @@ func testAPIActionsDeleteRunCheckPermission(t *testing.T) {
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
}
func testAPIActionsDeleteRunGeneral(t *testing.T) {
func TestAPIActionsDeleteRun(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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)
@ -126,7 +122,9 @@ func testAPIActionsDeleteRunGeneral(t *testing.T) {
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
}
func testAPIActionsDeleteRunRunning(t *testing.T) {
func TestAPIActionsDeleteRunRunning(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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)
@ -144,17 +142,22 @@ func testAPIActionsDeleteRun(t *testing.T, repo *repo_model.Repository, token st
}
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)
listResp := DecodeJSON(t, resp, &api.ActionArtifactsResponse{})
var listResp api.ActionArtifactsResponse
err := json.Unmarshal(resp.Body.Bytes(), &listResp)
assert.NoError(t, err)
assert.Len(t, listResp.Entries, artifacts)
}
func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, token string, expected bool) {
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)
listResp := DecodeJSON(t, resp, &api.ActionTaskResponse{})
var listResp api.ActionTaskResponse
err := json.Unmarshal(resp.Body.Bytes(), &listResp)
assert.NoError(t, err)
findTask1 := false
findTask2 := false
for _, entry := range listResp.Entries {
@ -171,7 +174,9 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository,
assert.Equal(t, expected, findTask2)
}
func testAPIActionsRerunWorkflowRun(t *testing.T) {
func TestAPIActionsRerunWorkflowRun(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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})
@ -191,10 +196,13 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) {
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/rerun", repo.FullName())).AddTokenAuth(writeToken)
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{})
var rerunResp api.ActionWorkflowRun
err := json.Unmarshal(resp.Body.Bytes(), &rerunResp)
require.NoError(t, err)
assert.Equal(t, int64(795), rerunResp.ID)
assert.Equal(t, "queued", rerunResp.Status)
assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", rerunResp.HeadSha)
@ -232,7 +240,144 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) {
})
}
func testAPIActionsRerunWorkflowJob(t *testing.T) {
func TestAPIActionsCancelWorkflowRun(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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)
MakeRequest(t, req, http.StatusOK)
})
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) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
// user2 is the owner of the base repo
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
// user4 is the owner of the fork repo
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
user4Token := getTokenForLoggedInUser(t, loginUser(t, user4.Name), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiBaseRepo := createActionsTestRepo(t, user2Token, "approve-workflow-run", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user2APICtx)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
// init workflow
wfTreePath := ".gitea/workflows/approve.yml"
wfFileContent := `name: Approve
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo test
`
opts := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create %s"+wfTreePath, wfFileContent)
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath, opts)
// user4 forks the repo
forkName := "approve-workflow-run-fork"
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
&api.CreateForkOption{
Name: &forkName,
}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user4APICtx)(t)
// user4 creates a pull request from a branch
doAPICreateFile(user4APICtx, "test.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "feature/test",
Message: "create test.txt",
Author: api.Identity{
Name: user4.Name,
Email: user4.Email,
},
Committer: api.Identity{
Name: user4.Name,
Email: user4.Email,
},
Dates: api.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("test")),
})(t)
_, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":feature/test")(t)
assert.NoError(t, err)
// check run
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID})
assert.True(t, run.NeedApproval)
assert.Equal(t, actions_model.StatusBlocked, run.Status)
// Test approve workflow run via API
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)).
AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusOK)
// Verify run was approved and jobs unblocked
updatedRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.False(t, updatedRun.NeedApproval)
assert.Equal(t, user2.ID, updatedRun.ApprovedBy)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(t.Context(), baseRepo.ID, run.ID)
require.NoError(t, err)
for _, job := range jobs {
assert.Equal(t, actions_model.StatusWaiting, job.Status)
}
// Test approve already approved run (idempotency)
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)).
AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusBadRequest)
// Test approve non-existent run
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/approve", baseRepo.FullName())).
AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusNotFound)
// Test approve by non-owner (user4 should get forbidden)
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", baseRepo.FullName(), run.ID)).
AddTokenAuth(user4Token)
MakeRequest(t, req, http.StatusForbidden)
})
}
func TestAPIActionsRerunWorkflowJob(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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})
@ -252,10 +397,13 @@ func testAPIActionsRerunWorkflowJob(t *testing.T) {
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)
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{})
var rerunResp api.ActionWorkflowJob
err := json.Unmarshal(resp.Body.Bytes(), &rerunResp)
require.NoError(t, err)
job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199)
assert.Equal(t, job199Rerun.ID, rerunResp.ID)
assert.Equal(t, "queued", rerunResp.Status)
@ -293,7 +441,9 @@ func testAPIActionsRerunWorkflowJob(t *testing.T) {
})
}
func testAPIActionsListUserWorkflows(t *testing.T) {
func TestAPIActionsListUserWorkflows(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
@ -301,7 +451,9 @@ func testAPIActionsListUserWorkflows(t *testing.T) {
t.Run("Runs", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/user/actions/runs").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
var runs api.ActionWorkflowRunsResponse
err := json.Unmarshal(resp.Body.Bytes(), &runs)
require.NoError(t, err)
assert.Positive(t, runs.TotalCount)
assert.NotEmpty(t, runs.Entries)
@ -317,7 +469,9 @@ func testAPIActionsListUserWorkflows(t *testing.T) {
t.Run("Jobs", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
var jobs api.ActionWorkflowJobsResponse
err := json.Unmarshal(resp.Body.Bytes(), &jobs)
require.NoError(t, err)
assert.Positive(t, jobs.TotalCount)
assert.NotEmpty(t, jobs.Entries)
@ -329,7 +483,9 @@ func testAPIActionsListUserWorkflows(t *testing.T) {
})
}
func testAPIActionsListRepoWorkflows(t *testing.T) {
func TestAPIActionsListRepoWorkflows(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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)
@ -337,7 +493,9 @@ func testAPIActionsListRepoWorkflows(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs", repo.FullName())).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
var runs api.ActionWorkflowRunsResponse
err := json.Unmarshal(resp.Body.Bytes(), &runs)
require.NoError(t, err)
assert.Positive(t, runs.TotalCount)
assert.NotEmpty(t, runs.Entries)
@ -348,3 +506,78 @@ func testAPIActionsListRepoWorkflows(t *testing.T) {
assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated")
}
}
func TestAPIActionsGetWorkflowRunLogs(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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("Success", func(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
})
t.Run("NotFound", 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)
})
}
func TestAPIActionsGetWorkflowJobLogs(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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 198 exists but has no log file in the test fixture
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/198/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/runs/795/jobs/999999/logs", repo.FullName())).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestAPIActionsGetWorkflowRunLogsStream(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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("EmptyCursors", func(t *testing.T) {
req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName()), strings.NewReader(`{"logCursors": []}`)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var logResp map[string]any
err := json.Unmarshal(resp.Body.Bytes(), &logResp)
assert.NoError(t, err)
assert.Contains(t, logResp, "stepsLog")
})
t.Run("WithCursor", func(t *testing.T) {
req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName()), strings.NewReader(`{"logCursors": [{"step": 0, "cursor": 0, "expanded": true}]}`)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
})
t.Run("NotFound", func(t *testing.T) {
req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/logs", repo.FullName()), strings.NewReader(`{"logCursors": []}`)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
}