This commit is contained in:
@@ -8,13 +8,13 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
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 (
|
||||
|
||||
@@ -4,21 +4,22 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
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 (
|
||||
@@ -158,8 +159,10 @@ func Runners(ctx *context.Context) {
|
||||
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)
|
||||
pager.AddParamFromQuery(ctx.Req.URL.Query())
|
||||
|
||||
ctx.Data["Page"] = pager
|
||||
|
||||
@@ -362,8 +365,76 @@ func RunnerUpdatePost(ctx *context.Context) {
|
||||
ctx.JSONRedirect("")
|
||||
}
|
||||
|
||||
func RedirectToDefaultSetting(ctx *context.Context) {
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners")
|
||||
// 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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -7,16 +7,16 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
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 (
|
||||
|
||||
@@ -6,11 +6,11 @@ package issue
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]`
|
||||
@@ -38,6 +38,7 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo
|
||||
ctx.ServerError("GetLabelsByRepoID", err)
|
||||
return ret
|
||||
}
|
||||
issues_model.SortLabelsForDisplay(repoLabels)
|
||||
allLabels = append(allLabels, repoLabels...)
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo
|
||||
ctx.ServerError("GetLabelsByOrgID", err)
|
||||
return ret
|
||||
}
|
||||
issues_model.SortLabelsForDisplay(orgLabels)
|
||||
allLabels = append(allLabels, orgLabels...)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
package label
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/label"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
"gitea.dev/modules/label"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
)
|
||||
|
||||
func GetLabelEditForm(ctx *context.Context) *forms.CreateLabelForm {
|
||||
form := web.GetForm(ctx).(*forms.CreateLabelForm)
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.Data["ErrorMsg"].(string))
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
|
||||
@@ -6,8 +6,8 @@ package mention
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
)
|
||||
|
||||
// Mention is the JSON structure returned by mention autocomplete endpoints.
|
||||
|
||||
@@ -8,16 +8,16 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
cargo_service "code.gitea.io/gitea/services/packages/cargo"
|
||||
container_service "code.gitea.io/gitea/services/packages/container"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
cargo_service "gitea.dev/services/packages/cargo"
|
||||
container_service "gitea.dev/services/packages/container"
|
||||
)
|
||||
|
||||
func SetPackagesContext(ctx *context.Context, owner *user_model.User) {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
project_model "gitea.dev/models/project"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// MoveColumns moves or keeps columns in a project and sorts them inside that project
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
secret_model "code.gitea.io/gitea/models/secret"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
secret_service "code.gitea.io/gitea/services/secrets"
|
||||
"gitea.dev/models/db"
|
||||
secret_model "gitea.dev/models/secret"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
secret_service "gitea.dev/services/secrets"
|
||||
)
|
||||
|
||||
func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) {
|
||||
|
||||
@@ -6,11 +6,13 @@ package user
|
||||
import (
|
||||
"errors"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
|
||||
func BlockedUsers(ctx *context.Context, blocker *user_model.User) {
|
||||
@@ -28,49 +30,51 @@ func BlockedUsers(ctx *context.Context, blocker *user_model.User) {
|
||||
ctx.Data["UserBlocks"] = blocks
|
||||
}
|
||||
|
||||
func BlockedUsersPost(ctx *context.Context, blocker *user_model.User) {
|
||||
form := web.GetForm(ctx).(*forms.BlockUserForm)
|
||||
if ctx.HasError() {
|
||||
ctx.ServerError("FormValidation", nil)
|
||||
return
|
||||
}
|
||||
|
||||
func blockedUsersPost(ctx *context.Context, form *forms.BlockUserForm, blocker *user_model.User) error {
|
||||
blockee, err := user_model.GetUserByName(ctx, form.Blockee)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserByName", nil)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
switch form.Action {
|
||||
case "block":
|
||||
if err := user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, form.Note); err != nil {
|
||||
if errors.Is(err, user_model.ErrCanNotBlock) || errors.Is(err, user_model.ErrBlockOrganization) {
|
||||
ctx.Flash.Error(ctx.Tr("user.block.block.failure", err.Error()))
|
||||
} else {
|
||||
ctx.ServerError("BlockUser", err)
|
||||
return
|
||||
}
|
||||
err = user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, form.Note)
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
return util.ErrorWrapTranslatable(err, "user.block.block.failure", err.Error())
|
||||
}
|
||||
return err
|
||||
case "unblock":
|
||||
if err := user_service.UnblockUser(ctx, ctx.Doer, blocker, blockee); err != nil {
|
||||
if errors.Is(err, user_model.ErrCanNotUnblock) || errors.Is(err, user_model.ErrBlockOrganization) {
|
||||
ctx.Flash.Error(ctx.Tr("user.block.unblock.failure", err.Error()))
|
||||
} else {
|
||||
ctx.ServerError("UnblockUser", err)
|
||||
return
|
||||
}
|
||||
err = user_service.UnblockUser(ctx, ctx.Doer, blocker, blockee)
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
return util.ErrorWrapTranslatable(err, "user.block.unblock.failure", err.Error())
|
||||
}
|
||||
return err
|
||||
case "note":
|
||||
block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetBlocking", err)
|
||||
return
|
||||
}
|
||||
if block != nil {
|
||||
if err := user_model.UpdateBlockingNote(ctx, block.ID, form.Note); err != nil {
|
||||
ctx.ServerError("UpdateBlockingNote", err)
|
||||
return
|
||||
}
|
||||
return err
|
||||
}
|
||||
return user_model.UpdateBlockingNote(ctx, block.ID, form.Note)
|
||||
}
|
||||
setting.PanicInDevOrTesting("Unknown action: %q", form.Action)
|
||||
return errors.New("unknown action")
|
||||
}
|
||||
|
||||
func BlockedUsersPost(ctx *context.Context, blocker *user_model.User, redirect string) {
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.BlockUserForm)
|
||||
err := blockedUsersPost(ctx, form, blocker)
|
||||
if err == nil {
|
||||
ctx.JSONRedirect(redirect)
|
||||
} else if errTr := util.ErrorAsTranslatable(err); errTr != nil {
|
||||
ctx.JSONError(errTr.Translate(ctx.Locale))
|
||||
} else if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.JSONError(ctx.Locale.Tr("error.not_found"))
|
||||
} else {
|
||||
ctx.ServerError("BlockedUsersPost", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,25 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
project_model "code.gitea.io/gitea/models/project"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
project_model "gitea.dev/models/project"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// prepareContextForProfileBigAvatar set the context for big avatar view on the profile page
|
||||
@@ -83,10 +84,9 @@ func prepareContextForProfileBigAvatar(ctx *context.Context) {
|
||||
}
|
||||
|
||||
if ctx.Doer != nil {
|
||||
if block, err := user_model.GetBlocking(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
|
||||
ctx.Data["UserBlocking"], err = user_model.GetBlocking(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
ctx.ServerError("GetBlocking", err)
|
||||
} else {
|
||||
ctx.Data["UserBlocking"] = block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,25 +8,47 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/models/user"
|
||||
"gitea.dev/models/user"
|
||||
)
|
||||
|
||||
type SearchUserInfo struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"username"`
|
||||
AvatarLink string `json:"avatar_link"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
type SearchUserResponse struct {
|
||||
Results []*SearchUserInfo `json:"results"`
|
||||
}
|
||||
|
||||
func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User {
|
||||
if doer != nil {
|
||||
idx := slices.IndexFunc(users, func(u *user.User) bool {
|
||||
return u.ID == doer.ID
|
||||
})
|
||||
if idx > 0 {
|
||||
newUsers := make([]*user.User, len(users))
|
||||
newUsers[0] = users[idx]
|
||||
copy(newUsers[1:], users[:idx])
|
||||
copy(newUsers[idx+1:], users[idx+1:])
|
||||
return newUsers
|
||||
}
|
||||
if doer == nil {
|
||||
return users
|
||||
}
|
||||
idx := slices.IndexFunc(users, func(u *user.User) bool {
|
||||
return u.ID == doer.ID
|
||||
})
|
||||
if idx > 0 {
|
||||
newUsers := make([]*user.User, len(users))
|
||||
newUsers[0] = users[idx]
|
||||
copy(newUsers[1:], users[:idx])
|
||||
copy(newUsers[idx+1:], users[idx+1:])
|
||||
return newUsers
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func ToSearchUserResponse(ctx context.Context, self *user.User, users []*user.User) (ret *SearchUserResponse) {
|
||||
infos := MakeSelfOnTop(self, users)
|
||||
resp := &SearchUserResponse{Results: make([]*SearchUserInfo, len(infos))}
|
||||
for i, u := range infos {
|
||||
resp.Results[i] = &SearchUserInfo{UserID: u.ID, UserName: u.Name, AvatarLink: u.AvatarLink(ctx)}
|
||||
resp.Results[i].FullName = u.FullName
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// GetFilterUserIDByName tries to get the user ID from the given username.
|
||||
// Before, the "issue filter" passes user ID to query the list, but in many cases, it's impossible to pre-fetch the full user list.
|
||||
// So it's better to make it work like GitHub: users could input username directly.
|
||||
|
||||
@@ -6,7 +6,7 @@ package user
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/user"
|
||||
"gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user