chore: initialize Gitea v1.27.2 fork
giteabot backport / giteabot (push) Canceled after 0s
giteabot / giteabot (push) Canceled after 0s
release-nightly / nightly-binary (push) Canceled after 0s
release-nightly / nightly-container (push) Canceled after 0s
cache-seeder / gobuild (push) Canceled after 0s
cache-seeder / lint (bindata, lint-backend) (push) Canceled after 0s
release-nightly-snapcraft / build-and-publish (push) Canceled after 0s
giteabot backport / giteabot (push) Canceled after 0s
giteabot / giteabot (push) Canceled after 0s
release-nightly / nightly-binary (push) Canceled after 0s
release-nightly / nightly-container (push) Canceled after 0s
cache-seeder / gobuild (push) Canceled after 0s
cache-seeder / lint (bindata, lint-backend) (push) Canceled after 0s
release-nightly-snapcraft / build-and-publish (push) Canceled after 0s
Includes direct password setup links in registration emails. Assisted-by: Codex:GPT-5
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplOrgSettingsActionsGeneral templates.TplName = "org/settings/actions_general"
|
||||
tplUserSettingsActionsGeneral templates.TplName = "user/settings/actions_general"
|
||||
)
|
||||
|
||||
// ParseMaxTokenPermissions parses the maximum token permissions from form values
|
||||
func ParseMaxTokenPermissions(ctx *context.Context) *repo_model.ActionsTokenPermissions {
|
||||
parseMaxPerm := func(unitType unit.Type) perm.AccessMode {
|
||||
value := ctx.FormString("max_unit_access_mode_" + strconv.Itoa(int(unitType)))
|
||||
switch value {
|
||||
case "write":
|
||||
return perm.AccessModeWrite
|
||||
case "read":
|
||||
return perm.AccessModeRead
|
||||
default:
|
||||
return perm.AccessModeNone
|
||||
}
|
||||
}
|
||||
ret := new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
for _, ut := range repo_model.ActionsTokenUnitTypes {
|
||||
ret.UnitAccessModes[ut] = parseMaxPerm(ut)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// GeneralSettings renders the actions general settings page
|
||||
func GeneralSettings(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("actions.actions")
|
||||
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if rCtx.IsOrg {
|
||||
ctx.Data["PageIsOrgSettings"] = true
|
||||
ctx.Data["PageIsOrgSettingsActionsGeneral"] = true
|
||||
} else if rCtx.IsUser {
|
||||
ctx.Data["PageIsUserSettings"] = true
|
||||
ctx.Data["PageIsUserSettingsActionsGeneral"] = true
|
||||
} else {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Load User/Org Actions Config
|
||||
actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOwnerActionsConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode
|
||||
ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive
|
||||
ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted
|
||||
ctx.Data["MaxTokenPermissions"] = actionsCfg.MaxTokenPermissions
|
||||
if actionsCfg.MaxTokenPermissions == nil {
|
||||
ctx.Data["MaxTokenPermissions"] = (&repo_model.ActionsConfig{}).GetMaxTokenPermissions()
|
||||
}
|
||||
ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil
|
||||
|
||||
// Load Allowed Repositories
|
||||
allowedRepos, err := repo_model.GetOwnerRepositoriesByIDs(ctx, rCtx.OwnerID, actionsCfg.AllowedCrossRepoIDs)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOwnerRepositoriesByIDs", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["AllowedRepos"] = allowedRepos
|
||||
ctx.Data["OwnerID"] = rCtx.OwnerID
|
||||
|
||||
if rCtx.IsOrg {
|
||||
ctx.HTML(http.StatusOK, tplOrgSettingsActionsGeneral)
|
||||
} else {
|
||||
ctx.HTML(http.StatusOK, tplUserSettingsActionsGeneral)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateGeneralSettings responses for actions general settings page
|
||||
func UpdateGeneralSettings(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !rCtx.IsOrg && !rCtx.IsUser {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetOwnerActionsConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.FormBool("cross_repo_add_target") {
|
||||
targetRepoName := ctx.FormString("cross_repo_add_target_name")
|
||||
if targetRepoName != "" {
|
||||
targetRepo, err := repo_model.GetRepositoryByName(ctx, rCtx.OwnerID, targetRepoName)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.JSONError("Repository doesn't exist")
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetRepositoryByName", err)
|
||||
return
|
||||
}
|
||||
if !slices.Contains(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID) {
|
||||
actionsCfg.AllowedCrossRepoIDs = append(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if crossRepoRemoveTargetID := ctx.FormInt64("cross_repo_remove_target_id"); crossRepoRemoveTargetID != 0 {
|
||||
actionsCfg.AllowedCrossRepoIDs = util.SliceRemoveAll(actionsCfg.AllowedCrossRepoIDs, crossRepoRemoveTargetID)
|
||||
}
|
||||
|
||||
// Update Token Permission Mode
|
||||
tokenPermissionMode, tokenPermissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")))
|
||||
if tokenPermissionModeValid {
|
||||
actionsCfg.TokenPermissionMode = tokenPermissionMode
|
||||
enableMaxPermissions := ctx.FormBool("enable_max_permissions")
|
||||
// Update Maximum Permissions (radio buttons: none/read/write)
|
||||
if enableMaxPermissions {
|
||||
actionsCfg.MaxTokenPermissions = ParseMaxTokenPermissions(ctx)
|
||||
} else {
|
||||
actionsCfg.MaxTokenPermissions = nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := actions_model.SetOwnerActionsConfig(ctx, rCtx.OwnerID, actionsCfg); err != nil {
|
||||
ctx.ServerError("SetOwnerActionsConfig", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.saved_successfully"))
|
||||
ctx.JSONRedirect("") // use JSONRedirect because frontend uses form-fetch-action
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO: Separate secrets from runners when layout is ready
|
||||
tplRepoRunners templates.TplName = "repo/settings/actions"
|
||||
tplOrgRunners templates.TplName = "org/settings/actions"
|
||||
tplAdminRunners templates.TplName = "admin/actions"
|
||||
tplUserRunners templates.TplName = "user/settings/actions"
|
||||
tplRepoRunnerEdit templates.TplName = "repo/settings/runner_edit"
|
||||
tplOrgRunnerEdit templates.TplName = "org/settings/runners_edit"
|
||||
tplAdminRunnerEdit templates.TplName = "admin/runners/edit"
|
||||
tplUserRunnerEdit templates.TplName = "user/settings/runner_edit"
|
||||
)
|
||||
|
||||
type runnersCtx struct {
|
||||
OwnerID int64
|
||||
RepoID int64
|
||||
IsRepo bool
|
||||
IsOrg bool
|
||||
IsAdmin bool
|
||||
IsUser bool
|
||||
RunnersTemplate templates.TplName
|
||||
RunnerEditTemplate templates.TplName
|
||||
RedirectLink string
|
||||
}
|
||||
|
||||
func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
|
||||
if ctx.Data["PageIsRepoSettings"] == true {
|
||||
return &runnersCtx{
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
OwnerID: 0,
|
||||
IsRepo: true,
|
||||
RunnersTemplate: tplRepoRunners,
|
||||
RunnerEditTemplate: tplRepoRunnerEdit,
|
||||
RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ctx.Data["PageIsOrgSettings"] == true {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
return nil, fmt.Errorf("RenderUserOrgHeader: %w", err)
|
||||
}
|
||||
return &runnersCtx{
|
||||
RepoID: 0,
|
||||
OwnerID: ctx.Org.Organization.ID,
|
||||
IsOrg: true,
|
||||
RunnersTemplate: tplOrgRunners,
|
||||
RunnerEditTemplate: tplOrgRunnerEdit,
|
||||
RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ctx.Data["PageIsAdmin"] == true {
|
||||
return &runnersCtx{
|
||||
RepoID: 0,
|
||||
OwnerID: 0,
|
||||
IsAdmin: true,
|
||||
RunnersTemplate: tplAdminRunners,
|
||||
RunnerEditTemplate: tplAdminRunnerEdit,
|
||||
RedirectLink: setting.AppSubURL + "/-/admin/actions/runners/",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ctx.Data["PageIsUserSettings"] == true {
|
||||
return &runnersCtx{
|
||||
OwnerID: ctx.Doer.ID,
|
||||
RepoID: 0,
|
||||
IsUser: true,
|
||||
RunnersTemplate: tplUserRunners,
|
||||
RunnerEditTemplate: tplUserRunnerEdit,
|
||||
RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("unable to set Runners context")
|
||||
}
|
||||
|
||||
// Runners render settings/actions/runners page for repo level
|
||||
func Runners(ctx *context.Context) {
|
||||
ctx.Data["PageIsSharedSettingsRunners"] = true
|
||||
ctx.Data["Title"] = ctx.Tr("actions.actions")
|
||||
ctx.Data["PageType"] = "runners"
|
||||
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
|
||||
opts := actions_model.FindRunnerOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: 100,
|
||||
},
|
||||
Sort: ctx.Req.URL.Query().Get("sort"),
|
||||
Filter: ctx.Req.URL.Query().Get("q"),
|
||||
}
|
||||
if rCtx.IsRepo {
|
||||
opts.RepoID = rCtx.RepoID
|
||||
opts.WithAvailable = true
|
||||
} else if rCtx.IsOrg || rCtx.IsUser {
|
||||
opts.OwnerID = rCtx.OwnerID
|
||||
opts.WithAvailable = true
|
||||
}
|
||||
|
||||
runners, count, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("CountRunners", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := actions_model.RunnerList(runners).LoadAttributes(ctx); err != nil {
|
||||
ctx.ServerError("LoadAttributes", err)
|
||||
return
|
||||
}
|
||||
|
||||
// ownid=0,repo_id=0,means this token is used for global
|
||||
var token *actions_model.ActionRunnerToken
|
||||
token, err = actions_model.GetLatestRunnerToken(ctx, opts.OwnerID, opts.RepoID)
|
||||
if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) {
|
||||
token, err = actions_model.NewRunnerToken(ctx, opts.OwnerID, opts.RepoID)
|
||||
if err != nil {
|
||||
ctx.ServerError("CreateRunnerToken", err)
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
ctx.ServerError("GetLatestRunnerToken", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Keyword"] = opts.Filter
|
||||
ctx.Data["Runners"] = runners
|
||||
ctx.Data["Total"] = count
|
||||
ctx.Data["RegistrationToken"] = token.Token
|
||||
ctx.Data["RunnerOwnerID"] = opts.OwnerID
|
||||
ctx.Data["RunnerRepoID"] = opts.RepoID
|
||||
ctx.Data["SortType"] = opts.Sort
|
||||
ctx.Data["AllowBulkActions"] = rCtx.IsAdmin
|
||||
|
||||
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
|
||||
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, rCtx.RunnersTemplate)
|
||||
}
|
||||
|
||||
// RunnersEdit renders runner edit page for repository level
|
||||
func RunnersEdit(ctx *context.Context) {
|
||||
ctx.Data["PageIsSharedSettingsRunners"] = true
|
||||
ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner")
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
page := max(ctx.FormInt("page"), 1)
|
||||
|
||||
runnerID := ctx.PathParamInt64("runnerid")
|
||||
ownerID := rCtx.OwnerID
|
||||
repoID := rCtx.RepoID
|
||||
|
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetRunnerByID", err)
|
||||
return
|
||||
}
|
||||
if err := runner.LoadAttributes(ctx); err != nil {
|
||||
ctx.ServerError("LoadAttributes", err)
|
||||
return
|
||||
}
|
||||
if !runner.EditableInContext(ownerID, repoID) {
|
||||
err = errors.New("no permission to edit this runner")
|
||||
ctx.NotFound(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Runner"] = runner
|
||||
|
||||
opts := actions_model.FindTaskOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: 30,
|
||||
},
|
||||
Status: actions_model.StatusUnknown, // Unknown means all
|
||||
RunnerID: runner.ID,
|
||||
}
|
||||
|
||||
tasks, count, err := db.FindAndCount[actions_model.ActionTask](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("CountTasks", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = actions_model.TaskList(tasks).LoadAttributes(ctx); err != nil {
|
||||
ctx.ServerError("TasksLoadAttributes", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Tasks"] = tasks
|
||||
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate)
|
||||
}
|
||||
|
||||
func RunnersEditPost(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
runnerID := ctx.PathParamInt64("runnerid")
|
||||
ownerID := rCtx.OwnerID
|
||||
repoID := rCtx.RepoID
|
||||
redirectTo := rCtx.RedirectLink
|
||||
|
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
||||
if err != nil {
|
||||
log.Warn("RunnerDetailsEditPost.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL)
|
||||
ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err)
|
||||
return
|
||||
}
|
||||
if !runner.EditableInContext(ownerID, repoID) {
|
||||
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner"))
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.EditRunnerForm)
|
||||
runner.Description = form.Description
|
||||
|
||||
err = actions_model.UpdateRunner(ctx, runner, "description")
|
||||
if err != nil {
|
||||
log.Warn("RunnerDetailsEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
|
||||
ctx.Flash.Warning(ctx.Tr("actions.runners.update_runner_failed"))
|
||||
ctx.Redirect(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("RunnerDetailsEditPost success: %s", ctx.Req.URL)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("actions.runners.update_runner_success"))
|
||||
ctx.Redirect(redirectTo)
|
||||
}
|
||||
|
||||
func ResetRunnerRegistrationToken(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID := rCtx.OwnerID
|
||||
repoID := rCtx.RepoID
|
||||
redirectTo := rCtx.RedirectLink
|
||||
|
||||
if _, err := actions_model.NewRunnerToken(ctx, ownerID, repoID); err != nil {
|
||||
ctx.ServerError("ResetRunnerRegistrationToken", err)
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("actions.runners.reset_registration_token_success"))
|
||||
ctx.JSONRedirect(redirectTo)
|
||||
}
|
||||
|
||||
// RunnerDeletePost response for deleting runner
|
||||
func RunnerDeletePost(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
runner := findActionsRunner(ctx, rCtx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) {
|
||||
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to delete this runner"))
|
||||
return
|
||||
}
|
||||
|
||||
successRedirectTo := rCtx.RedirectLink
|
||||
failedRedirectTo := rCtx.RedirectLink + url.PathEscape(ctx.PathParam("runnerid"))
|
||||
|
||||
if err := actions_model.DeleteRunner(ctx, runner.ID); err != nil {
|
||||
log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
|
||||
ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner_failed"))
|
||||
|
||||
ctx.JSONRedirect(failedRedirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("DeleteRunnerPost success: %s", ctx.Req.URL)
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("actions.runners.delete_runner_success"))
|
||||
|
||||
ctx.JSONRedirect(successRedirectTo)
|
||||
}
|
||||
|
||||
func RunnerUpdatePost(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
runner := findActionsRunner(ctx, rCtx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) {
|
||||
ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner"))
|
||||
return
|
||||
}
|
||||
|
||||
isDisabled := ctx.FormOptionalBool("disabled")
|
||||
if !isDisabled.Has() {
|
||||
ctx.HTTPError(http.StatusBadRequest, "missing 'disabled' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
successKey := "actions.runners.enable_runner_success"
|
||||
failedKey := "actions.runners.enable_runner_failed"
|
||||
if isDisabled.Value() {
|
||||
successKey = "actions.runners.disable_runner_success"
|
||||
failedKey = "actions.runners.disable_runner_failed"
|
||||
}
|
||||
|
||||
if err := actions_model.SetRunnerDisabled(ctx, runner, isDisabled.Value()); err != nil {
|
||||
log.Warn("RunnerUpdatePost.SetRunnerDisabled failed: %v, url: %s", err, ctx.Req.URL)
|
||||
ctx.Flash.Error(ctx.Tr(failedKey))
|
||||
ctx.JSONRedirect("")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr(successKey))
|
||||
ctx.JSONRedirect("")
|
||||
}
|
||||
|
||||
// RunnerBulkActionPost performs a bulk action (delete/disable/enable) on multiple runners.
|
||||
// Admin-only: route must be mounted inside the admin runners group; defense-in-depth check below.
|
||||
func RunnerBulkActionPost(ctx *context.Context) {
|
||||
rCtx, err := getRunnersCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getRunnersCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !rCtx.IsAdmin {
|
||||
ctx.HTTPError(http.StatusForbidden, "bulk actions are admin-only")
|
||||
return
|
||||
}
|
||||
// ATTENTION: it completely depends on the assumption that the doer is "site admin"
|
||||
// So it doesn't do extra permission check to the runner IDs
|
||||
// In the future, if you need to support such operation on non-admin pages, be careful!
|
||||
runnerIDs := ctx.FormStringInt64s("ids")
|
||||
if len(runnerIDs) == 0 {
|
||||
ctx.HTTPError(http.StatusBadRequest, "missing runner IDs")
|
||||
return
|
||||
}
|
||||
|
||||
action := ctx.FormString("action")
|
||||
var successKey, failedKey string
|
||||
switch action {
|
||||
case "delete":
|
||||
successKey, failedKey = "actions.runners.delete_runner_success", "actions.runners.delete_runner_failed"
|
||||
case "disable":
|
||||
successKey, failedKey = "actions.runners.disable_runner_success", "actions.runners.disable_runner_failed"
|
||||
case "enable":
|
||||
successKey, failedKey = "actions.runners.enable_runner_success", "actions.runners.enable_runner_failed"
|
||||
default:
|
||||
ctx.HTTPError(http.StatusBadRequest, "invalid action")
|
||||
return
|
||||
}
|
||||
|
||||
runners, err := db.Find[actions_model.ActionRunner](ctx, &actions_model.FindRunnerOptions{IDs: runnerIDs})
|
||||
if err != nil {
|
||||
ctx.ServerError("FindRunners", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = db.WithTx(ctx, func(txCtx stdctx.Context) error {
|
||||
for _, r := range runners {
|
||||
switch action {
|
||||
case "delete":
|
||||
if err := actions_model.DeleteRunner(txCtx, r.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "disable":
|
||||
if err := actions_model.SetRunnerDisabled(txCtx, r, true); err != nil {
|
||||
return err
|
||||
}
|
||||
case "enable":
|
||||
if err := actions_model.SetRunnerDisabled(txCtx, r, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("RunnerBulkActionPost.%s failed: %v, url: %s", action, err, ctx.Req.URL)
|
||||
ctx.Flash.Error(ctx.Tr(failedKey))
|
||||
ctx.JSONRedirect(rCtx.RedirectLink)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr(successKey))
|
||||
ctx.JSONRedirect(rCtx.RedirectLink)
|
||||
}
|
||||
|
||||
func findActionsRunner(ctx *context.Context, rCtx *runnersCtx) *actions_model.ActionRunner {
|
||||
runnerID := ctx.PathParamInt64("runnerid")
|
||||
opts := &actions_model.FindRunnerOptions{
|
||||
IDs: []int64{runnerID},
|
||||
}
|
||||
switch {
|
||||
case rCtx.IsRepo:
|
||||
opts.RepoID = rCtx.RepoID
|
||||
if opts.RepoID == 0 {
|
||||
panic("repoID is 0")
|
||||
}
|
||||
case rCtx.IsOrg, rCtx.IsUser:
|
||||
opts.OwnerID = rCtx.OwnerID
|
||||
if opts.OwnerID == 0 {
|
||||
panic("ownerID is 0")
|
||||
}
|
||||
case rCtx.IsAdmin:
|
||||
// do nothing
|
||||
default:
|
||||
panic("invalid actions runner context")
|
||||
}
|
||||
|
||||
got, err := db.Find[actions_model.ActionRunner](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindRunner", err)
|
||||
return nil
|
||||
} else if len(got) == 0 {
|
||||
ctx.NotFound(errors.New("runner not found"))
|
||||
return nil
|
||||
}
|
||||
|
||||
return got[0]
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// 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.RelativePath(), 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)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeriveScopedStatusContexts(t *testing.T) {
|
||||
t.Run("jobs x events; job name is its name: or its id", func(t *testing.T) {
|
||||
content := []byte(`name: CI
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
build:
|
||||
name: Build It
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`)
|
||||
events, err := actions_module.GetEventsFromContent(content)
|
||||
require.NoError(t, err)
|
||||
got := deriveScopedStatusContexts("org/src", "CI", content, events)
|
||||
assert.ElementsMatch(t, []string{
|
||||
"org/src: CI / lint (push)",
|
||||
"org/src: CI / lint (pull_request)",
|
||||
"org/src: CI / Build It (push)",
|
||||
"org/src: CI / Build It (pull_request)",
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("only status-producing events; workflow_dispatch/schedule/workflow_call skipped", func(t *testing.T) {
|
||||
content := []byte(`name: CI
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
jobs:
|
||||
j:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`)
|
||||
events, err := actions_module.GetEventsFromContent(content)
|
||||
require.NoError(t, err)
|
||||
got := deriveScopedStatusContexts("org/src", "CI", content, events)
|
||||
assert.Equal(t, []string{"org/src: CI / j (push)"}, got) // only push posts a commit status
|
||||
})
|
||||
|
||||
t.Run("a workflow_dispatch-only workflow has no expected contexts", func(t *testing.T) {
|
||||
content := []byte(`name: Deploy
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`)
|
||||
events, err := actions_module.GetEventsFromContent(content)
|
||||
require.NoError(t, err)
|
||||
got := deriveScopedStatusContexts("org/src", "Deploy", content, events)
|
||||
assert.Empty(t, got) // workflow_dispatch posts no commit status -> nothing to preview (and it cannot be a required check)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplRepoVariables templates.TplName = "repo/settings/actions"
|
||||
tplOrgVariables templates.TplName = "org/settings/actions"
|
||||
tplUserVariables templates.TplName = "user/settings/actions"
|
||||
tplAdminVariables templates.TplName = "admin/actions"
|
||||
)
|
||||
|
||||
type variablesCtx struct {
|
||||
OwnerID int64
|
||||
RepoID int64
|
||||
IsRepo bool
|
||||
IsOrg bool
|
||||
IsUser bool
|
||||
IsGlobal bool
|
||||
VariablesTemplate templates.TplName
|
||||
RedirectLink string
|
||||
}
|
||||
|
||||
func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) {
|
||||
if ctx.Data["PageIsRepoSettings"] == true {
|
||||
return &variablesCtx{
|
||||
OwnerID: 0,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
IsRepo: true,
|
||||
VariablesTemplate: tplRepoVariables,
|
||||
RedirectLink: ctx.Repo.RepoLink + "/settings/actions/variables",
|
||||
}, nil
|
||||
}
|
||||
|
||||
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 &variablesCtx{
|
||||
OwnerID: ctx.ContextUser.ID,
|
||||
RepoID: 0,
|
||||
IsOrg: true,
|
||||
VariablesTemplate: tplOrgVariables,
|
||||
RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ctx.Data["PageIsUserSettings"] == true {
|
||||
return &variablesCtx{
|
||||
OwnerID: ctx.Doer.ID,
|
||||
RepoID: 0,
|
||||
IsUser: true,
|
||||
VariablesTemplate: tplUserVariables,
|
||||
RedirectLink: setting.AppSubURL + "/user/settings/actions/variables",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ctx.Data["PageIsAdmin"] == true {
|
||||
return &variablesCtx{
|
||||
OwnerID: 0,
|
||||
RepoID: 0,
|
||||
IsGlobal: true,
|
||||
VariablesTemplate: tplAdminVariables,
|
||||
RedirectLink: setting.AppSubURL + "/-/admin/actions/variables",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("unable to set Variables context")
|
||||
}
|
||||
|
||||
func Variables(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("actions.variables")
|
||||
ctx.Data["PageType"] = "variables"
|
||||
ctx.Data["PageIsSharedSettingsVariables"] = true
|
||||
|
||||
vCtx, err := getVariablesCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getVariablesCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
variables, err := db.Find[actions_model.ActionVariable](ctx, actions_model.FindVariablesOpts{
|
||||
OwnerID: vCtx.OwnerID,
|
||||
RepoID: vCtx.RepoID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("FindVariables", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Variables"] = variables
|
||||
ctx.Data["DataMaxLength"] = actions_model.VariableDataMaxLength
|
||||
ctx.Data["DescriptionMaxLength"] = actions_model.VariableDescriptionMaxLength
|
||||
ctx.HTML(http.StatusOK, vCtx.VariablesTemplate)
|
||||
}
|
||||
|
||||
func VariableCreate(ctx *context.Context) {
|
||||
vCtx, err := getVariablesCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getVariablesCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() { // form binding validation error
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.EditVariableForm)
|
||||
|
||||
v, err := actions_service.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, form.Name, form.Data, form.Description)
|
||||
if err != nil {
|
||||
log.Error("CreateVariable: %v", err)
|
||||
ctx.JSONError(ctx.Tr("actions.variables.creation.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name))
|
||||
ctx.JSONRedirect(vCtx.RedirectLink)
|
||||
}
|
||||
|
||||
func VariableUpdate(ctx *context.Context) {
|
||||
vCtx, err := getVariablesCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getVariablesCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() { // form binding validation error
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
id := ctx.PathParamInt64("variable_id")
|
||||
|
||||
variable := findActionsVariable(ctx, id, vCtx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.EditVariableForm)
|
||||
variable.Name = form.Name
|
||||
variable.Data = form.Data
|
||||
variable.Description = form.Description
|
||||
|
||||
if ok, err := actions_service.UpdateVariableNameData(ctx, variable); err != nil || !ok {
|
||||
log.Error("UpdateVariable: %v", err)
|
||||
ctx.JSONError(ctx.Tr("actions.variables.update.failed"))
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("actions.variables.update.success"))
|
||||
ctx.JSONRedirect(vCtx.RedirectLink)
|
||||
}
|
||||
|
||||
func findActionsVariable(ctx *context.Context, id int64, vCtx *variablesCtx) *actions_model.ActionVariable {
|
||||
opts := actions_model.FindVariablesOpts{
|
||||
IDs: []int64{id},
|
||||
}
|
||||
switch {
|
||||
case vCtx.IsRepo:
|
||||
opts.RepoID = vCtx.RepoID
|
||||
if opts.RepoID == 0 {
|
||||
panic("RepoID is 0")
|
||||
}
|
||||
case vCtx.IsOrg, vCtx.IsUser:
|
||||
opts.OwnerID = vCtx.OwnerID
|
||||
if opts.OwnerID == 0 {
|
||||
panic("OwnerID is 0")
|
||||
}
|
||||
case vCtx.IsGlobal:
|
||||
// do nothing
|
||||
default:
|
||||
panic("invalid actions variable")
|
||||
}
|
||||
|
||||
got, err := actions_model.FindVariables(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindVariables", err)
|
||||
return nil
|
||||
} else if len(got) == 0 {
|
||||
ctx.NotFound(nil)
|
||||
return nil
|
||||
}
|
||||
return got[0]
|
||||
}
|
||||
|
||||
func VariableDelete(ctx *context.Context) {
|
||||
vCtx, err := getVariablesCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getVariablesCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
id := ctx.PathParamInt64("variable_id")
|
||||
|
||||
variable := findActionsVariable(ctx, id, vCtx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := actions_service.DeleteVariableByID(ctx, variable.ID); err != nil {
|
||||
log.Error("Delete variable [%d] failed: %v", id, err)
|
||||
ctx.JSONError(ctx.Tr("actions.variables.deletion.failed"))
|
||||
return
|
||||
}
|
||||
ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success"))
|
||||
ctx.JSONRedirect(vCtx.RedirectLink)
|
||||
}
|
||||
Reference in New Issue
Block a user