mirror of https://github.com/go-gitea/gitea.git
Download actions job logs from API (#33858)
Related to #33709, #31416 It's similar with https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#download-job-logs-for-a-workflow-run--code-samples. This use `job_id` as path parameter which is consistent with Github's APIs. --------- Co-authored-by: ChristopherHX <christopher.homberger@web.de> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>pull/34032/head
parent
e0ad72e223
commit
0c6957ef8d
|
@ -10,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
|
@ -22,6 +23,7 @@ type ActionRunJob struct {
|
||||||
RunID int64 `xorm:"index"`
|
RunID int64 `xorm:"index"`
|
||||||
Run *ActionRun `xorm:"-"`
|
Run *ActionRun `xorm:"-"`
|
||||||
RepoID int64 `xorm:"index"`
|
RepoID int64 `xorm:"index"`
|
||||||
|
Repo *repo_model.Repository `xorm:"-"`
|
||||||
OwnerID int64 `xorm:"index"`
|
OwnerID int64 `xorm:"index"`
|
||||||
CommitSHA string `xorm:"index"`
|
CommitSHA string `xorm:"index"`
|
||||||
IsForkPullRequest bool
|
IsForkPullRequest bool
|
||||||
|
@ -58,6 +60,17 @@ func (job *ActionRunJob) LoadRun(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (job *ActionRunJob) LoadRepo(ctx context.Context) error {
|
||||||
|
if job.Repo == nil {
|
||||||
|
repo, err := repo_model.GetRepositoryByID(ctx, job.RepoID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
job.Repo = repo
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoadAttributes load Run if not loaded
|
// LoadAttributes load Run if not loaded
|
||||||
func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
|
func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
|
||||||
if job == nil {
|
if job == nil {
|
||||||
|
@ -83,7 +96,7 @@ func GetRunJobByID(ctx context.Context, id int64) (*ActionRunJob, error) {
|
||||||
return &job, nil
|
return &job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRunJobsByRunID(ctx context.Context, runID int64) ([]*ActionRunJob, error) {
|
func GetRunJobsByRunID(ctx context.Context, runID int64) (ActionJobList, error) {
|
||||||
var jobs []*ActionRunJob
|
var jobs []*ActionRunJob
|
||||||
if err := db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("id").Find(&jobs); err != nil {
|
if err := db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("id").Find(&jobs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/modules/container"
|
"code.gitea.io/gitea/modules/container"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
|
||||||
|
@ -21,7 +22,33 @@ func (jobs ActionJobList) GetRunIDs() []int64 {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (jobs ActionJobList) LoadRepos(ctx context.Context) error {
|
||||||
|
repoIDs := container.FilterSlice(jobs, func(j *ActionRunJob) (int64, bool) {
|
||||||
|
return j.RepoID, j.RepoID != 0 && j.Repo == nil
|
||||||
|
})
|
||||||
|
if len(repoIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repos := make(map[int64]*repo_model.Repository, len(repoIDs))
|
||||||
|
if err := db.GetEngine(ctx).In("id", repoIDs).Find(&repos); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, j := range jobs {
|
||||||
|
if j.RepoID > 0 && j.Repo == nil {
|
||||||
|
j.Repo = repos[j.RepoID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (jobs ActionJobList) LoadRuns(ctx context.Context, withRepo bool) error {
|
func (jobs ActionJobList) LoadRuns(ctx context.Context, withRepo bool) error {
|
||||||
|
if withRepo {
|
||||||
|
if err := jobs.LoadRepos(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runIDs := jobs.GetRunIDs()
|
runIDs := jobs.GetRunIDs()
|
||||||
runs := make(map[int64]*ActionRun, len(runIDs))
|
runs := make(map[int64]*ActionRun, len(runIDs))
|
||||||
if err := db.GetEngine(ctx).In("id", runIDs).Find(&runs); err != nil {
|
if err := db.GetEngine(ctx).In("id", runIDs).Find(&runs); err != nil {
|
||||||
|
@ -30,15 +57,9 @@ func (jobs ActionJobList) LoadRuns(ctx context.Context, withRepo bool) error {
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
if j.RunID > 0 && j.Run == nil {
|
if j.RunID > 0 && j.Run == nil {
|
||||||
j.Run = runs[j.RunID]
|
j.Run = runs[j.RunID]
|
||||||
|
j.Run.Repo = j.Repo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if withRepo {
|
|
||||||
var runsList RunList = make([]*ActionRun, 0, len(runs))
|
|
||||||
for _, r := range runs {
|
|
||||||
runsList = append(runsList, r)
|
|
||||||
}
|
|
||||||
return runsList.LoadRepos(ctx)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1168,6 +1168,10 @@ func Routes() *web.Router {
|
||||||
m.Post("/{workflow_id}/dispatches", reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), repo.ActionsDispatchWorkflow)
|
m.Post("/{workflow_id}/dispatches", reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), repo.ActionsDispatchWorkflow)
|
||||||
}, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions))
|
}, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions))
|
||||||
|
|
||||||
|
m.Group("/actions/jobs", func() {
|
||||||
|
m.Get("/{job_id}/logs", repo.DownloadActionsRunJobLogs)
|
||||||
|
}, reqToken(), reqRepoReader(unit.TypeActions))
|
||||||
|
|
||||||
m.Group("/hooks/git", func() {
|
m.Group("/hooks/git", func() {
|
||||||
m.Combo("").Get(repo.ListGitHooks)
|
m.Combo("").Get(repo.ListGitHooks)
|
||||||
m.Group("/{id}", func() {
|
m.Group("/{id}", func() {
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
"code.gitea.io/gitea/routers/common"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DownloadActionsRunJobLogs(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs repository downloadActionsRunJobLogs
|
||||||
|
// ---
|
||||||
|
// summary: Downloads the job logs for a workflow run
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: name of the owner
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repository
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: job_id
|
||||||
|
// in: path
|
||||||
|
// description: id of the job
|
||||||
|
// type: integer
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// description: output blob content
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
jobID := ctx.PathParamInt64("job_id")
|
||||||
|
curJob, err := actions_model.GetRunJobByID(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = curJob.LoadRepo(ctx); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
|
ctx.APIErrorNotFound(err)
|
||||||
|
} else {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/modules/actions"
|
||||||
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DownloadActionsRunJobLogsWithIndex(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobIndex int64) error {
|
||||||
|
runJobs, err := actions_model.GetRunJobsByRunID(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GetRunJobsByRunID: %w", err)
|
||||||
|
}
|
||||||
|
if err = runJobs.LoadRepos(ctx); err != nil {
|
||||||
|
return fmt.Errorf("LoadRepos: %w", err)
|
||||||
|
}
|
||||||
|
if 0 < jobIndex || jobIndex >= int64(len(runJobs)) {
|
||||||
|
return util.NewNotExistErrorf("job index is out of range: %d", jobIndex)
|
||||||
|
}
|
||||||
|
return DownloadActionsRunJobLogs(ctx, ctxRepo, runJobs[jobIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if curJob.TaskID == 0 {
|
||||||
|
return util.NewNotExistErrorf("job not started")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := curJob.LoadRun(ctx); err != nil {
|
||||||
|
return fmt.Errorf("LoadRun: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := actions_model.GetTaskByID(ctx, curJob.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GetTaskByID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.LogExpired {
|
||||||
|
return util.NewNotExistErrorf("logs have been cleaned up")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("OpenLogs: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
workflowName := curJob.Run.WorkflowID
|
||||||
|
if p := strings.Index(workflowName, "."); p > 0 {
|
||||||
|
workflowName = workflowName[0:p]
|
||||||
|
}
|
||||||
|
ctx.ServeContent(reader, &context.ServeHeaderOptions{
|
||||||
|
Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, curJob.Name, task.ID),
|
||||||
|
ContentLength: &task.LogSize,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
ContentTypeCharset: "utf-8",
|
||||||
|
Disposition: "attachment",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
actions_model "code.gitea.io/gitea/models/actions"
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
|
@ -31,6 +30,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
|
"code.gitea.io/gitea/routers/common"
|
||||||
actions_service "code.gitea.io/gitea/services/actions"
|
actions_service "code.gitea.io/gitea/services/actions"
|
||||||
context_module "code.gitea.io/gitea/services/context"
|
context_module "code.gitea.io/gitea/services/context"
|
||||||
notify_service "code.gitea.io/gitea/services/notify"
|
notify_service "code.gitea.io/gitea/services/notify"
|
||||||
|
@ -469,49 +469,19 @@ func Logs(ctx *context_module.Context) {
|
||||||
runIndex := getRunIndex(ctx)
|
runIndex := getRunIndex(ctx)
|
||||||
jobIndex := ctx.PathParamInt64("job")
|
jobIndex := ctx.PathParamInt64("job")
|
||||||
|
|
||||||
job, _ := getRunJobs(ctx, runIndex, jobIndex)
|
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
|
||||||
if ctx.Written() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if job.TaskID == 0 {
|
|
||||||
ctx.HTTPError(http.StatusNotFound, "job is not started")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := job.LoadRun(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool {
|
||||||
|
return errors.Is(err, util.ErrNotExist)
|
||||||
|
}, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
task, err := actions_model.GetTaskByID(ctx, job.TaskID)
|
if err = common.DownloadActionsRunJobLogsWithIndex(ctx.Base, ctx.Repo.Repository, run.ID, jobIndex); err != nil {
|
||||||
if err != nil {
|
ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithIndex", func(err error) bool {
|
||||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
return errors.Is(err, util.ErrNotExist)
|
||||||
return
|
}, err)
|
||||||
}
|
}
|
||||||
if task.LogExpired {
|
|
||||||
ctx.HTTPError(http.StatusNotFound, "logs have been cleaned up")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
|
|
||||||
if err != nil {
|
|
||||||
ctx.HTTPError(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
workflowName := job.Run.WorkflowID
|
|
||||||
if p := strings.Index(workflowName, "."); p > 0 {
|
|
||||||
workflowName = workflowName[0:p]
|
|
||||||
}
|
|
||||||
ctx.ServeContent(reader, &context_module.ServeHeaderOptions{
|
|
||||||
Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, job.Name, task.ID),
|
|
||||||
ContentLength: &task.LogSize,
|
|
||||||
ContentType: "text/plain",
|
|
||||||
ContentTypeCharset: "utf-8",
|
|
||||||
Disposition: "attachment",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Cancel(ctx *context_module.Context) {
|
func Cancel(ctx *context_module.Context) {
|
||||||
|
|
|
@ -4187,6 +4187,52 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "Downloads the job logs for a workflow run",
|
||||||
|
"operationId": "downloadActionsRunJobLogs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the owner",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repository",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "id of the job",
|
||||||
|
"name": "job_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "output blob content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/actions/runners/registration-token": {
|
"/repos/{owner}/{repo}/actions/runners/registration-token": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
|
|
@ -7,10 +7,12 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
actions_model "code.gitea.io/gitea/models/actions"
|
||||||
auth_model "code.gitea.io/gitea/models/auth"
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
@ -149,6 +151,27 @@ jobs:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runID, _ := strconv.ParseInt(task.Context.GetFields()["run_id"].GetStringValue(), 10, 64)
|
||||||
|
|
||||||
|
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), runID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, jobs, 1)
|
||||||
|
jobID := jobs[0].ID
|
||||||
|
|
||||||
|
// download task logs from API and check content
|
||||||
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs/%d/logs", user2.Name, repo.Name, jobID)).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
logTextLines = strings.Split(strings.TrimSpace(resp.Body.String()), "\n")
|
||||||
|
assert.Len(t, logTextLines, len(tc.outcome.logRows))
|
||||||
|
for idx, lr := range tc.outcome.logRows {
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
fmt.Sprintf("%s %s", lr.Time.AsTime().Format("2006-01-02T15:04:05.0000000Z07:00"), lr.Content),
|
||||||
|
logTextLines[idx],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
resetFunc()
|
resetFunc()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue