From 00f83749e19db96c0839f34176dc79597fdd3989 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 31 Mar 2026 00:18:46 +0200 Subject: [PATCH 01/11] commit --- models/actions/task.go | 26 +++++++- options/locale/locale_en-US.json | 2 + routers/api/v1/repo/actions_run.go | 8 ++- routers/common/actions.go | 28 ++++++--- routers/web/devtest/mock_actions.go | 21 +++++++ routers/web/repo/actions/view.go | 37 +++++++++-- routers/web/web.go | 1 + templates/repo/actions/view_component.tmpl | 2 + tests/integration/actions_route_test.go | 71 ++++++++++++++++++++++ web_src/js/components/ActionRunJobView.vue | 4 ++ web_src/js/components/ActionRunView.ts | 9 +++ web_src/js/components/RepoActionView.vue | 26 +++++++- web_src/js/features/repo-actions.ts | 2 + 13 files changed, 219 insertions(+), 18 deletions(-) diff --git a/models/actions/task.go b/models/actions/task.go index e092d6fbbd948..1667733759481 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -29,7 +29,7 @@ import ( // ActionTask represents a distribution of job type ActionTask struct { ID int64 - JobID int64 + JobID int64 `xorm:"index"` Job *ActionRunJob `xorm:"-"` Steps []*ActionTaskStep `xorm:"-"` Attempt int64 @@ -164,6 +164,30 @@ func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) { return &task, nil } +// GetTasksByJobID returns lightweight task metadata for all attempts of a job, +// ordered by attempt ascending. Only fields needed for the attempts list are selected +// to avoid loading LogIndexes (LONGBLOB) on every request. +func GetTasksByJobID(ctx context.Context, jobID int64) ([]*ActionTask, error) { + var tasks []*ActionTask + return tasks, db.GetEngine(ctx). + Cols("id", "job_id", "attempt", "status", "started", "stopped", "log_expired"). + Where("job_id=?", jobID). + OrderBy("attempt ASC"). + Find(&tasks) +} + +// GetTaskByJobAndAttempt returns the task for a specific attempt of a job. +func GetTaskByJobAndAttempt(ctx context.Context, jobID, attempt int64) (*ActionTask, error) { + var task ActionTask + has, err := db.GetEngine(ctx).Where("job_id=? AND attempt=?", jobID, attempt).Get(&task) + if err != nil { + return nil, err + } else if !has { + return nil, util.NewNotExistErrorf("task with job_id %d attempt %d", jobID, attempt) + } + return &task, nil +} + func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) { errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist) if token == "" { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 2ffa130751c7a..ec7695a7532be 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3772,6 +3772,8 @@ "actions.variables.update.success": "The variable has been edited.", "actions.logs.always_auto_scroll": "Always auto scroll logs", "actions.logs.always_expand_running": "Always expand running logs", + "actions.attempt": "Attempt", + "actions.previous_logs": "Previous logs", "actions.general": "General", "actions.general.enable_actions": "Enable Actions", "actions.general.collaborative_owners_management": "Collaborative Owners Management", diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 64ac1a3ad5f02..185b4c8c662ff 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -34,6 +34,11 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) { // description: id of the job // type: integer // required: true + // - name: attempt + // in: query + // description: the attempt number of the job (0 or omit for latest) + // type: integer + // required: false // responses: // "200": // description: output blob content @@ -43,6 +48,7 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) { // "$ref": "#/responses/notFound" jobID := ctx.PathParamInt64("job_id") + attempt := ctx.FormInt64("attempt") curJob, err := actions_model.GetRunJobByRepoAndID(ctx, ctx.Repo.Repository.ID, jobID) if err != nil { if errors.Is(err, util.ErrNotExist) { @@ -57,7 +63,7 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) { return } - err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob) + err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob, attempt) if err != nil { if errors.Is(err, util.ErrNotExist) { ctx.APIErrorNotFound(err) diff --git a/routers/common/actions.go b/routers/common/actions.go index 4eb7078db6754..ab02e434e899e 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -15,7 +15,7 @@ import ( "code.gitea.io/gitea/services/context" ) -func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error { +func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID, attempt int64) error { job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID) if err != nil { return err @@ -23,25 +23,33 @@ func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repo if err := job.LoadRepo(ctx); err != nil { return fmt.Errorf("LoadRepo: %w", err) } - return DownloadActionsRunJobLogs(ctx, ctxRepo, job) + return DownloadActionsRunJobLogs(ctx, ctxRepo, job, attempt) } -func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error { +func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob, attempt int64) 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) + var task *actions_model.ActionTask + var err error + if attempt > 0 { + task, err = actions_model.GetTaskByJobAndAttempt(ctx, curJob.ID, attempt) + if err != nil { + return fmt.Errorf("GetTaskByJobAndAttempt: %w", err) + } + } else { + if curJob.TaskID == 0 { + return util.NewNotExistErrorf("job not started") + } + task, err = actions_model.GetTaskByID(ctx, curJob.TaskID) + if err != nil { + return fmt.Errorf("GetTaskByID: %w", err) + } } if task.LogExpired { diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 0fb2a358243c0..1390bfe5248d3 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -4,6 +4,7 @@ package devtest import ( + "fmt" mathRand "math/rand/v2" "net/http" "slices" @@ -64,6 +65,17 @@ func MockActionsView(ctx *context.Context) { ctx.HTML(http.StatusOK, "devtest/repo-action-view") } +func MockActionsJobLogs(ctx *context.Context) { + runID := ctx.PathParamInt64("run") + jobID := ctx.PathParamInt64("job") + attempt := ctx.FormInt64("attempt") + if attempt <= 0 { + attempt = 3 + } + + ctx.PlainText(http.StatusOK, fmt.Sprintf("mock run=%d job=%d attempt=%d log line 1\nmock run=%d job=%d attempt=%d log line 2\n", runID, jobID, attempt, runID, jobID, attempt)) +} + func MockActionsRunsJobs(ctx *context.Context) { runID := ctx.PathParamInt64("run") @@ -178,6 +190,15 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo } req := web.GetForm(ctx).(*actions.ViewRequest) + + if ctx.PathParamInt64("run") == 10 && jobID == 100 { + resp.State.CurrentJob.AvailableAttempts = []*actions.ViewAttempt{ + {Attempt: 1, Status: actions_model.StatusFailure.String(), Started: time.Now().Add(-2 * time.Hour).Unix(), Stopped: time.Now().Add(-110 * time.Minute).Unix()}, + {Attempt: 2, Status: actions_model.StatusCancelled.String(), Started: time.Now().Add(-90 * time.Minute).Unix(), Stopped: time.Now().Add(-80 * time.Minute).Unix()}, + {Attempt: 3, Status: actions_model.StatusSuccess.String(), Started: time.Now().Add(-30 * time.Minute).Unix(), Stopped: time.Now().Add(-20 * time.Minute).Unix()}, + } + } + var mockLogOptions []generateMockStepsLogOptions resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ Summary: "step 0 (mock slow)", diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 6b3e95f3dafb9..3456e7c0342c1 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -151,9 +151,10 @@ type ViewResponse struct { TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule } `json:"run"` CurrentJob struct { - Title string `json:"title"` - Detail string `json:"detail"` - Steps []*ViewJobStep `json:"steps"` + Title string `json:"title"` + Detail string `json:"detail"` + Steps []*ViewJobStep `json:"steps"` + AvailableAttempts []*ViewAttempt `json:"availableAttempts"` } `json:"currentJob"` } `json:"state"` Logs struct { @@ -195,6 +196,14 @@ type ViewJobStep struct { Status string `json:"status"` } +type ViewAttempt struct { + Attempt int64 `json:"attempt"` + Status string `json:"status"` + Started int64 `json:"started"` // unix seconds + Stopped int64 `json:"stopped"` // unix seconds + LogExpired bool `json:"logExpired"` +} + type ViewStepLog struct { Step int `json:"step"` Cursor int64 `json:"cursor"` @@ -327,6 +336,25 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon return } + // Only query previous attempts when more than one attempt has been made. + if current.Attempt > 1 { + allTasks, err := actions_model.GetTasksByJobID(ctx, current.ID) + if err != nil { + ctx.ServerError("actions_model.GetTasksByJobID", err) + return + } + resp.State.CurrentJob.AvailableAttempts = make([]*ViewAttempt, 0, len(allTasks)) + for _, t := range allTasks { + resp.State.CurrentJob.AvailableAttempts = append(resp.State.CurrentJob.AvailableAttempts, &ViewAttempt{ + Attempt: t.Attempt, + Status: t.Status.String(), + Started: t.Started.AsTime().Unix(), + Stopped: t.Stopped.AsTime().Unix(), + LogExpired: t.LogExpired, + }) + } + } + var task *actions_model.ActionTask if current.TaskID > 0 { var err error @@ -514,8 +542,9 @@ func Logs(ctx *context_module.Context) { return } jobID := ctx.PathParamInt64("job") + attempt := ctx.FormInt64("attempt") - if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil { + if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID, attempt); err != nil { ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool { return errors.Is(err, util.ErrNotExist) }, err) diff --git a/routers/web/web.go b/routers/web/web.go index e3dcf27cc4afe..bbe1f867a605e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1749,6 +1749,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Any("/{sub}", devtest.TmplCommon) m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView) m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView) + m.Get("/repo-action-view/runs/{run}/jobs/{job}/logs", devtest.MockActionsJobLogs) m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) }) diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index 59b5c9cbf9dec..1d1c4f8522df3 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -33,5 +33,7 @@ data-locale-download-logs="{{ctx.Locale.Tr "download_logs"}}" data-locale-logs-always-auto-scroll="{{ctx.Locale.Tr "actions.logs.always_auto_scroll"}}" data-locale-logs-always-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}" + data-locale-attempt="{{ctx.Locale.Tr "actions.attempt"}}" + data-locale-previous-logs="{{ctx.Locale.Tr "actions.previous_logs"}}" > diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go index 91d56507ede9f..0c478881e788f 100644 --- a/tests/integration/actions_route_test.go +++ b/tests/integration/actions_route_test.go @@ -5,6 +5,7 @@ package integration import ( "fmt" + "io" "net/http" "net/url" "testing" @@ -16,6 +17,7 @@ import ( runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestActionsRoute(t *testing.T) { @@ -98,3 +100,72 @@ jobs: user2Session.MakeRequest(t, req, http.StatusNotFound) }) } + +func TestActionsRouteJobAttemptLogs(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + repo := createActionsTestRepo(t, token, "actions-attempt-logs", false) + runner := newMockRunner() + runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false) + + workflowTreePath := ".gitea/workflows/test.yml" + workflowContent := `name: test +on: + push: + paths: + - '.gitea/workflows/test.yml' +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo job1 +` + + opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+workflowTreePath, workflowContent) + createWorkflowFile(t, token, user2.Name, repo.Name, workflowTreePath, opts) + + task1 := runner.fetchTask(t) + _, job, run := getTaskAndJobAndRunByTaskID(t, task1.Id) + runner.execTask(t, task1, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + logRows: []*runnerv1.LogRow{{Content: "attempt-1"}}, + }) + + req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job.ID)) + session.MakeRequest(t, req, http.StatusOK) + + task2 := runner.fetchTask(t) + runner.execTask(t, task2, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + logRows: []*runnerv1.LogRow{{Content: "attempt-2"}}, + }) + runner.fetchNoTask(t) + + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo.Name, run.ID, job.ID)) + resp := session.MakeRequest(t, req, http.StatusOK) + var viewResp actions_web.ViewResponse + DecodeJSON(t, resp, &viewResp) + require.Len(t, viewResp.State.CurrentJob.AvailableAttempts, 2) + assert.Equal(t, int64(1), viewResp.State.CurrentJob.AvailableAttempts[0].Attempt) + assert.Equal(t, int64(2), viewResp.State.CurrentJob.AvailableAttempts[1].Attempt) + + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs?attempt=1", user2.Name, repo.Name, run.ID, job.ID)) + resp = session.MakeRequest(t, req, http.StatusOK) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "attempt-1") + assert.NotContains(t, string(body), "attempt-2") + + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job.ID)) + resp = session.MakeRequest(t, req, http.StatusOK) + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "attempt-2") + + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs?attempt=99", user2.Name, repo.Name, run.ID, job.ID)) + session.MakeRequest(t, req, http.StatusNotFound) + }) +} diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index fba78917c9c69..fb894976c3fb4 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -11,6 +11,7 @@ import {localUserSettings} from '../modules/user-settings.ts'; import type {ActionsArtifact, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts'; import { type ActionRunViewStore, + type ActionsAttempt, createLogLineMessage, type LogLine, type LogLineCommand, @@ -53,6 +54,7 @@ type CurrentJob = { title: string; detail: string; steps: Array; + availableAttempts: Array; }; type JobData = { @@ -112,6 +114,7 @@ const currentJob = ref({ title: '', detail: '', steps: [] as Array, + availableAttempts: [], }); const stepsContainer = ref(null); const jobStepLogs = ref>([]); @@ -296,6 +299,7 @@ async function loadJob() { // Use consistent "store" operations to load/update the view data store.viewData.runArtifacts = runJobResp.artifacts || []; store.viewData.currentRun = runJobResp.state.run; + store.viewData.currentJobAttempts = runJobResp.state.currentJob.availableAttempts || []; currentJob.value = runJobResp.state.currentJob; const jobLogs = runJobResp.logs.stepsLog ?? []; diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts index 133b7263ebacf..b26194cfd588e 100644 --- a/web_src/js/components/ActionRunView.ts +++ b/web_src/js/components/ActionRunView.ts @@ -5,6 +5,14 @@ import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsRunStatus} from '.. import type {IntervalId} from '../types.ts'; import {POST} from '../modules/fetch.ts'; +export type ActionsAttempt = { + attempt: number; + status: ActionsRunStatus; + started: number; + stopped: number; + logExpired: boolean; +}; + // How GitHub Actions logs work: // * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...." // * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc. @@ -131,6 +139,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) { const viewData = reactive({ currentRun: createEmptyActionsRun(), runArtifacts: [] as Array, + currentJobAttempts: [] as Array, }); const loadCurrentRun = async () => { if (loadingAbortController) return; diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 3637763b90e95..55e9772695f59 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -1,7 +1,7 @@