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 acf45d989a810..5d2d6b8fffc9d 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3779,6 +3779,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..b9cdf71d09f22 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -5,6 +5,7 @@ package repo import ( "errors" + "net/http" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/modules/util" @@ -34,6 +35,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 +49,11 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) { // "$ref": "#/responses/notFound" jobID := ctx.PathParamInt64("job_id") + attempt := ctx.FormInt64("attempt") + if attempt < 0 { + ctx.APIError(http.StatusBadRequest, util.NewInvalidArgumentErrorf("attempt must be >= 0")) + return + } curJob, err := actions_model.GetRunJobByRepoAndID(ctx, ctx.Repo.Repository.ID, jobID) if err != nil { if errors.Is(err, util.ErrNotExist) { @@ -57,7 +68,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..f50927722069b 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" @@ -30,6 +31,7 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt mockedLogs = append(mockedLogs, "::group::test group for: step={step}, cursor={cursor}") mockedLogs = append(mockedLogs, slices.Repeat([]string{"in group msg for: step={step}, cursor={cursor}"}, opts.groupRepeat)...) mockedLogs = append(mockedLogs, "::endgroup::") + mockedLogs = append(mockedLogs, "::error::error message for: step={step}, cursor={cursor}") mockedLogs = append(mockedLogs, "message for: step={step}, cursor={cursor}", "message for: step={step}, cursor={cursor}", @@ -64,6 +66,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") @@ -122,6 +135,7 @@ func MockActionsRunsJobs(ctx *context.Context) { Name: "job 100", Status: actions_model.StatusRunning.String(), CanRerun: true, + Attempt: 3, Duration: "1h", }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ @@ -130,6 +144,7 @@ func MockActionsRunsJobs(ctx *context.Context) { Name: "job 101", Status: actions_model.StatusWaiting.String(), CanRerun: false, + Attempt: 1, Duration: "2h", Needs: []string{"job-100"}, }) @@ -139,6 +154,7 @@ func MockActionsRunsJobs(ctx *context.Context) { Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit", Status: actions_model.StatusFailure.String(), CanRerun: false, + Attempt: 2, Duration: "3h", Needs: []string{"job-100", "job-101"}, }) @@ -148,6 +164,7 @@ func MockActionsRunsJobs(ctx *context.Context) { Name: "job 103", Status: actions_model.StatusCancelled.String(), CanRerun: false, + Attempt: 1, Duration: "2m", Needs: []string{"job-100"}, }) @@ -161,6 +178,7 @@ func MockActionsRunsJobs(ctx *context.Context) { Name: "job dup test " + strconv.Itoa(i), Status: actions_model.StatusSuccess.String(), CanRerun: false, + Attempt: 1, Duration: "2m", Needs: []string{"job-103", "job-101", "job-100"}, }) @@ -178,6 +196,16 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo } req := web.GetForm(ctx).(*actions.ViewRequest) + + if ctx.PathParamInt64("run") == 10 && jobID == 100 { + resp.State.CurrentJob.Attempt = 4 + 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(-10 * time.Minute).Unix(), LogExpired: true}, + } + } + 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..760cc0eefbfd8 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -151,9 +151,11 @@ 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"` + Attempt int64 `json:"attempt"` + Steps []*ViewJobStep `json:"steps"` + AvailableAttempts []*ViewAttempt `json:"availableAttempts"` } `json:"currentJob"` } `json:"state"` Logs struct { @@ -167,6 +169,7 @@ type ViewJob struct { Name string `json:"name"` Status string `json:"status"` CanRerun bool `json:"canRerun"` + Attempt int64 `json:"attempt"` Duration string `json:"duration"` Needs []string `json:"needs,omitempty"` } @@ -195,6 +198,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"` @@ -283,6 +294,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, Name: v.Name, Status: v.Status.String(), CanRerun: resp.State.Run.CanRerun, + Attempt: v.Attempt, Duration: v.Duration().String(), Needs: v.Needs, }) @@ -327,6 +339,26 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon return } + if current.Attempt > 1 { + allTasks, err := actions_model.GetTasksByJobID(ctx, current.ID) + if err != nil { + ctx.ServerError("actions_model.GetTasksByJobID", err) + return + } + for _, t := range allTasks { + if t.Attempt == current.Attempt { + continue + } + 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 @@ -344,6 +376,7 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon resp.State.CurrentJob.Title = current.Name resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale) + resp.State.CurrentJob.Attempt = current.Attempt if run.NeedApproval { resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc") } @@ -375,7 +408,7 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors } for _, cursor := range cursors { - if !cursor.Expanded { + if !cursor.Expanded || cursor.Step < 0 || cursor.Step >= len(steps) { continue } @@ -514,8 +547,13 @@ func Logs(ctx *context_module.Context) { return } jobID := ctx.PathParamInt64("job") + attempt := ctx.FormInt64("attempt") + if attempt < 0 { + ctx.HTTPError(http.StatusBadRequest, "attempt") + return + } - 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/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index b4247e261243d..04c9085267f41 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -4938,6 +4938,12 @@ "name": "job_id", "in": "path", "required": true + }, + { + "type": "integer", + "description": "the attempt number of the job (0 or omit for latest)", + "name": "attempt", + "in": "query" } ], "responses": { diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go index 91d56507ede9f..c3e68a0e67833 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) + assert.Equal(t, int64(2), viewResp.State.CurrentJob.Attempt) + require.Len(t, viewResp.State.CurrentJob.AvailableAttempts, 1) + assert.Equal(t, int64(1), viewResp.State.CurrentJob.AvailableAttempts[0].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..279be328e8f1c 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, @@ -52,7 +53,9 @@ type LocaleStorageOptions = { type CurrentJob = { title: string; detail: string; + attempt: number; steps: Array; + availableAttempts: Array; }; type JobData = { @@ -111,7 +114,9 @@ const optionAlwaysExpandRunning = ref(expandRunning); const currentJob = ref({ title: '', detail: '', + attempt: 0, steps: [] as Array, + availableAttempts: [], }); const stepsContainer = ref(null); const jobStepLogs = ref>([]); @@ -412,6 +417,24 @@ async function hashChangeListener() { {{ currentJob.detail }}

+
+ ×{{ job.attempt }} {{ job.duration }} @@ -302,6 +303,20 @@ async function deleteArtifact(name: string) { align-items: center; } +.job-brief-attempt { + margin-left: 0.5rem; + margin-right: 0.5rem; + flex-shrink: 0; + font-size: 12px; + line-height: 1; + padding: 2px 6px; + border: 1px solid var(--color-secondary); + border-radius: 9999px; + color: var(--color-text-light-2); + background: transparent; + font-variant-numeric: tabular-nums; +} + .job-brief-item .job-brief-item-left .job-brief-name { display: block; } diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index ad233246309fa..d6fd3f568d624 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -50,6 +50,8 @@ export function initRepositoryActionView() { }, logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'), logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'), + attempt: el.getAttribute('data-locale-attempt'), + previousLogs: el.getAttribute('data-locale-previous-logs'), }, }); view.mount(el); diff --git a/web_src/js/modules/gitea-actions.ts b/web_src/js/modules/gitea-actions.ts index 96b31e4c9496e..8fe3eda4c969e 100644 --- a/web_src/js/modules/gitea-actions.ts +++ b/web_src/js/modules/gitea-actions.ts @@ -43,6 +43,7 @@ export type ActionsJob = { name: string; status: ActionsRunStatus; canRerun: boolean; + attempt: number; needs?: string[]; duration: string; };