Files
Gitea/routers/web/repo/actions/actions.go
T
Lunny Xiao 2087d4a1a5 refactor: use the shared workflow model from actionslib (#38768)
Pairs with https://gitea.com/gitea/runner/pulls/1143.

Gitea depends on `gitea.com/gitea/runner` for exactly two packages:
`act/model` and `act/exprparser`, the workflow model and the expression
evaluator it needs to parse workflows and to build the task payload the
runner consumes. Pulling the whole runner module in for that is heavy
and puts shared code in the repository of one of the two consumers.

Both packages now live in `gitea.dev/actionslib` (`pkg/model`,
`pkg/exprparser`), the module Gitea and the runner already share for the
runner API, so the dependency on the runner repository is dropped here.

### Changes

- `gitea.com/gitea/runner/act/model` ->
`gitea.dev/actionslib/pkg/model`, `.../act/exprparser` ->
`gitea.dev/actionslib/pkg/exprparser` (22 files, import paths only).
- `routers/api/actions/runner/interceptor.go` takes the `x-runner-uuid`
/ `x-runner-token` names from `gitea.dev/actionslib/pkg/protocol`
instead of repeating the literals the runner also has.
- `go.mod`: `gitea.com/gitea/runner` removed.

---------

Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-08-07 20:56:40 -07:00

845 lines
26 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"bytes"
stdCtx "context"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"slices"
"strings"
act_model "gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/actions"
"gitea.dev/modules/base"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
shared_user "gitea.dev/routers/web/shared/user"
actions_service "gitea.dev/services/actions"
"gitea.dev/services/context"
"gitea.dev/services/convert"
"go.yaml.in/yaml/v4"
)
const (
tplListActions templates.TplName = "repo/actions/list"
tplDispatchInputsActions templates.TplName = "repo/actions/workflow_dispatch_inputs"
tplViewActions templates.TplName = "repo/actions/view"
)
type WorkflowInfo struct {
EntryName string
ErrMsg string
Workflow *act_model.Workflow
}
type workflowBadge struct {
DisplayName string
BadgeURL string
WorkflowURL string
}
// DisplayName returns the workflow name from the YAML file if present, otherwise the filename.
func (w WorkflowInfo) DisplayName() string {
if w.Workflow != nil && w.Workflow.Name != "" {
return w.Workflow.Name
}
return w.EntryName
}
// MustEnableActions check if actions are enabled in settings
func MustEnableActions(ctx *context.Context) {
if !setting.Actions.Enabled {
ctx.NotFound(nil)
return
}
if unit.TypeActions.UnitGlobalDisabled() {
ctx.NotFound(nil)
return
}
if ctx.Repo.Repository != nil {
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
ctx.NotFound(nil)
return
}
}
}
func List(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("actions.actions")
ctx.Data["PageIsActions"] = true
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx, ctx.Repo.Repository.DefaultBranch)
if errors.Is(err, util.ErrNotExist) {
ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch)
ctx.NotFound(nil)
return
} else if err != nil {
ctx.ServerError("GetBranchCommit", err)
return
}
workflows, curWorkflowID := prepareWorkflowTemplate(ctx, commit)
if ctx.Written() {
return
}
data := prepareWorkflowList(ctx, curWorkflowID)
if data.partialRefresh {
// TODO: at the moment, the "partial refresh" depends on some heavy git&db operations (especially finding & parsing the workflow files)
// If it becomes a real problem in the future, maybe some "caches" can be used to optimize.
if !data.preparePartialRefreshRuns(ctx) {
return
}
if !data.prepareCommon(ctx, workflows) {
return
}
ctx.HTML(http.StatusOK, "repo/actions/runs_list")
return
}
scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, data.scopedWorkflowSourceRepoID)
if ctx.Written() {
return
}
otherWorkflows := prepareOtherWorkflows(ctx, workflows, scopedNames, curWorkflowID)
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, data.scopedWorkflowSourceRepoID)
if ctx.Written() {
return
}
if !data.prepareFullPageRuns(ctx, otherWorkflows) {
return
}
if !data.prepareCommon(ctx, workflows) {
return
}
if !data.prepareFullPageTemplate(ctx) {
return
}
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(data.ActionRuns) > 0 || len(scopedNames) > 0
ctx.HTML(http.StatusOK, tplListActions)
}
// prepareOtherWorkflows surfaces historical runs whose workflow file no longer
// exists on the default branch (renamed, removed, or only on other branches).
func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string {
listed := make(container.Set[string], len(workflows))
for _, w := range workflows {
listed.Add(w.EntryName)
}
var other []string
if ctx.Repo.Repository.NumActionRuns > 0 {
// "Other workflows" lists repo-level orphans only: GetRepoRunWorkflowIDs excludes scoped runs.
ids, err := actions_model.GetRepoRunWorkflowIDs(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetRepoRunWorkflowIDs", err)
return nil
}
other = container.FilterSlice(ids, func(id string) (string, bool) {
return id, id != "" && !listed.Contains(id)
})
}
ctx.Data["OtherWorkflows"] = other
// A selected workflow counts as "listed" if it is a repo-level file or an active scoped workflow.
ctx.Data["CurWorkflowIsListed"] = curWorkflowID == "" || listed.Contains(curWorkflowID) || scopedNames.Contains(curWorkflowID)
return other
}
func WorkflowDispatchInputs(ctx *context.Context) {
ref := ctx.FormString("ref")
if ref == "" {
ctx.NotFound(nil)
return
}
// get target commit of run from specified ref
refName := git.RefName(ref)
var commit *git.Commit
var err error
if refName.IsTag() {
commit, err = ctx.Repo.GitRepo.GetTagCommit(ctx, refName.TagName())
} else if refName.IsBranch() {
commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx, refName.BranchName())
} else {
ctx.ServerError("UnsupportedRefType", nil)
return
}
if err != nil {
ctx.ServerError("GetTagCommit/GetBranchCommit", err)
return
}
workflows, curWorkflowID := prepareWorkflowTemplate(ctx, commit)
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, ctx.FormInt64("scoped_workflow_source_repo_id"))
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplDispatchInputsActions)
}
func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflows []WorkflowInfo, curWorkflowID string) {
curWorkflowID = ctx.FormString("workflow")
_, entries, err := actions.ListWorkflows(ctx, ctx.Repo.GitRepo, commit)
if err != nil {
ctx.ServerError("ListWorkflows", err)
return nil, ""
}
workflows = make([]WorkflowInfo, 0, len(entries))
for _, entry := range entries {
workflow := WorkflowInfo{EntryName: entry.Name()}
content, err := actions.GetContentFromEntry(ctx, ctx.Repo.GitRepo, entry)
if err != nil {
ctx.ServerError("GetContentFromEntry", err)
return nil, ""
}
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
if err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
workflows = append(workflows, workflow)
continue
}
if err := actions.ValidateWorkflowContent(content); err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
workflows = append(workflows, workflow)
continue
}
workflow.Workflow = wf
// The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run.
hasJobWithoutNeeds := false
// Check whether you have matching runner and a job without "needs"
emptyJobsNumber := 0
for _, j := range wf.Jobs {
if j == nil {
emptyJobsNumber++
continue
}
if !hasJobWithoutNeeds && len(j.Needs()) == 0 {
hasJobWithoutNeeds = true
}
if j.Uses != "" {
if _, err := actions_service.ResolveUses(ctx, j.Uses); err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
break
}
}
}
if workflow.ErrMsg == "" {
if !hasJobWithoutNeeds {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
}
if emptyJobsNumber == len(wf.Jobs) {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
}
}
workflows = append(workflows, workflow)
}
ctx.Data["workflows"] = workflows
ctx.Data["RepoLink"] = ctx.Repo.Repository.Link()
ctx.Data["RepoID"] = ctx.Repo.Repository.ID
ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.Permission.IsAdmin()
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
ctx.Data["ActionsConfig"] = actionsConfig
ctx.Data["CurWorkflow"] = curWorkflowID
ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(curWorkflowID)
return workflows, curWorkflowID
}
// ScopedWorkflowInfo describes a scoped workflow effective for the current repo, listed under its source group.
type ScopedWorkflowInfo struct {
SourceRepoID int64
EntryName string
DisplayName string
Required bool
Disabled bool
}
// ScopedWorkflowSourceGroup groups the scoped workflows contributed by one source repo for the All-Workflows sidebar.
type ScopedWorkflowSourceGroup struct {
SourceRepoID int64
SourceRepoName string // owner/name of the source repo; shown for instance-level sources and used as the tooltip
SourceRepoShortName string // name only; shown for owner-level sources, where the owner is always the current owner
FromInstance bool // registered at instance level (owner_id == 0) rather than by the owner
IsActive bool // the currently-selected workflow belongs to this source; render the group expanded
Workflows []ScopedWorkflowInfo
}
// prepareScopedWorkflows lists the scoped workflows effective for the repo's owner (and instance) for the All-Workflows sidebar.
func prepareScopedWorkflows(ctx *context.Context, curWorkflowID string, curWorkflowRepoID int64) container.Set[string] {
scopedNames := make(container.Set[string])
repo := ctx.Repo.Repository
sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, repo.OwnerID)
if err != nil {
ctx.ServerError("GetEffectiveScopedWorkflowSources", err)
return scopedNames
}
if len(sources) == 0 {
return scopedNames
}
actionsConfig := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
groups := make([]ScopedWorkflowSourceGroup, 0, len(sources))
seen := make(map[int64]bool, len(sources))
for _, source := range sources {
if seen[source.SourceRepoID] {
continue
}
seen[source.SourceRepoID] = true
sourceRepo, err := repo_model.GetRepositoryByID(ctx, source.SourceRepoID)
if err != nil {
log.Error("scoped workflows list: load source repo %d: %v", source.SourceRepoID, err)
continue
}
if sourceRepo.IsEmpty {
continue
}
_, entries, err := actions_service.LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
log.Error("scoped workflows list: parse %s: %v", sourceRepo.FullName(), err)
continue
}
if len(entries) == 0 {
continue
}
group := ScopedWorkflowSourceGroup{
SourceRepoID: sourceRepo.ID,
SourceRepoName: sourceRepo.FullName(),
SourceRepoShortName: sourceRepo.Name,
FromInstance: source.OwnerID == 0,
}
for _, e := range entries {
scopedNames.Add(e.EntryName)
required := actions_model.IsWorkflowRequiredInSources(sources, sourceRepo.ID, e.EntryName)
disabled := actionsConfig.IsScopedWorkflowDisabled(sourceRepo.ID, e.EntryName)
group.Workflows = append(group.Workflows, ScopedWorkflowInfo{
SourceRepoID: sourceRepo.ID,
EntryName: e.EntryName,
DisplayName: e.DisplayName,
Required: required,
Disabled: disabled,
})
if curWorkflowID == e.EntryName && curWorkflowRepoID == sourceRepo.ID {
ctx.Data["CurWorkflowDisabled"] = disabled
ctx.Data["CurWorkflowScopedRepoID"] = sourceRepo.ID
ctx.Data["CurWorkflowRequired"] = required
group.IsActive = true // keep this group expanded so the selected workflow stays visible
}
}
groups = append(groups, group)
}
ctx.Data["ScopedWorkflowGroups"] = groups
return scopedNames
}
// loadScopedWorkflowModel reads and parses a scoped workflow's content from its source repo's default branch.
func loadScopedWorkflowModel(ctx *context.Context, repo *repo_model.Repository, sourceRepoID int64, workflowID string) *act_model.Workflow {
effective, err := actions_model.IsScopedWorkflowSourceEffective(ctx, repo.OwnerID, sourceRepoID)
if err != nil {
log.Error("scoped dispatch: IsScopedWorkflowSourceEffective: %v", err)
return nil
}
if !effective {
return nil
}
sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID)
if err != nil || sourceRepo.IsEmpty {
return nil
}
content, err := actions_service.ScopedWorkflowContent(ctx, sourceRepo, workflowID)
if err != nil {
log.Error("scoped dispatch: content of %s in %s: %v", workflowID, sourceRepo.FullName(), err)
return nil
}
if content == nil {
return nil // the workflow does not exist on the source's default branch
}
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
if err != nil {
return nil
}
return wf
}
func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string, curWorkflowRepoID int64) {
repo := ctx.Repo.Repository
if curWorkflowID == "" || !ctx.Repo.Permission.CanWrite(unit.TypeActions) {
return
}
actionsConfig := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
isScoped := curWorkflowRepoID > 0
if isScoped {
// a required scoped workflow can never be opted out, so a stale disabled flag must not hide its dispatch form
optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, actionsConfig, repo.OwnerID, curWorkflowRepoID, curWorkflowID)
if err != nil {
log.Error("IsScopedWorkflowOptedOut: %v", err)
return
}
if optedOut {
return
}
} else if actionsConfig.IsWorkflowDisabled(curWorkflowID) {
return
}
var curWorkflow *act_model.Workflow
if isScoped {
// a scoped workflow's content lives in its source repo, not in workflowInfos (the consumer's own files)
curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID)
} else {
for _, workflowInfo := range workflowInfos {
if workflowInfo.EntryName == curWorkflowID {
if workflowInfo.Workflow == nil {
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
return
}
curWorkflow = workflowInfo.Workflow
break
}
}
}
if curWorkflow == nil {
return
}
ctx.Data["CurWorkflowExists"] = true
curWfDispatchCfg := workflowDispatchConfig(curWorkflow)
if curWfDispatchCfg == nil {
return
}
ctx.Data["WorkflowDispatchConfig"] = curWfDispatchCfg
branchOpts := git_model.FindBranchOptions{
RepoID: ctx.Repo.Repository.ID,
IsDeletedBranch: optional.Some(false),
ListOptions: db.ListOptions{
ListAll: true,
},
}
branches, err := git_model.FindBranchNames(ctx, branchOpts)
if err != nil {
ctx.ServerError("FindBranchNames", err)
return
}
// always put default branch on the top
branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch)
branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...)
ctx.Data["Branches"] = branches
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err)
return
}
ctx.Data["Tags"] = tags
}
func (data *actionRunListData) prepareFullPageRuns(ctx *context.Context, otherWorkflows []string) bool {
opts := actions_model.FindRunOptions{
ListOptions: db.ListOptions{
Page: max(ctx.FormInt("page"), 1),
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
},
RepoID: ctx.Repo.Repository.ID,
WorkflowID: data.workflowID,
WorkflowRepoID: data.scopedWorkflowSourceRepoID,
TriggerUserID: data.actorID,
}
// Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id.
if data.workflowID != "" && !slices.Contains(otherWorkflows, data.workflowID) {
opts.IsScopedRun = optional.Some(data.scopedWorkflowSourceRepoID > 0)
}
// if status is not StatusUnknown, it means user has selected a status filter
if data.status != actions_model.StatusUnknown {
opts.Status = []actions_model.Status{data.status}
}
if data.branch != "" {
opts.Ref = string(git.RefNameFromBranch(data.branch))
}
runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts)
if err != nil {
ctx.ServerError("FindAndCount", err)
return false
}
data.pager = context.NewPagination(total, opts.PageSize, opts.Page, 5)
data.pager.AddParamFromRequest(ctx.Req)
data.ActionRuns = runs
return true
}
type actionRunListData struct {
actorID int64
status actions_model.Status
workflowID string
workflowDisplayName string
scopedWorkflowSourceRepoID int64
branch string
refreshRunIDs []int64
partialRefresh bool
pager *context.Pagination
// the following fields are used by the template
RefreshLink template.URL
RefreshIntervalMs int64
ActionRuns []*actions_model.ActionRun
IsFiltered bool
WorkflowNames map[string]string
RunErrors map[int64]string
CurWorkflow string
CanWriteRepoUnitActions bool
}
func (data *actionRunListData) processActionRuns(ctx *context.Context) bool {
// Check for each run if there is at least one online runner that can run its jobs
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
RepoID: ctx.Repo.Repository.ID,
IsOnline: optional.Some(true),
WithAvailable: true,
})
if err != nil {
ctx.ServerError("FindRunners", err)
return false
}
data.RunErrors = make(map[int64]string)
for _, run := range data.ActionRuns {
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
continue
}
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
return false
}
for _, job := range jobs {
// A deferred matrix is unresolvable until its needs finish, so the whole per-job block
// is skipped: parsing the payload would report a valid workflow as invalid.
if job.IsMatrixDeferred || !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) {
continue
}
if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil {
data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
break
}
if job.CallUses != "" {
if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil {
data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
break
}
}
if job.Status.IsWaiting() {
hasOnlineRunner := false
for _, runner := range runners {
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
hasOnlineRunner = true
break
}
}
if !hasOnlineRunner {
data.RunErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
break
}
}
}
}
return true
}
func (data *actionRunListData) fillRefreshMeta(ctx *context.Context) bool {
hasActiveRuns := false
var actionRunIDs []int64
for _, run := range data.ActionRuns {
if !hasActiveRuns && run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
hasActiveRuns = true
}
actionRunIDs = append(actionRunIDs, run.ID)
}
data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 3*1000, 12*1000)
if !setting.IsProd {
data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 1000, 2*1000) // faster in dev mode to make debug easier
}
if len(data.ActionRuns) == 0 {
data.RefreshIntervalMs = 0
}
// Only refresh the items on the current page, do not keep loading "new" items.
// Otherwise, when end users are browsing the list page, they will just keep seeing "new" items coming in
// and "old" items disappearing, they won't be able to focus on an item that they are interested in, which is a bad user experience.
data.RefreshLink = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "run_ids", strings.Join(base.Int64sToStrings(actionRunIDs), ","))
return true
}
func (data *actionRunListData) fillWorkflowNames(workflows []WorkflowInfo) bool {
data.WorkflowNames = make(map[string]string, len(workflows))
data.workflowDisplayName = data.workflowID
for _, wf := range workflows {
displayName := wf.DisplayName()
data.WorkflowNames[wf.EntryName] = displayName
if wf.EntryName == data.workflowID {
data.workflowDisplayName = displayName
}
}
return true
}
func prepareWorkflowList(ctx *context.Context, curWorkflowID string) *actionRunListData {
data := &actionRunListData{}
data.actorID = ctx.FormInt64("actor")
data.status = actions_model.Status(ctx.FormInt("status"))
data.workflowID = ctx.FormString("workflow")
data.scopedWorkflowSourceRepoID = ctx.FormInt64("scoped_workflow_source_repo_id")
data.branch = ctx.FormString("branch")
data.refreshRunIDs = ctx.FormStringInt64s("run_ids")
data.partialRefresh = ctx.Req.Form.Has("run_ids")
data.IsFiltered = data.actorID > 0 || data.status != actions_model.StatusUnknown || data.branch != ""
data.CurWorkflow = curWorkflowID
data.CanWriteRepoUnitActions = ctx.Repo.Permission.CanWrite(unit.TypeActions)
return data
}
func (data *actionRunListData) preparePartialRefreshRuns(ctx *context.Context) bool {
runs, err := actions_model.GetRunsByRepoAndID(ctx, ctx.Repo.Repository.ID, data.refreshRunIDs)
if err != nil {
ctx.ServerError("GetRunsByRepoAndID", err)
return false
}
data.ActionRuns = runs
return true
}
func (data *actionRunListData) prepareCommon(ctx *context.Context, workflows []WorkflowInfo) bool {
for _, run := range data.ActionRuns {
run.Repo = ctx.Repo.Repository
}
if err := actions_model.RunList(data.ActionRuns).LoadTriggerUser(ctx); err != nil {
ctx.ServerError("LoadTriggerUser", err)
return false
}
if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, data.ActionRuns); err != nil {
log.Error("LoadIsRefDeleted", err)
}
if !data.processActionRuns(ctx) {
return false
}
if !data.fillRefreshMeta(ctx) {
return false
}
if !data.fillWorkflowNames(workflows) {
return false
}
ctx.Data["ActionRunListData"] = data
return true
}
func (data *actionRunListData) prepareFullPageTemplate(ctx *context.Context) bool {
// A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs),
// so don't offer the "create status badge" entry for it.
if data.scopedWorkflowSourceRepoID == 0 {
prepareWorkflowBadgeTemplate(ctx, data.workflowID, data.workflowDisplayName)
}
// fill template data
actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetActors", err)
return false
}
ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors)
runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetRunBranches", err)
return false
}
ctx.Data["RunBranches"] = runBranches
ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale)
ctx.Data["CurActor"] = data.actorID
ctx.Data["CurStatus"] = int(data.status) // don't make template use its "String" method, otherwise the query parameter would be wrong
ctx.Data["CurBranch"] = data.branch
ctx.Data["CurWorkflowRepoID"] = data.scopedWorkflowSourceRepoID
ctx.Data["Page"] = data.pager
return true
}
func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) {
if workflowID == "" {
return
}
displayName = util.IfZero(displayName, workflowID)
repoURL := ctx.Repo.Repository.HTMLURL(ctx)
badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", repoURL, util.PathEscapeSegments(workflowID), url.QueryEscape(ctx.Repo.Repository.DefaultBranch))
workflowURL := fmt.Sprintf("%s/actions?workflow=%s", repoURL, url.QueryEscape(workflowID))
ctx.Data["WorkflowBadge"] = workflowBadge{
BadgeURL: badgeURL,
WorkflowURL: workflowURL,
DisplayName: displayName,
}
}
// loadIsRefDeleted loads the IsRefDeleted field for each run in the list.
// TODO: move this function to models/actions/run_list.go but now it will result in a circular import.
func loadIsRefDeleted(ctx stdCtx.Context, repoID int64, runs actions_model.RunList) error {
branches := make(container.Set[string], len(runs))
for _, run := range runs {
refName := git.RefName(run.Ref)
if refName.IsBranch() {
branches.Add(refName.ShortName())
}
}
if len(branches) == 0 {
return nil
}
branchInfos, err := git_model.GetBranches(ctx, repoID, branches.Values(), false)
if err != nil {
return err
}
branchSet := git_model.BranchesToNamesSet(branchInfos)
for _, run := range runs {
refName := git.RefName(run.Ref)
if refName.IsBranch() && !branchSet.Contains(refName.ShortName()) {
run.IsRefDeleted = true
}
}
return nil
}
type WorkflowDispatchInput struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
Options []string `yaml:"options"`
}
type WorkflowDispatch struct {
Inputs []WorkflowDispatchInput
}
func workflowDispatchConfig(w *act_model.Workflow) *WorkflowDispatch {
switch w.RawOn.Kind {
case yaml.ScalarNode:
var val string
if !decodeNode(w.RawOn, &val) {
return nil
}
if val == "workflow_dispatch" {
return &WorkflowDispatch{}
}
case yaml.SequenceNode:
var val []string
if !decodeNode(w.RawOn, &val) {
return nil
}
if slices.Contains(val, "workflow_dispatch") {
return &WorkflowDispatch{}
}
case yaml.MappingNode:
var val map[string]yaml.Node
if !decodeNode(w.RawOn, &val) {
return nil
}
workflowDispatchNode, found := val["workflow_dispatch"]
if !found {
return nil
}
var workflowDispatch WorkflowDispatch
var workflowDispatchVal map[string]yaml.Node
if !decodeNode(workflowDispatchNode, &workflowDispatchVal) {
return &workflowDispatch
}
inputsNode, found := workflowDispatchVal["inputs"]
if !found || inputsNode.Kind != yaml.MappingNode {
return &workflowDispatch
}
i := 0
for {
if i+1 >= len(inputsNode.Content) {
break
}
var input WorkflowDispatchInput
if decodeNode(*inputsNode.Content[i+1], &input) {
input.Name = inputsNode.Content[i].Value
workflowDispatch.Inputs = append(workflowDispatch.Inputs, input)
}
i += 2
}
return &workflowDispatch
default:
return nil
}
return nil
}
func decodeNode(node yaml.Node, out any) bool {
if err := node.Decode(out); err != nil {
log.Warn("Failed to decode node %v into %T: %v", node, out, err)
return false
}
return true
}
func actionsListRedirectURL(repoLink, workflow, scopedWorkflowSourceRepoID, actor, status, branch string) string {
return fmt.Sprintf("%s/actions?workflow=%s&scoped_workflow_source_repo_id=%s&actor=%s&status=%s&branch=%s",
repoLink,
url.QueryEscape(workflow),
url.QueryEscape(scopedWorkflowSourceRepoID),
url.QueryEscape(actor),
url.QueryEscape(status),
url.QueryEscape(branch),
)
}