// Copyright 2022 The Gitea Authors. All rights reserved. // Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions import ( "archive/zip" "compress/gzip" "context" "errors" "fmt" "html/template" "io" "net/http" "net/url" "strconv" "strings" "time" actions_model "forgejo.org/models/actions" "forgejo.org/models/db" git_model "forgejo.org/models/git" repo_model "forgejo.org/models/repo" "forgejo.org/models/unit" "forgejo.org/modules/actions" "forgejo.org/modules/base" "forgejo.org/modules/git" "forgejo.org/modules/json" "forgejo.org/modules/log" "forgejo.org/modules/setting" "forgejo.org/modules/storage" "forgejo.org/modules/templates" "forgejo.org/modules/timeutil" "forgejo.org/modules/util" "forgejo.org/modules/web" "forgejo.org/routers/common" actions_service "forgejo.org/services/actions" context_module "forgejo.org/services/context" "xorm.io/builder" ) func RedirectToLatestAttempt(ctx *context_module.Context) { runIndex := ctx.ParamsInt64("run") jobIndex := ctx.ParamsInt64("job") job, _ := getRunJobs(ctx, runIndex, jobIndex) if ctx.Written() { return } jobURL, err := job.HTMLURL(ctx) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return } ctx.Redirect(jobURL, http.StatusTemporaryRedirect) } func View(ctx *context_module.Context) { ctx.Data["PageIsActions"] = true runIndex := ctx.ParamsInt64("run") jobIndex := ctx.ParamsInt64("job") // note: this is `attemptNumber` not `attemptIndex` since this value has to matches the ActionTask's Attempt field // which uses 1-based numbering... would be confusing as "Index" if it later can't be used to index an slice/array. attemptNumber := ctx.ParamsInt64("attempt") job, _ := getRunJobs(ctx, runIndex, jobIndex) if ctx.Written() { return } workflowName := job.Run.WorkflowID ctx.Data["RunIndex"] = runIndex ctx.Data["RunID"] = job.Run.ID ctx.Data["JobIndex"] = jobIndex ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" ctx.Data["AttemptNumber"] = attemptNumber ctx.Data["WorkflowName"] = workflowName ctx.Data["WorkflowURL"] = ctx.Repo.RepoLink + "/actions?workflow=" + workflowName viewResponse := getViewResponse(ctx, &ViewRequest{}, runIndex, jobIndex, attemptNumber) if ctx.Written() { return } artifactsViewResponse := getArtifactsViewResponse(ctx, runIndex) if ctx.Written() { return } var buf1, buf2 strings.Builder if err := json.NewEncoder(&buf1).Encode(viewResponse); err != nil { ctx.ServerError("EncodingError", err) return } ctx.Data["InitialData"] = buf1.String() if err := json.NewEncoder(&buf2).Encode(artifactsViewResponse); err != nil { ctx.ServerError("EncodingError", err) return } ctx.Data["InitialArtifactsData"] = buf2.String() ctx.HTML(http.StatusOK, tplViewActions) } func ViewLatest(ctx *context_module.Context) { run, err := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.NotFound("GetLatestRun", err) return } err = run.LoadAttributes(ctx) if err != nil { ctx.ServerError("LoadAttributes", err) return } ctx.Redirect(run.HTMLURL(), http.StatusTemporaryRedirect) } func ViewLatestWorkflowRun(ctx *context_module.Context) { branch := ctx.FormString("branch") if branch == "" { branch = ctx.Repo.Repository.DefaultBranch } branch = fmt.Sprintf("refs/heads/%s", branch) event := ctx.FormString("event") workflowFile := ctx.Params("workflow_name") run, err := actions_model.GetLatestRunForBranchAndWorkflow(ctx, ctx.Repo.Repository.ID, branch, workflowFile, event) if err != nil { if errors.Is(err, util.ErrNotExist) { ctx.NotFound("GetLatestRunForBranchAndWorkflow", err) } else { ctx.ServerError("GetLatestRunForBranchAndWorkflow", err) } return } err = run.LoadAttributes(ctx) if err != nil { ctx.ServerError("LoadAttributes", err) return } ctx.Redirect(run.HTMLURL(), http.StatusTemporaryRedirect) } type ViewRequest struct { LogCursors []struct { Step int `json:"step"` Cursor int64 `json:"cursor"` Expanded bool `json:"expanded"` } `json:"logCursors"` } type ViewResponse struct { State ViewState `json:"state"` Logs ViewLogs `json:"logs"` } type ViewState struct { Run ViewRunInfo `json:"run"` CurrentJob ViewCurrentJob `json:"currentJob"` } type ViewRunInfo struct { Link string `json:"link"` Title string `json:"title"` TitleHTML template.HTML `json:"titleHTML"` Status string `json:"status"` CanCancel bool `json:"canCancel"` CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve CanRerun bool `json:"canRerun"` CanDeleteArtifact bool `json:"canDeleteArtifact"` Done bool `json:"done"` Jobs []*ViewJob `json:"jobs"` Commit ViewCommit `json:"commit"` } type ViewCurrentJob struct { Title string `json:"title"` Detail string `json:"detail"` Steps []*ViewJobStep `json:"steps"` AllAttempts []*TaskAttempt `json:"allAttempts"` } type ViewLogs struct { StepsLog []*ViewStepLog `json:"stepsLog"` } type ViewJob struct { ID int64 `json:"id"` Name string `json:"name"` Status string `json:"status"` CanRerun bool `json:"canRerun"` Duration string `json:"duration"` } type ViewCommit struct { LocaleCommit string `json:"localeCommit"` LocalePushedBy string `json:"localePushedBy"` LocaleWorkflow string `json:"localeWorkflow"` ShortSha string `json:"shortSHA"` Link string `json:"link"` Pusher ViewUser `json:"pusher"` Branch ViewBranch `json:"branch"` } type ViewUser struct { DisplayName string `json:"displayName"` Link string `json:"link"` } type ViewBranch struct { Name string `json:"name"` Link string `json:"link"` IsDeleted bool `json:"isDeleted"` } type ViewJobStep struct { Summary string `json:"summary"` Duration string `json:"duration"` Status string `json:"status"` } type ViewStepLog struct { Step int `json:"step"` Cursor int64 `json:"cursor"` Lines []*ViewStepLogLine `json:"lines"` Started int64 `json:"started"` } type ViewStepLogLine struct { Index int64 `json:"index"` Message string `json:"message"` Timestamp float64 `json:"timestamp"` } type TaskAttempt struct { Number int64 `json:"number"` Started template.HTML `json:"time_since_started_html"` Status string `json:"status"` } func ViewPost(ctx *context_module.Context) { req := web.GetForm(ctx).(*ViewRequest) runIndex := ctx.ParamsInt64("run") jobIndex := ctx.ParamsInt64("job") // note: this is `attemptNumber` not `attemptIndex` since this value has to matches the ActionTask's Attempt field // which uses 1-based numbering... would be confusing as "Index" if it later can't be used to index an slice/array. attemptNumber := ctx.ParamsInt64("attempt") resp := getViewResponse(ctx, req, runIndex, jobIndex, attemptNumber) if ctx.Written() { return } ctx.JSON(http.StatusOK, resp) } func getViewResponse(ctx *context_module.Context, req *ViewRequest, runIndex, jobIndex, attemptNumber int64) *ViewResponse { current, jobs := getRunJobs(ctx, runIndex, jobIndex) if ctx.Written() { return nil } run := current.Run if err := run.LoadAttributes(ctx); err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return nil } resp := &ViewResponse{} metas := ctx.Repo.Repository.ComposeMetas(ctx) resp.State.Run.Title = run.Title resp.State.Run.TitleHTML = templates.RenderCommitMessage(ctx, run.Title, metas) resp.State.Run.Link = run.Link() resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead of 'null' in json resp.State.Run.Status = run.Status.String() // It's possible for the run to be marked with a finalized status (eg. failure) because of a single job within the // run; eg. one job fails, the run fails. But other jobs can still be running. The frontend RepoActionView uses the // `done` flag to indicate whether to stop querying the run's status -- so even though the run has reached a final // state, it may not be time to stop polling for updates. done := run.Status.IsDone() for _, v := range jobs { if !v.Status.IsDone() { // Ah, another job is still running. Keep the frontend polling enabled then. done = false } resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{ ID: v.ID, Name: v.Name, Status: v.Status.String(), CanRerun: v.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions), Duration: v.Duration().String(), }) } resp.State.Run.Done = done pusher := ViewUser{ DisplayName: run.TriggerUser.GetDisplayName(), Link: run.TriggerUser.HomeLink(), } branch := ViewBranch{ Name: run.PrettyRef(), Link: run.RefLink(), } refName := git.RefName(run.Ref) if refName.IsBranch() { b, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, refName.ShortName()) if err != nil && !git_model.IsErrBranchNotExist(err) { log.Error("GetBranch: %v", err) } else if git_model.IsErrBranchNotExist(err) || (b != nil && b.IsDeleted) { branch.IsDeleted = true } } resp.State.Run.Commit = ViewCommit{ LocaleCommit: ctx.Locale.TrString("actions.runs.commit"), LocalePushedBy: ctx.Locale.TrString("actions.runs.pushed_by"), LocaleWorkflow: ctx.Locale.TrString("actions.runs.workflow"), ShortSha: base.ShortSha(run.CommitSHA), Link: fmt.Sprintf("%s/commit/%s", run.Repo.Link(), run.CommitSHA), Pusher: pusher, Branch: branch, } var task *actions_model.ActionTask // TaskID will be set only when the ActionRunJob has been picked by a runner, resulting in an ActionTask being // created representing the specific task. If current.TaskID is not set, then the user is attempting to view a job // that hasn't been picked up by a runner... in this case we're not going to try to fetch the specific attempt. // This helps to support the UI displaying a useful and error-free page when viewing a job that is queued but not // picked, or an attempt that is queued for rerun but not yet picked. if current.TaskID > 0 { var err error task, err = actions_model.GetTaskByJobAttempt(ctx, current.ID, attemptNumber) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return nil } task.Job = current if err := task.LoadAttributes(ctx); err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return nil } } resp.State.CurrentJob.Title = current.Name resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale) if run.NeedApproval { resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc") } resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead of 'null' in json resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead of 'null' in json // As noted above with TaskID; task will be nil when the job hasn't be picked yet... if task != nil { taskAttempts, err := task.GetAllAttempts(ctx) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return nil } allAttempts := make([]*TaskAttempt, len(taskAttempts)) for i, actionTask := range taskAttempts { allAttempts[i] = &TaskAttempt{ Number: actionTask.Attempt, Started: templates.TimeSince(actionTask.Started), Status: actionTask.Status.String(), } } resp.State.CurrentJob.AllAttempts = allAttempts steps := actions.FullSteps(task) for _, v := range steps { resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &ViewJobStep{ Summary: v.Name, Duration: v.Duration().String(), Status: v.Status.String(), }) } for _, cursor := range req.LogCursors { if !cursor.Expanded { continue } step := steps[cursor.Step] // if task log is expired, return a consistent log line if task.LogExpired { if cursor.Cursor == 0 { resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ Step: cursor.Step, Cursor: 1, Lines: []*ViewStepLogLine{ { Index: 1, Message: ctx.Locale.TrString("actions.runs.expire_log_message"), // Timestamp doesn't mean anything when the log is expired. // Set it to the task's updated time since it's probably the time when the log has expired. Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second), }, }, Started: int64(step.Started), }) } continue } logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead of 'null' in json index := step.LogIndex + cursor.Cursor validCursor := cursor.Cursor >= 0 && // !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready. // So return the same cursor and empty lines to let the frontend retry. cursor.Cursor < step.LogLength && // !(index < task.LogIndexes[index]) when task data is older than step data. // It can be fixed by making sure write/read tasks and steps in the same transaction, // but it's easier to just treat it as fetching the next line before it's ready. 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 { ctx.Error(http.StatusInternalServerError, err.Error()) return nil } for i, row := range logRows { logLines = append(logLines, &ViewStepLogLine{ Index: cursor.Cursor + int64(i) + 1, // start at 1 Message: row.Content, Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), }) } } resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ Step: cursor.Step, Cursor: cursor.Cursor + int64(len(logLines)), Lines: logLines, Started: int64(step.Started), }) } } return resp } // When used with the JS `linkAction` handler (typically a