mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-23 14:55:19 +02:00
369 lines
13 KiB
Go
369 lines
13 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
|
|
actions_model "gitea.dev/models/actions"
|
|
repo_model "gitea.dev/models/repo"
|
|
actions_module "gitea.dev/modules/actions"
|
|
"gitea.dev/modules/actions/jobparser"
|
|
"gitea.dev/modules/container"
|
|
"gitea.dev/modules/log"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
tplOrgScopedWorkflows templates.TplName = "org/settings/actions"
|
|
tplUserScopedWorkflows templates.TplName = "user/settings/actions"
|
|
tplAdminScopedWorkflows templates.TplName = "admin/actions"
|
|
)
|
|
|
|
type scopedWorkflowsCtx struct {
|
|
OwnerID int64 // 0 = instance-level
|
|
IsOrg bool
|
|
IsUser bool
|
|
IsGlobal bool
|
|
Template templates.TplName
|
|
RedirectLink string
|
|
// SearchUID is the uid passed to the repo-search box. For org/user it scopes the search to that owner;
|
|
// for admin (0) it searches all repos and therefore requires admin access on the route.
|
|
SearchUID int64
|
|
}
|
|
|
|
func getScopedWorkflowsCtx(ctx *context.Context) (*scopedWorkflowsCtx, error) {
|
|
if ctx.Data["PageIsOrgSettings"] == true {
|
|
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
|
ctx.ServerError("RenderUserOrgHeader", err)
|
|
return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError
|
|
}
|
|
return &scopedWorkflowsCtx{
|
|
OwnerID: ctx.Org.Organization.ID,
|
|
IsOrg: true,
|
|
Template: tplOrgScopedWorkflows,
|
|
RedirectLink: ctx.Org.OrgLink + "/settings/actions/scoped-workflows",
|
|
SearchUID: ctx.Org.Organization.ID,
|
|
}, nil
|
|
}
|
|
|
|
if ctx.Data["PageIsUserSettings"] == true {
|
|
return &scopedWorkflowsCtx{
|
|
OwnerID: ctx.Doer.ID,
|
|
IsUser: true,
|
|
Template: tplUserScopedWorkflows,
|
|
RedirectLink: setting.AppSubURL + "/user/settings/actions/scoped-workflows",
|
|
SearchUID: ctx.Doer.ID,
|
|
}, nil
|
|
}
|
|
|
|
if ctx.Data["PageIsAdmin"] == true {
|
|
return &scopedWorkflowsCtx{
|
|
OwnerID: 0,
|
|
IsGlobal: true,
|
|
Template: tplAdminScopedWorkflows,
|
|
RedirectLink: setting.AppSubURL + "/-/admin/actions/scoped-workflows",
|
|
SearchUID: 0,
|
|
}, nil
|
|
}
|
|
|
|
return nil, errors.New("unable to set scoped workflows context")
|
|
}
|
|
|
|
// scopedWorkflowInfo is one scoped workflow shown on the settings page, merged with its stored merge-gate config.
|
|
type scopedWorkflowInfo struct {
|
|
EntryName string
|
|
DisplayName string
|
|
Required bool
|
|
Patterns string // newline-joined stored status-check patterns (kept even when not required, as history)
|
|
Contexts []string // the commit-status contexts this workflow is expected to post, to preview which patterns match
|
|
Missing bool // the workflow file no longer exists on the source default branch, but a stored config lingers and must stay clearable
|
|
}
|
|
|
|
// scopedWorkflowSourceView is the per-source data shown on the settings page.
|
|
type scopedWorkflowSourceView struct {
|
|
Repo *repo_model.Repository
|
|
ScopedWorkflowInfos []scopedWorkflowInfo
|
|
}
|
|
|
|
func ScopedWorkflows(ctx *context.Context) {
|
|
ctx.Data["Title"] = ctx.Tr("actions.scoped_workflows")
|
|
ctx.Data["PageType"] = "scoped-workflows"
|
|
ctx.Data["PageIsSharedSettingsScopedWorkflows"] = true
|
|
|
|
swCtx, err := getScopedWorkflowsCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getScopedWorkflowsCtx", err)
|
|
return
|
|
}
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case swCtx.IsOrg:
|
|
ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_org")
|
|
case swCtx.IsUser:
|
|
ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_user")
|
|
default: // instance-level
|
|
ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_global")
|
|
}
|
|
|
|
sources, err := actions_model.GetScopedWorkflowSourcesByOwner(ctx, swCtx.OwnerID)
|
|
if err != nil {
|
|
ctx.ServerError("GetScopedWorkflowSourcesByOwner", err)
|
|
return
|
|
}
|
|
|
|
views := make([]*scopedWorkflowSourceView, 0, len(sources))
|
|
for _, src := range sources {
|
|
repo, err := repo_model.GetRepositoryByID(ctx, src.SourceRepoID)
|
|
if err != nil {
|
|
log.Error("scoped workflows settings: load source repo %d: %v", src.SourceRepoID, err)
|
|
continue
|
|
}
|
|
views = append(views, &scopedWorkflowSourceView{
|
|
Repo: repo,
|
|
ScopedWorkflowInfos: listSourceScopedWorkflowFiles(ctx, repo, src.WorkflowConfigs),
|
|
})
|
|
}
|
|
|
|
ctx.Data["ScopedWorkflowSources"] = views
|
|
ctx.Data["RepoSearchUID"] = swCtx.SearchUID
|
|
// owner/user scopes the repo search to the owner (exclusive);
|
|
// instance-level (admin) searches all repos and so must submit owner/name to disambiguate the selection across owners.
|
|
ctx.Data["ScopedWorkflowsSearchExclusive"] = !swCtx.IsGlobal
|
|
ctx.Data["ScopedWorkflowsSearchFullName"] = swCtx.IsGlobal
|
|
ctx.Data["RedirectLink"] = swCtx.RedirectLink
|
|
ctx.Data["ScopedWorkflowDirs"] = strings.Join(setting.Actions.ScopedWorkflowDirs, ", ")
|
|
ctx.HTML(http.StatusOK, swCtx.Template)
|
|
}
|
|
|
|
// parsePatternLines splits a textarea value into trimmed, non-empty status-check patterns (one per line).
|
|
func parsePatternLines(raw string) []string {
|
|
var patterns []string
|
|
for line := range strings.SplitSeq(raw, "\n") {
|
|
if p := strings.TrimSpace(line); p != "" {
|
|
patterns = append(patterns, p)
|
|
}
|
|
}
|
|
return patterns
|
|
}
|
|
|
|
// deriveScopedStatusContexts returns the commit-status contexts a scoped workflow is expected to post on a consumer:
|
|
// "<source FullName>: <display> / <job> (<event>)" for each parsed job (matrix-expanded, matching run creation) and triggering event.
|
|
// Job names that depend on run-context expressions cannot resolve here (no run context) and appear as authored; a glob pattern still matches them.
|
|
func deriveScopedStatusContexts(prefix, displayName string, content []byte, events []*jobparser.Event) []string {
|
|
parsed, err := jobparser.Parse(content)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
eventNames := make([]string, 0, len(events))
|
|
for _, e := range events {
|
|
// only events whose runs post a commit status can be a required check; workflow_dispatch, schedule, etc. post none.
|
|
if actions_module.ShouldEventCreateCommitStatus(e.Name) {
|
|
eventNames = append(eventNames, e.Name)
|
|
}
|
|
}
|
|
seen := make(container.Set[string])
|
|
contexts := make([]string, 0, len(parsed)*len(eventNames))
|
|
for _, sw := range parsed {
|
|
_, job := sw.Job()
|
|
if job == nil {
|
|
continue
|
|
}
|
|
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
|
|
for _, ev := range eventNames {
|
|
ctxName := actions_module.ScopedWorkflowStatusContextName(prefix, displayName, jobName, ev)
|
|
if seen.Contains(ctxName) {
|
|
continue
|
|
}
|
|
seen.Add(ctxName)
|
|
contexts = append(contexts, ctxName)
|
|
}
|
|
}
|
|
return contexts
|
|
}
|
|
|
|
func listSourceScopedWorkflowFiles(ctx *context.Context, repo *repo_model.Repository, configs map[string]*actions_model.ScopedWorkflowConfig) []scopedWorkflowInfo {
|
|
rendered := make(container.Set[string], len(configs))
|
|
files := make([]scopedWorkflowInfo, 0, len(configs))
|
|
|
|
// An empty source repo (or one that fails to parse) has no live workflow files, but a previously-saved config may still linger;
|
|
// fall through to surface those as orphan rows below so they remain clearable.
|
|
if !repo.IsEmpty {
|
|
_, parsed, err := actions_service.LoadParsedScopedWorkflows(ctx, repo)
|
|
if err != nil {
|
|
log.Error("scoped workflows settings: parse %s: %v", repo.FullName(), err)
|
|
} else {
|
|
for _, p := range parsed {
|
|
info := scopedWorkflowInfo{
|
|
EntryName: p.EntryName,
|
|
DisplayName: p.DisplayName,
|
|
Contexts: deriveScopedStatusContexts(repo.FullName(), p.DisplayName, p.Content, p.Events),
|
|
}
|
|
if cfg := configs[p.EntryName]; cfg != nil {
|
|
info.Required = cfg.Required
|
|
info.Patterns = strings.Join(cfg.Patterns, "\n")
|
|
}
|
|
rendered.Add(p.EntryName)
|
|
files = append(files, info)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Surface configs whose workflow file no longer exists on the source default branch as orphan rows.
|
|
// A required orphan still gates merges (must-present), so the owner/admin must be able to see and clear it;
|
|
// otherwise the only escape would be removing the whole source registration.
|
|
orphans := make([]scopedWorkflowInfo, 0, len(configs))
|
|
for name, cfg := range configs {
|
|
if cfg == nil || rendered.Contains(name) {
|
|
continue
|
|
}
|
|
orphans = append(orphans, scopedWorkflowInfo{
|
|
EntryName: name,
|
|
DisplayName: name,
|
|
Required: cfg.Required,
|
|
Patterns: strings.Join(cfg.Patterns, "\n"),
|
|
Missing: true,
|
|
})
|
|
}
|
|
// map iteration order is random; sort orphans for a stable settings page
|
|
slices.SortFunc(orphans, func(a, b scopedWorkflowInfo) int { return strings.Compare(a.EntryName, b.EntryName) })
|
|
return append(files, orphans...)
|
|
}
|
|
|
|
func ScopedWorkflowAdd(ctx *context.Context) {
|
|
swCtx, err := getScopedWorkflowsCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getScopedWorkflowsCtx", err)
|
|
return
|
|
}
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
repoName := ctx.FormString("repo_name")
|
|
var repo *repo_model.Repository
|
|
if swCtx.IsGlobal {
|
|
// instance-level: the source may be any repo on the instance, identified by owner/name
|
|
ownerName, name, ok := strings.Cut(repoName, "/")
|
|
if !ok {
|
|
ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found"))
|
|
return
|
|
}
|
|
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, name)
|
|
} else {
|
|
// owner-level: resolve within the owner, which also enforces that the source is one of the owner's own repositories
|
|
repo, err = repo_model.GetRepositoryByName(ctx, swCtx.OwnerID, repoName)
|
|
}
|
|
if err != nil {
|
|
ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found"))
|
|
return
|
|
}
|
|
|
|
if err := actions_model.AddScopedWorkflowSource(ctx, swCtx.OwnerID, repo.ID); err != nil {
|
|
ctx.ServerError("AddScopedWorkflowSource", err)
|
|
return
|
|
}
|
|
ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.source.add_success"))
|
|
ctx.JSONRedirect(swCtx.RedirectLink)
|
|
}
|
|
|
|
func ScopedWorkflowSetRequired(ctx *context.Context) {
|
|
swCtx, err := getScopedWorkflowsCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getScopedWorkflowsCtx", err)
|
|
return
|
|
}
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
repoID := ctx.FormInt64("repo_id")
|
|
|
|
// the source must be registered for this owner
|
|
if _, err := actions_model.GetScopedWorkflowSource(ctx, swCtx.OwnerID, repoID); err != nil {
|
|
if errors.Is(err, util.ErrNotExist) {
|
|
ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found"))
|
|
} else {
|
|
ctx.ServerError("GetScopedWorkflowSource", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Live workflow entry names on the source default branch, used to distinguish orphan configs (whose workflow file no longer exists) from live ones.
|
|
sourceRepo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
|
if err != nil {
|
|
ctx.ServerError("GetRepositoryByID", err)
|
|
return
|
|
}
|
|
liveSet := make(container.Set[string])
|
|
if !sourceRepo.IsEmpty { // an empty source has no live workflows
|
|
_, parsed, err := actions_service.LoadParsedScopedWorkflows(ctx, sourceRepo)
|
|
if err != nil {
|
|
ctx.ServerError("LoadParsedScopedWorkflows", err)
|
|
return
|
|
}
|
|
for _, p := range parsed {
|
|
liveSet.Add(p.EntryName)
|
|
}
|
|
}
|
|
|
|
// Every workflow row submits its ID in workflow_ids and its patterns (one per line) in required_patterns[<id>];
|
|
// checked rows additionally submit their ID in required_workflow_ids.
|
|
// A required workflow must have at least one pattern.
|
|
requiredSet := make(container.Set[string])
|
|
for _, workflowID := range ctx.FormStrings("required_workflow_ids") {
|
|
requiredSet.Add(workflowID)
|
|
}
|
|
configs := make(map[string]*actions_model.ScopedWorkflowConfig)
|
|
for _, workflowID := range ctx.FormStrings("workflow_ids") {
|
|
patterns := parsePatternLines(ctx.FormString("required_patterns[" + workflowID + "]"))
|
|
required := requiredSet.Contains(workflowID)
|
|
if required && len(patterns) == 0 {
|
|
ctx.JSONError(ctx.Tr("actions.scoped_workflows.required.patterns_empty"))
|
|
return
|
|
}
|
|
// Keep a config only if it is required, or it is a still-existing.
|
|
// An orphan (file no longer in the source) that is not required is dropped.
|
|
if required || (liveSet.Contains(workflowID) && len(patterns) > 0) {
|
|
configs[workflowID] = &actions_model.ScopedWorkflowConfig{Required: required, Patterns: patterns}
|
|
}
|
|
}
|
|
if err := actions_model.SetScopedWorkflowSourceConfigs(ctx, swCtx.OwnerID, repoID, configs); err != nil {
|
|
ctx.ServerError("SetScopedWorkflowSourceConfigs", err)
|
|
return
|
|
}
|
|
ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.required.update_success"))
|
|
ctx.JSONRedirect(swCtx.RedirectLink)
|
|
}
|
|
|
|
func ScopedWorkflowRemove(ctx *context.Context) {
|
|
swCtx, err := getScopedWorkflowsCtx(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("getScopedWorkflowsCtx", err)
|
|
return
|
|
}
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
|
|
repoID := ctx.FormInt64("repo_id")
|
|
if err := actions_model.RemoveScopedWorkflowSource(ctx, swCtx.OwnerID, repoID); err != nil {
|
|
ctx.ServerError("RemoveScopedWorkflowSource", err)
|
|
return
|
|
}
|
|
ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.source.remove_success"))
|
|
ctx.JSONRedirect(swCtx.RedirectLink)
|
|
}
|