Compare commits

...

6 Commits

Author SHA1 Message Date
wxiaoguang
53d948898f
Merge c2af8385e0 into bc50431e8b 2025-10-27 00:06:58 +08:00
Lunny Xiao
bc50431e8b
Upgrade go mail to 0.7.2 (#35748) 2025-10-26 09:52:01 -04:00
GiteaBot
2a6af15448 [skip ci] Updated translations via Crowdin 2025-10-26 00:38:59 +00:00
Zettat123
c9beb0b01f
Support actions and reusable workflows from private repos (#32562)
Some checks are pending
release-nightly / nightly-binary (push) Waiting to run
release-nightly / nightly-docker-rootful (push) Waiting to run
release-nightly / nightly-docker-rootless (push) Waiting to run
Resolve https://gitea.com/gitea/act_runner/issues/102

This PR allows administrators of a private repository to specify some
collaborative owners. The repositories of collaborative owners will be
allowed to access this repository's actions and workflows.

Settings for private repos:


![image](https://github.com/user-attachments/assets/e591c877-f94d-48fb-82f3-3b051f21557e)

---

This PR also moves "Enable Actions" setting to `Actions > General` page

<img width="960" alt="image"
src="https://github.com/user-attachments/assets/49337ec2-afb1-4a67-8516-5c9ef0ce05d4"
/>

<img width="960" alt="image"
src="https://github.com/user-attachments/assets/f58ee6d5-17f9-4180-8760-a78e859f1c37"
/>

---------

Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
2025-10-25 17:37:33 +00:00
Lunny Xiao
5454fdacd4
Use git model to detect whether branch exist instead of gitrepo method (#35459) 2025-10-25 10:08:25 -07:00
wxiaoguang
c2af8385e0 fix 2025-10-24 14:34:01 +08:00
88 changed files with 678 additions and 192 deletions

2
go.mod
View File

@ -109,7 +109,7 @@ require (
github.com/ulikunitz/xz v0.5.15
github.com/urfave/cli-docs/v3 v3.0.0-alpha6
github.com/urfave/cli/v3 v3.4.1
github.com/wneessen/go-mail v0.7.1
github.com/wneessen/go-mail v0.7.2
github.com/xeipuuv/gojsonschema v1.2.0
github.com/yohcop/openid-go v1.0.1
github.com/yuin/goldmark v1.7.13

4
go.sum
View File

@ -768,8 +768,8 @@ github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZ
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wneessen/go-mail v0.7.1 h1:rvy63sp14N06/kdGqCYwW8Na5gDCXjTQM1E7So4PuKk=
github.com/wneessen/go-mail v0.7.1/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=

View File

@ -139,3 +139,23 @@
updated: 1683636626
need_approval: 0
approved_by: 0
-
id: 804
title: "use a private action"
repo_id: 60
owner_id: 40
workflow_id: "run.yaml"
index: 189
trigger_user_id: 40
ref: "refs/heads/master"
commit_sha: "6e64b26de7ba966d01d90ecfaf5c7f14ef203e86"
event: "push"
trigger_event: "push"
is_fork_pull_request: 0
status: 1
started: 1683636528
stopped: 1683636626
created: 1683636108
updated: 1683636626
need_approval: 0
approved_by: 0

View File

@ -129,3 +129,17 @@
status: 5
started: 1683636528
stopped: 1683636626
-
id: 205
run_id: 804
repo_id: 6
owner_id: 10
commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86
is_fork_pull_request: 0
name: job_2
attempt: 1
job_id: job_2
task_id: 48
status: 1
started: 1683636528
stopped: 1683636626

View File

@ -177,3 +177,23 @@
log_length: 0
log_size: 0
log_expired: 0
-
id: 55
job_id: 205
attempt: 1
runner_id: 1
status: 6 # 6 is the status code for "running"
started: 1683636528
stopped: 1683636626
repo_id: 6
owner_id: 10
commit_sha: 6e64b26de7ba966d01d90ecfaf5c7f14ef203e86
is_fork_pull_request: 0
token_hash: b8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc478422b
token_salt: ERxJGHvg3I
token_last_eight: 182199eb
log_filename: collaborative-owner-test/1a/49.log
log_in_storage: 1
log_length: 707
log_size: 90179
log_expired: 0

View File

@ -225,3 +225,27 @@
is_deleted: false
deleted_by_id: 0
deleted_unix: 0
-
id: 27
repo_id: 1
name: 'DefaultBranch'
commit_id: '90c1019714259b24fb81711d4416ac0f18667dfa'
commit_message: 'add license'
commit_time: 1709345946
pusher_id: 1
is_deleted: false
deleted_by_id: 0
deleted_unix: 0
-
id: 28
repo_id: 1
name: 'sub-home-md-img-check'
commit_id: '4649299398e4d39a5c09eb4f534df6f1e1eb87cc'
commit_message: "Test how READMEs render images when found in a subfolder"
commit_time: 1678403550
pusher_id: 1
is_deleted: false
deleted_by_id: 0
deleted_unix: 0

View File

@ -733,3 +733,10 @@
type: 3
config: "{\"IgnoreWhitespaceConflicts\":false,\"AllowMerge\":true,\"AllowRebase\":true,\"AllowRebaseMerge\":true,\"AllowSquash\":true}"
created_unix: 946684810
-
id: 111
repo_id: 3
type: 10
config: "{}"
created_unix: 946684810

View File

@ -264,13 +264,22 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
if err != nil {
return perm, err
}
if task.RepoID != repo.ID {
// FIXME allow public repo read access if tokenless pull is enabled
return perm, nil
}
var accessMode perm_model.AccessMode
if task.IsForkPullRequest {
if task.RepoID != repo.ID {
taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID)
if err != nil || !exist {
return perm, err
}
actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate {
// The task repo can access the current repo only if the task repo is private and
// the owner of the task repo is a collaborative owner of the current repo.
// FIXME allow public repo read access if tokenless pull is enabled
return perm, nil
}
accessMode = perm_model.AccessModeRead
} else if task.IsForkPullRequest {
accessMode = perm_model.AccessModeRead
} else {
accessMode = perm_model.AccessModeWrite

View File

@ -170,6 +170,9 @@ func (cfg *PullRequestsConfig) GetDefaultMergeStyle() MergeStyle {
type ActionsConfig struct {
DisabledWorkflows []string
// CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos.
// Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions.
CollaborativeOwnerIDs []int64
}
func (cfg *ActionsConfig) EnableWorkflow(file string) {
@ -192,6 +195,20 @@ func (cfg *ActionsConfig) DisableWorkflow(file string) {
cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file)
}
func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) {
if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) {
cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID)
}
}
func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) {
cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID)
}
func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool {
return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID)
}
// FromDB fills up a ActionsConfig from serialized format.
func (cfg *ActionsConfig) FromDB(bs []byte) error {
return json.UnmarshalHandleDoubleEncode(bs, &cfg)

View File

@ -6,6 +6,7 @@ package user
import (
"context"
"fmt"
"slices"
"strings"
"code.gitea.io/gitea/models/db"
@ -22,7 +23,7 @@ type SearchUserOptions struct {
db.ListOptions
Keyword string
Type UserType
Types []UserType
UID int64
LoginName string // this option should be used only for admin user
SourceID int64 // this option should be used only for admin user
@ -43,16 +44,16 @@ type SearchUserOptions struct {
func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session {
var cond builder.Cond
cond = builder.Eq{"type": opts.Type}
cond = builder.In("type", opts.Types)
if opts.IncludeReserved {
switch opts.Type {
case UserTypeIndividual:
switch {
case slices.Contains(opts.Types, UserTypeIndividual):
cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or(
builder.Eq{"type": UserTypeBot},
).Or(
builder.Eq{"type": UserTypeRemoteUser},
)
case UserTypeOrganization:
case slices.Contains(opts.Types, UserTypeOrganization):
cond = cond.Or(builder.Eq{"type": UserTypeOrganizationReserved})
}
}

View File

@ -1449,3 +1449,15 @@ func DisabledFeaturesWithLoginType(user *User) *container.Set[string] {
}
return &setting.Admin.UserDisabledFeatures
}
// GetUserOrOrgIDByName returns the id for a user or an org by name
func GetUserOrOrgIDByName(ctx context.Context, name string) (int64, error) {
var id int64
has, err := db.GetEngine(ctx).Table("user").Where("name = ?", name).Cols("id").Get(&id)
if err != nil {
return 0, err
} else if !has {
return 0, fmt.Errorf("user or org with name %s: %w", name, util.ErrNotExist)
}
return id, nil
}

View File

@ -126,7 +126,7 @@ func TestSearchUsers(t *testing.T) {
// test orgs
testOrgSuccess := func(opts user_model.SearchUserOptions, expectedOrgIDs []int64) {
opts.Type = user_model.UserTypeOrganization
opts.Types = []user_model.UserType{user_model.UserTypeOrganization}
testSuccess(opts, expectedOrgIDs)
}
@ -150,7 +150,7 @@ func TestSearchUsers(t *testing.T) {
// test users
testUserSuccess := func(opts user_model.SearchUserOptions, expectedUserIDs []int64) {
opts.Type = user_model.UserTypeIndividual
opts.Types = []user_model.UserType{user_model.UserTypeIndividual}
testSuccess(opts, expectedUserIDs)
}

View File

@ -3586,6 +3586,7 @@ variables.update.success=Proměnná byla upravena.
logs.always_auto_scroll=Vždy automaticky posouvat logy
logs.always_expand_running=Vždy rozšířit běžící logy
[projects]
deleted.display_name=Odstraněný projekt
type-1.display_name=Samostatný projekt

View File

@ -3645,6 +3645,7 @@ variables.update.success=Die Variable wurde bearbeitet.
logs.always_auto_scroll=Autoscroll für Logs immer aktivieren
logs.always_expand_running=Laufende Logs immer erweitern
[projects]
deleted.display_name=Gelöschtes Projekt
type-1.display_name=Individuelles Projekt

View File

@ -3280,6 +3280,7 @@ variables.update.failed=Αποτυχία επεξεργασίας μεταβλη
variables.update.success=Η μεταβλητή έχει τροποποιηθεί.
[projects]
type-1.display_name=Ατομικό Έργο
type-2.display_name=Έργο Αποθετηρίου

View File

@ -3914,6 +3914,15 @@ variables.update.success = The variable has been edited.
logs.always_auto_scroll = Always auto scroll logs
logs.always_expand_running = Always expand running logs
general = General
general.enable_actions = Enable Actions
general.collaborative_owners_management = Collaborative Owners Management
general.collaborative_owners_management_help = A collaborative owner is a user or an organization whose private repository has access to the actions and workflows of this repository.
general.add_collaborative_owner = Add Collaborative Owner
general.collaborative_owner_not_exist = The collaborative owner does not exist.
general.remove_collaborative_owner = Remove Collaborative Owner
general.remove_collaborative_owner_desc = Removing a collaborative owner will prevent the repositories of the owner from accessing the actions in this repository. Continue?
[projects]
deleted.display_name = Deleted Project
type-1.display_name = Individual Project

View File

@ -3257,6 +3257,7 @@ variables.update.failed=Error al editar la variable.
variables.update.success=La variable ha sido editada.
[projects]
type-1.display_name=Proyecto individual
type-2.display_name=Proyecto repositorio

View File

@ -2446,6 +2446,7 @@ runs.commit=کامیت
[projects]
[git.filemode]

View File

@ -1693,6 +1693,7 @@ runs.commit=Commit
[projects]
[git.filemode]

View File

@ -3906,6 +3906,7 @@ variables.update.success=La variable a bien été modifiée.
logs.always_auto_scroll=Toujours faire défiler les journaux automatiquement
logs.always_expand_running=Toujours développer les journaux en cours
[projects]
deleted.display_name=Projet supprimé
type-1.display_name=Projet personnel

View File

@ -3914,6 +3914,7 @@ variables.update.success=Tá an t-athróg curtha in eagar.
logs.always_auto_scroll=Logchomhaid scrollaithe uathoibríoch i gcónaí
logs.always_expand_running=Leathnaigh logs reatha i gcónaí
[projects]
deleted.display_name=Tionscadal scriosta
type-1.display_name=Tionscadal Aonair

View File

@ -1605,6 +1605,7 @@ runs.commit=Commit
[projects]
[git.filemode]

View File

@ -1428,6 +1428,7 @@ variables.update.failed=Gagal mengedit variabel.
variables.update.success=Variabel telah diedit.
[projects]
type-1.display_name=Proyek Individu
type-2.display_name=Proyek Repositori

View File

@ -1334,6 +1334,7 @@ runs.commit=Framlag
[projects]
[git.filemode]

View File

@ -2706,6 +2706,7 @@ runs.commit=Commit
[projects]
[git.filemode]

View File

@ -3910,6 +3910,7 @@ variables.update.success=変数を更新しました。
logs.always_auto_scroll=常にログを自動スクロール
logs.always_expand_running=常に実行中のログを展開
[projects]
deleted.display_name=削除されたプロジェクト
type-1.display_name=個人プロジェクト

View File

@ -1554,6 +1554,7 @@ runs.commit=커밋
[projects]
[git.filemode]

View File

@ -3282,6 +3282,7 @@ variables.update.failed=Neizdevās labot mainīgo.
variables.update.success=Mainīgais tika labots.
[projects]
type-1.display_name=Individuālais projekts
type-2.display_name=Repozitorija projekts

View File

@ -2458,6 +2458,7 @@ runs.commit=Commit
[projects]
[git.filemode]

View File

@ -2347,6 +2347,7 @@ runs.commit=Commit
[projects]
[git.filemode]

View File

@ -3615,6 +3615,7 @@ variables.update.failed=Falha ao editar a variável.
variables.update.success=A variável foi editada.
[projects]
deleted.display_name=Excluir Projeto
type-1.display_name=Projeto Individual

View File

@ -3914,6 +3914,7 @@ variables.update.success=A variável foi editada.
logs.always_auto_scroll=Rolar registos de forma automática e permanente
logs.always_expand_running=Expandir sempre os registos que vão rolando
[projects]
deleted.display_name=Planeamento eliminado
type-1.display_name=Planeamento individual

View File

@ -3225,6 +3225,7 @@ variables.update.failed=Не удалось изменить переменну
variables.update.success=Переменная изменена.
[projects]
type-1.display_name=Индивидуальный проект
type-2.display_name=Проект репозитория

View File

@ -2391,6 +2391,7 @@ runs.commit=කැප
[projects]
[git.filemode]

View File

@ -1292,6 +1292,7 @@ runners.labels=Štítky
[projects]
[git.filemode]

View File

@ -1968,6 +1968,7 @@ runs.commit=Commit
[projects]
[git.filemode]

View File

@ -3907,6 +3907,7 @@ variables.update.success=Değişken düzenlendi.
logs.always_auto_scroll=Günlükleri her zaman otomatik kaydır
logs.always_expand_running=Çalıştırma günlüklerini her zaman genişlet
[projects]
deleted.display_name=Silinmiş Proje
type-1.display_name=Kişisel Proje

View File

@ -3428,6 +3428,7 @@ variables.update.success=Змінну відредаговано.
logs.always_auto_scroll=Завжди автоматично прокручувати журнали
logs.always_expand_running=Завжди розгортати поточні журнали
[projects]
deleted.display_name=Видалений проєкт
type-1.display_name=Індивідуальний проєкт

View File

@ -3911,6 +3911,7 @@ variables.update.success=变量已编辑。
logs.always_auto_scroll=总是自动滚动日志
logs.always_expand_running=总是展开运行日志
[projects]
deleted.display_name=已删除项目
type-1.display_name=个人项目

View File

@ -980,6 +980,7 @@ runners.task_list.repository=儲存庫
[projects]
[git.filemode]

View File

@ -3554,6 +3554,7 @@ variables.update.failed=編輯變數失敗。
variables.update.success=已編輯變數。
[projects]
deleted.display_name=已刪除的專案
type-1.display_name=個人專案

View File

@ -103,7 +103,7 @@ func GetAllOrgs(ctx *context.APIContext) {
users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Type: user_model.UserTypeOrganization,
Types: []user_model.UserType{user_model.UserTypeOrganization},
OrderBy: db.SearchOrderByAlphabetically,
ListOptions: listOptions,
Visible: []api.VisibleType{api.VisibleTypePublic, api.VisibleTypeLimited, api.VisibleTypePrivate},

View File

@ -425,7 +425,7 @@ func SearchUsers(ctx *context.APIContext) {
users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Type: user_model.UserTypeIndividual,
Types: []user_model.UserType{user_model.UserTypeIndividual},
LoginName: ctx.FormTrim("login_name"),
SourceID: ctx.FormInt64("source_id"),
OrderBy: db.SearchOrderByAlphabetically,

View File

@ -202,7 +202,7 @@ func GetAll(ctx *context.APIContext) {
publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
ListOptions: listOptions,
Type: user_model.UserTypeOrganization,
Types: []user_model.UserType{user_model.UserTypeOrganization},
OrderBy: db.SearchOrderByAlphabetically,
Visible: vMode,
})

View File

@ -225,7 +225,7 @@ func CreateBranch(ctx *context.APIContext) {
return
}
} else if len(opt.OldBranchName) > 0 { //nolint:staticcheck // deprecated field
if gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, opt.OldBranchName) { //nolint:staticcheck // deprecated field
if exist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, opt.OldBranchName); exist { //nolint:staticcheck // deprecated field
oldCommit, err = ctx.Repo.GitRepo.GetBranchCommit(opt.OldBranchName) //nolint:staticcheck // deprecated field
if err != nil {
ctx.APIErrorInternal(err)
@ -1011,7 +1011,11 @@ func EditBranchProtection(ctx *context.APIContext) {
isPlainRule := !git_model.IsRuleNameSpecial(bpName)
var isBranchExist bool
if isPlainRule {
isBranchExist = gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, bpName)
isBranchExist, err = git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, bpName)
if err != nil {
ctx.APIErrorInternal(err)
return
}
}
if isBranchExist {

View File

@ -13,6 +13,7 @@ import (
"time"
activities_model "code.gitea.io/gitea/models/activities"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
pull_model "code.gitea.io/gitea/models/pull"
@ -755,7 +756,12 @@ func EditPullRequest(ctx *context.APIContext) {
// change pull target branch
if !pr.HasMerged && len(form.Base) != 0 && form.Base != pr.BaseBranch {
if !gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, form.Base) {
branchExist, err := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, form.Base)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if !branchExist {
ctx.APIError(http.StatusNotFound, fmt.Errorf("new base '%s' not exist", form.Base))
return
}

View File

@ -77,7 +77,7 @@ func Search(ctx *context.APIContext) {
Actor: ctx.Doer,
Keyword: ctx.FormTrim("q"),
UID: uid,
Type: user_model.UserTypeIndividual,
Types: []user_model.UserType{user_model.UserTypeIndividual},
SearchByEmail: true,
Visible: visible,
ListOptions: listOptions,

View File

@ -6,6 +6,7 @@ package utils
import (
"errors"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
@ -27,7 +28,7 @@ func ResolveRefCommit(ctx reqctx.RequestContext, repo *repo_model.Repository, in
return nil, err
}
refCommit := RefCommit{InputRef: inputRef}
if gitrepo.IsBranchExist(ctx, repo, inputRef) {
if exist, _ := git_model.IsBranchExist(ctx, repo.ID, inputRef); exist {
refCommit.RefName = git.RefNameFromBranch(inputRef)
} else if gitrepo.IsTagExist(ctx, repo, inputRef) {
refCommit.RefName = git.RefNameFromTag(inputRef)

View File

@ -21,7 +21,9 @@ import (
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/private"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/agit"
gitea_context "code.gitea.io/gitea/services/context"
pull_service "code.gitea.io/gitea/services/pull"
)
@ -452,25 +454,18 @@ func preReceiveFor(ctx *preReceiveContext, refFullName git.RefName) {
return
}
baseBranchName := refFullName.ForBranchName()
baseBranchExist := gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, baseBranchName)
if !baseBranchExist {
for p, v := range baseBranchName {
if v == '/' && gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, baseBranchName[:p]) && p != len(baseBranchName)-1 {
baseBranchExist = true
break
}
_, _, err := agit.GetAgitBranchInfo(ctx, ctx.Repo.Repository.ID, refFullName.ForBranchName())
if err != nil {
if !errors.Is(err, util.ErrNotExist) {
ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("Unexpected ref: %s", refFullName),
})
} else {
ctx.JSON(http.StatusInternalServerError, private.Response{
Err: err.Error(),
})
}
}
if !baseBranchExist {
ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("Unexpected ref: %s", refFullName),
})
return
}
}
func generateGitEnv(opts *private.HookOptions) (env []string) {

View File

@ -29,7 +29,7 @@ func Organizations(ctx *context.Context) {
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Type: user_model.UserTypeOrganization,
Types: []user_model.UserType{user_model.UserTypeOrganization},
IncludeReserved: true, // administrator needs to list all accounts include reserved
ListOptions: db.ListOptions{
PageSize: setting.UI.Admin.OrgPagingNum,

View File

@ -67,7 +67,7 @@ func Users(ctx *context.Context) {
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Type: user_model.UserTypeIndividual,
Types: []user_model.UserType{user_model.UserTypeIndividual},
ListOptions: db.ListOptions{
PageSize: setting.UI.Admin.UserPagingNum,
},

View File

@ -46,7 +46,7 @@ func Organizations(ctx *context.Context) {
RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Type: user_model.UserTypeOrganization,
Types: []user_model.UserType{user_model.UserTypeOrganization},
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
Visible: visibleTypes,

View File

@ -153,7 +153,7 @@ func Users(ctx *context.Context) {
RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Type: user_model.UserTypeIndividual,
Types: []user_model.UserType{user_model.UserTypeIndividual},
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
IsActive: optional.Some(true),
Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate},

View File

@ -69,7 +69,7 @@ func HomeSitemap(ctx *context.Context) {
m := sitemap.NewSitemapIndex()
if !setting.Service.Explore.DisableUsersPage {
_, cnt, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
Type: user_model.UserTypeIndividual,
Types: []user_model.UserType{user_model.UserTypeIndividual},
ListOptions: db.ListOptions{PageSize: 1},
IsActive: optional.Some(true),
Visible: []structs.VisibleType{structs.VisibleTypePublic},

View File

@ -306,7 +306,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo {
// Check if base branch is valid.
baseIsCommit := ctx.Repo.GitRepo.IsCommitExist(ci.BaseBranch)
baseIsBranch := gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, ci.BaseBranch)
baseIsBranch, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, ci.BaseBranch)
baseIsTag := gitrepo.IsTagExist(ctx, ctx.Repo.Repository, ci.BaseBranch)
if !baseIsCommit && !baseIsBranch && !baseIsTag {
@ -508,7 +508,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo {
// Check if head branch is valid.
headIsCommit := ci.HeadGitRepo.IsCommitExist(ci.HeadBranch)
headIsBranch := gitrepo.IsBranchExist(ctx, ci.HeadRepo, ci.HeadBranch)
headIsBranch, _ := git_model.IsBranchExist(ctx, ci.HeadRepo.ID, ci.HeadBranch)
headIsTag := gitrepo.IsTagExist(ctx, ci.HeadRepo, ci.HeadBranch)
if !headIsCommit && !headIsBranch && !headIsTag {
// Check if headBranch is short sha commit hash

View File

@ -191,7 +191,7 @@ func httpBase(ctx *context.Context) *serviceHandler {
taskID := ctx.Data["ActionsTaskID"].(int64)
p, err := access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
ctx.ServerError("GetActionsUserRepoPermission", err)
return nil
}

View File

@ -11,11 +11,11 @@ import (
"strconv"
"strings"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/renderhelper"
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/htmlutil"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/markdown"
@ -121,7 +121,7 @@ func NewComment(ctx *context.Context) {
ctx.ServerError("Unable to load head repo", err)
return
}
if ok := gitrepo.IsBranchExist(ctx, pull.HeadRepo, pull.BaseBranch); !ok {
if exist, _ := git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.BaseBranch); !exist {
// todo localize
ctx.JSONError("The origin branch is delete, cannot reopen.")
return

View File

@ -26,7 +26,6 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/emoji"
"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"
@ -566,8 +565,10 @@ func preparePullViewDeleteBranch(ctx *context.Context, issue *issues_model.Issue
pull := issue.PullRequest
isPullBranchDeletable := canDelete &&
pull.HeadRepo != nil &&
gitrepo.IsBranchExist(ctx, pull.HeadRepo, pull.HeadBranch) &&
(!pull.HasMerged || ctx.Data["HeadBranchCommitID"] == ctx.Data["PullHeadCommitID"])
if isPullBranchDeletable {
isPullBranchDeletable, _ = git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch)
}
if isPullBranchDeletable && pull.HasMerged {
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pull.HeadRepoID, pull.HeadBranch)

View File

@ -358,7 +358,7 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_
defer baseGitRepo.Close()
}
if !gitrepo.IsBranchExist(ctx, pull.BaseRepo, pull.BaseBranch) {
if exist, _ := git_model.IsBranchExist(ctx, pull.BaseRepo.ID, pull.BaseBranch); !exist {
ctx.Data["BaseBranchNotExist"] = true
ctx.Data["IsPullRequestBroken"] = true
ctx.Data["BaseTarget"] = pull.BaseBranch
@ -415,7 +415,7 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *pull_
defer closer.Close()
if pull.Flow == issues_model.PullRequestFlowGithub {
headBranchExist = gitrepo.IsBranchExist(ctx, pull.HeadRepo, pull.HeadBranch)
headBranchExist, _ = git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch)
} else {
headBranchExist = gitrepo.IsReferenceExist(ctx, pull.BaseRepo, pull.GetGitHeadRefName())
}

View File

@ -18,7 +18,6 @@ import (
"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/markup/markdown"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
@ -424,7 +423,7 @@ func NewReleasePost(ctx *context.Context) {
return
}
if !gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, form.Target) {
if exist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, form.Target); !exist {
ctx.RenderWithErr(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form)
return
}

View File

@ -0,0 +1,121 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"errors"
"net/http"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository"
)
const tplRepoActionsGeneralSettings templates.TplName = "repo/settings/actions"
func ActionsGeneralSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("actions.general")
ctx.Data["PageType"] = "general"
ctx.Data["PageIsActionsSettingsGeneral"] = true
actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions)
if err != nil && !repo_model.IsErrUnitTypeNotExist(err) {
ctx.ServerError("GetUnit", err)
return
}
if actionsUnit == nil { // no actions unit
ctx.HTML(http.StatusOK, tplRepoActionsGeneralSettings)
return
}
if ctx.Repo.Repository.IsPrivate {
collaborativeOwnerIDs := actionsUnit.ActionsConfig().CollaborativeOwnerIDs
collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs)
if err != nil {
ctx.ServerError("GetUsersByIDs", err)
return
}
ctx.Data["CollaborativeOwners"] = collaborativeOwners
}
ctx.HTML(http.StatusOK, tplRepoActionsGeneralSettings)
}
func ActionsUnitPost(ctx *context.Context) {
redirectURL := ctx.Repo.RepoLink + "/settings/actions/general"
enableActionsUnit := ctx.FormBool("enable_actions")
repo := ctx.Repo.Repository
var err error
if enableActionsUnit && !unit_model.TypeActions.UnitGlobalDisabled() {
err = repo_service.UpdateRepositoryUnits(ctx, repo, []repo_model.RepoUnit{newRepoUnit(repo, unit_model.TypeActions, nil)}, nil)
} else if !unit_model.TypeActions.UnitGlobalDisabled() {
err = repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit_model.Type{unit_model.TypeActions})
}
if err != nil {
ctx.ServerError("UpdateRepositoryUnits", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
ctx.Redirect(redirectURL)
}
func AddCollaborativeOwner(ctx *context.Context) {
name := strings.ToLower(ctx.FormString("collaborative_owner"))
ownerID, err := user_model.GetUserOrOrgIDByName(ctx, name)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.Flash.Error(ctx.Tr("form.user_not_exist"))
ctx.JSONErrorNotFound()
} else {
ctx.ServerError("GetUserOrOrgIDByName", err)
}
return
}
actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions)
if err != nil {
ctx.ServerError("GetUnit", err)
return
}
actionsCfg := actionsUnit.ActionsConfig()
actionsCfg.AddCollaborativeOwner(ownerID)
if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil {
ctx.ServerError("UpdateRepoUnit", err)
return
}
ctx.JSONOK()
}
func DeleteCollaborativeOwner(ctx *context.Context) {
ownerID := ctx.FormInt64("id")
actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions)
if err != nil {
ctx.ServerError("GetUnit", err)
return
}
actionsCfg := actionsUnit.ActionsConfig()
if !actionsCfg.IsCollaborativeOwner(ownerID) {
ctx.Flash.Error(ctx.Tr("actions.general.collaborative_owner_not_exist"))
ctx.JSONErrorNotFound()
return
}
actionsCfg.RemoveCollaborativeOwner(ownerID)
if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil {
ctx.ServerError("UpdateRepoUnit", err)
return
}
ctx.JSONOK()
}

View File

@ -613,12 +613,6 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages)
}
if form.EnableActions && !unit_model.TypeActions.UnitGlobalDisabled() {
units = append(units, newRepoUnit(repo, unit_model.TypeActions, nil))
} else if !unit_model.TypeActions.UnitGlobalDisabled() {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions)
}
if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() {
units = append(units, newRepoUnit(repo, unit_model.TypePullRequests, &repo_model.PullRequestsConfig{
IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace,

View File

@ -16,10 +16,14 @@ import (
// SearchCandidates searches candidate users for dropdown list
func SearchCandidates(ctx *context.Context) {
searchUserTypes := []user_model.UserType{user_model.UserTypeIndividual}
if ctx.FormBool("orgs") {
searchUserTypes = append(searchUserTypes, user_model.UserTypeOrganization)
}
users, _, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Keyword: ctx.FormTrim("q"),
Type: user_model.UserTypeIndividual,
Types: searchUserTypes,
IsActive: optional.Some(true),
ListOptions: db.ListOptions{PageSize: setting.UI.MembersPagingNum},
})

View File

@ -1159,11 +1159,21 @@ func registerWebRoutes(m *web.Router) {
m.Post("/{lid}/unlock", repo_setting.LFSUnlock)
})
})
m.Group("/actions/general", func() {
m.Get("", repo_setting.ActionsGeneralSettings)
m.Post("/actions_unit", repo_setting.ActionsUnitPost)
})
m.Group("/actions", func() {
m.Get("", shared_actions.RedirectToDefaultSetting)
addSettingsRunnersRoutes()
addSettingsSecretsRoutes()
addSettingsVariablesRoutes()
m.Group("/general", func() {
m.Group("/collaborative_owner", func() {
m.Post("/add", repo_setting.AddCollaborativeOwner)
m.Post("/delete", repo_setting.DeleteCollaborativeOwner)
})
})
}, actions.MustEnableActions)
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
m.Group("/migrate", func() {

View File

@ -6,6 +6,7 @@ package agit
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
@ -32,6 +33,34 @@ func parseAgitPushOptionValue(s string) string {
return s
}
func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string) (string, string, error) {
baseBranchExist, err := git_model.IsBranchExist(ctx, repoID, baseBranchName)
if err != nil {
return "", "", err
}
if baseBranchExist {
return baseBranchName, "", nil
}
// try match <target-branch>/<topic-branch>
// refs/for have been trimmed to get baseBranchName
for p, v := range baseBranchName {
if v != '/' {
continue
}
baseBranchExist, err := git_model.IsBranchExist(ctx, repoID, baseBranchName[:p])
if err != nil {
return "", "", err
}
if baseBranchExist {
return baseBranchName[:p], baseBranchName[p+1:], nil
}
}
return "", "", util.NewNotExistErrorf("base branch does not exist")
}
// ProcReceive handle proc receive work
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) {
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
@ -70,17 +99,19 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
continue
}
baseBranchName := opts.RefFullNames[i].ForBranchName()
currentTopicBranch := ""
if !gitrepo.IsBranchExist(ctx, repo, baseBranchName) {
// try match refs/for/<target-branch>/<topic-branch>
for p, v := range baseBranchName {
if v == '/' && gitrepo.IsBranchExist(ctx, repo, baseBranchName[:p]) && p != len(baseBranchName)-1 {
currentTopicBranch = baseBranchName[p+1:]
baseBranchName = baseBranchName[:p]
break
}
baseBranchName, currentTopicBranch, err := GetAgitBranchInfo(ctx, repo.ID, opts.RefFullNames[i].ForBranchName())
if err != nil {
if !errors.Is(err, util.ErrNotExist) {
return nil, fmt.Errorf("failed to get branch information. Error: %w", err)
}
// If branch does not exist, we can continue
results = append(results, private.HookProcReceiveRefResult{
OriginalRef: opts.RefFullNames[i],
OldOID: opts.OldCommitIDs[i],
NewOID: opts.NewCommitIDs[i],
Err: "base-branch does not exist",
})
continue
}
if len(topicBranch) == 0 && len(currentTopicBranch) == 0 {

View File

@ -6,11 +6,56 @@ package agit
import (
"testing"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestParseAgitPushOptionValue(t *testing.T) {
assert.Equal(t, "a", parseAgitPushOptionValue("a"))
assert.Equal(t, "a", parseAgitPushOptionValue("{base64}YQ=="))
assert.Equal(t, "{base64}invalid value", parseAgitPushOptionValue("{base64}invalid value"))
}
func TestGetAgitBranchInfo(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
_, _, err := GetAgitBranchInfo(t.Context(), 1, "non-exist-basebranch")
assert.ErrorIs(t, err, util.ErrNotExist)
baseBranch, currentTopicBranch, err := GetAgitBranchInfo(t.Context(), 1, "master")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Empty(t, currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch", currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Empty(t, currentTopicBranch)
_, _, err = GetAgitBranchInfo(t.Context(), 1, "/")
assert.ErrorIs(t, err, util.ErrNotExist)
_, _, err = GetAgitBranchInfo(t.Context(), 1, "//")
assert.ErrorIs(t, err, util.ErrNotExist)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch/")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch/", currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch/1")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch/1", currentTopicBranch)
}

View File

@ -11,6 +11,7 @@ import (
"strings"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
pull_model "code.gitea.io/gitea/models/pull"
@ -207,7 +208,10 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
headBranchExist := pr.HeadRepo != nil && gitrepo.IsBranchExist(ctx, pr.HeadRepo, pr.HeadBranch)
headBranchExist := pr.HeadRepo != nil
if headBranchExist {
headBranchExist, _ = git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch)
}
if !headBranchExist {
log.Warn("Head branch of auto merge %-v does not exist [HeadRepoID: %d, Branch: %s]", pr, pr.HeadRepoID, pr.HeadBranch)
return

View File

@ -96,8 +96,12 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR
}
defer closer.Close()
if pr.Flow == issues_model.PullRequestFlowGithub && !gitrepo.IsBranchExist(ctx, pr.HeadRepo, pr.HeadBranch) {
return "", errors.New("Head branch does not exist, can not merge")
if pr.Flow == issues_model.PullRequestFlowGithub {
if exist, err := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch); err != nil {
return "", errors.Wrap(err, "IsBranchExist")
} else if !exist {
return "", errors.New("Head branch does not exist, can not merge")
}
}
if pr.Flow == issues_model.PullRequestFlowAGit && !gitrepo.IsReferenceExist(ctx, pr.HeadRepo, pr.GetGitHeadRefName()) {
return "", errors.New("Head branch does not exist, can not merge")

View File

@ -8,7 +8,6 @@ import (
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/gitrepo"
)
func CreateOrUpdateProtectedBranch(ctx context.Context, repo *repo_model.Repository,
@ -22,8 +21,7 @@ func CreateOrUpdateProtectedBranch(ctx context.Context, repo *repo_model.Reposit
isPlainRule := !git_model.IsRuleNameSpecial(protectBranch.RuleName)
var isBranchExist bool
if isPlainRule {
// TODO: read the database directly to check if the branch exists
isBranchExist = gitrepo.IsBranchExist(ctx, repo, protectBranch.RuleName)
isBranchExist, _ = git_model.IsBranchExist(ctx, repo.ID, protectBranch.RuleName)
}
if isBranchExist {

View File

@ -16,7 +16,6 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository"
)
@ -182,7 +181,7 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("fetch").AddArguments(fetchArgs...).AddDynamicArguments(remoteRepoName, headBranch+":"+trackingBranch)).
Run(ctx); err != nil {
cancel()
if !gitrepo.IsBranchExist(ctx, pr.HeadRepo, pr.HeadBranch) {
if exist, _ := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch); !exist {
return nil, nil, git_model.ErrBranchNotExist{
BranchName: pr.HeadBranch,
}

View File

@ -409,11 +409,11 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m
return "target_exist", nil
}
if gitrepo.IsBranchExist(ctx, repo, to) {
if exist, _ := git_model.IsBranchExist(ctx, repo.ID, to); exist {
return "target_exist", nil
}
if !gitrepo.IsBranchExist(ctx, repo, from) {
if exist, _ := git_model.IsBranchExist(ctx, repo.ID, from); !exist {
return "from_not_exist", nil
}
@ -624,7 +624,7 @@ func SetRepoDefaultBranch(ctx context.Context, repo *repo_model.Repository, newB
return nil
}
if !gitrepo.IsBranchExist(ctx, repo, newBranchName) {
if exist, _ := git_model.IsBranchExist(ctx, repo.ID, newBranchName); !exist {
return git_model.ErrBranchNotExist{
BranchName: newBranchName,
}

View File

@ -36,7 +36,6 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
copy_success: {{ctx.Locale.Tr "copy_success"}},
copy_error: {{ctx.Locale.Tr "copy_error"}},
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
network_error: {{ctx.Locale.Tr "error.network_error"}},
remove_label_str: {{ctx.Locale.Tr "remove_label_str"}},
modal_confirm: {{ctx.Locale.Tr "modal.confirm"}},
modal_cancel: {{ctx.Locale.Tr "modal.cancel"}},

View File

@ -6,6 +6,8 @@
{{template "shared/secrets/add_list" .}}
{{else if eq .PageType "variables"}}
{{template "shared/variables/variable_list" .}}
{{else if eq .PageType "general"}}
{{template "repo/settings/actions_general" .}}
{{end}}
</div>
{{template "repo/settings/layout_footer" .}}

View File

@ -0,0 +1,69 @@
<div class="repo-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.general.enable_actions"}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}/actions_unit" method="post">
{{.CsrfTokenHtml}}
{{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}}
{{$isActionsGlobalDisabled := ctx.Consts.RepoUnitTypeActions.UnitGlobalDisabled}}
<div class="inline field">
<label>{{ctx.Locale.Tr "actions.actions"}}</label>
<div class="ui checkbox{{if $isActionsGlobalDisabled}} disabled{{end}}"{{if $isActionsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_actions" type="checkbox" {{if $isActionsGlobalDisabled}}disabled{{end}} {{if $isActionsEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.actions_desc"}}</label>
</div>
</div>
{{if not $isActionsGlobalDisabled}}
<div class="divider"></div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
</div>
{{end}}
</form>
</div>
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}}
{{if .Repository.IsPrivate}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.general.collaborative_owners_management"}}
</h4>
{{if len .CollaborativeOwners}}
<div class="ui attached segment">
<div class="flex-list">
{{range .CollaborativeOwners}}
<div class="flex-item tw-items-center">
<div class="flex-item-leading">
<a href="{{.HomeLink}}">{{ctx.AvatarUtils.Avatar . 32}}</a>
</div>
<div class="flex-item-main">
<div class="flex-item-title">
{{template "shared/user/name" .}}
</div>
</div>
<div class="flex-item-trailing">
<button class="ui red tiny button inline link-action"
data-url="{{$.Link}}/collaborative_owner/delete?id={{.ID}}"
data-modal-confirm-header="{{ctx.Locale.Tr "actions.general.remove_collaborative_owner"}}"
data-modal-confirm-content="{{ctx.Locale.Tr "actions.general.remove_collaborative_owner_desc"}}"
>{{ctx.Locale.Tr "remove"}}</button>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
<div class="ui bottom attached segment">
<form class="ui form form-fetch-action" action="{{.Link}}/collaborative_owner/add" method="post">
{{.CsrfTokenHtml}}
<div id="search-user-box" class="ui search input tw-align-middle" data-include-orgs="true">
<input class="prompt" name="collaborative_owner" placeholder="{{ctx.Locale.Tr "search.user_kind"}}" autocomplete="off" autofocus required>
</div>
<button class="ui primary button">{{ctx.Locale.Tr "actions.general.add_collaborative_owner"}}</button>
</form>
<br>
{{ctx.Locale.Tr "actions.general.collaborative_owners_management_help"}}
</div>
{{end}}
{{end}}
</div>

View File

@ -38,10 +38,13 @@
</a>
{{end}}
{{end}}
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}}
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsActionsSettingsGeneral}}open{{end}}>
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
<div class="menu">
<a class="{{if .PageIsActionsSettingsGeneral}}active {{end}}item" href="{{.RepoLink}}/settings/actions/general">
{{ctx.Locale.Tr "actions.general"}}
</a>
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions)}}
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.RepoLink}}/settings/actions/runners">
{{ctx.Locale.Tr "actions.runners"}}
</a>
@ -51,8 +54,8 @@
<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.RepoLink}}/settings/actions/variables">
{{ctx.Locale.Tr "actions.variables"}}
</a>
{{end}}
</div>
</details>
{{end}}
</div>
</div>

View File

@ -509,18 +509,6 @@
</div>
</div>
{{if .EnableActions}}
{{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}}
{{$isActionsGlobalDisabled := ctx.Consts.RepoUnitTypeActions.UnitGlobalDisabled}}
<div class="inline field">
<label>{{ctx.Locale.Tr "actions.actions"}}</label>
<div class="ui checkbox{{if $isActionsGlobalDisabled}} disabled{{end}}"{{if $isActionsGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_actions" type="checkbox" {{if $isActionsEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.actions_desc"}}</label>
</div>
</div>
{{end}}
{{if not .IsMirror}}
<div class="divider"></div>
{{$pullRequestEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePullRequests}}

View File

@ -0,0 +1,62 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert"
)
func TestActionsCollaborativeOwner(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
// user2 is the owner of "reusable_workflow" repo
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
repo := createActionsTestRepo(t, user2Token, "reusable_workflow", true)
// a private repo(id=6) of user10 will try to clone "reusable_workflow" repo
user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
// task id is 55 and its repo_id=6
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 55, RepoID: 6})
taskToken := "674f727a81ed2f195bccab036cccf86a182199eb"
tokenHash := auth_model.HashToken(taskToken, task.TokenSalt)
assert.Equal(t, task.TokenHash, tokenHash)
dstPath := t.TempDir()
u.Path = fmt.Sprintf("%s/%s.git", repo.Owner.UserName, repo.Name)
u.User = url.UserPassword("gitea-actions", taskToken)
// the git clone will fail
doGitCloneFail(u)(t)
// add user10 to the list of collaborative owners
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", repo.Owner.UserName, repo.Name), map[string]string{
"_csrf": GetUserCSRFToken(t, user2Session),
"collaborative_owner": user10.Name,
})
user2Session.MakeRequest(t, req, http.StatusOK)
// the git clone will be successful
doGitClone(dstPath, u)(t)
// remove user10 from the list of collaborative owners
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/delete?id=%d", repo.Owner.UserName, repo.Name, user10.ID), map[string]string{
"_csrf": GetUserCSRFToken(t, user2Session),
})
user2Session.MakeRequest(t, req, http.StatusOK)
// the git clone will fail
doGitCloneFail(u)(t)
})
}

View File

@ -303,7 +303,7 @@ func TestAPICreateBranchWithSyncBranches(t *testing.T) {
RepoID: 1,
})
assert.NoError(t, err)
assert.Len(t, branches, 6)
assert.Len(t, branches, 8)
// make a broke repository with no branch on database
_, err = db.DeleteByBean(t.Context(), git_model.Branch{RepoID: 1})
@ -320,7 +320,7 @@ func TestAPICreateBranchWithSyncBranches(t *testing.T) {
RepoID: 1,
})
assert.NoError(t, err)
assert.Len(t, branches, 7)
assert.Len(t, branches, 9)
branches, err = db.Find[git_model.Branch](t.Context(), git_model.FindBranchOptions{
RepoID: 1,

View File

@ -2,62 +2,53 @@
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {getIssueColor, getIssueIcon} from '../features/issue.ts';
import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
import type {IssuePathInfo} from '../types.ts';
import {computed, onMounted, shallowRef} from 'vue';
const {appSubUrl, i18n} = window.config;
const props = defineProps<{
repoLink: string,
loadIssueInfoUrl: string,
}>();
const loading = shallowRef(false);
const issue = shallowRef(null);
const renderedLabels = shallowRef('');
const i18nErrorOccurred = i18n.error_occurred;
const i18nErrorMessage = shallowRef(null);
const errorMessage = shallowRef(null);
const createdAt = computed(() => {
return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
});
const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}));
const body = computed(() => {
const body = issue.value.body.replace(/\n+/g, ' ');
if (body.length > 85) {
return `${body.substring(0, 85)}`;
}
return body;
return body.length > 85 ? `${body.substring(0, 85)}` : body;
});
const root = useTemplateRef('root');
onMounted(() => {
root.value.addEventListener('ce-load-context-popup', (e: CustomEventInit<IssuePathInfo>) => {
if (!loading.value && issue.value === null) {
load(e.detail);
}
});
});
async function load(issuePathInfo: IssuePathInfo) {
onMounted(async () => {
loading.value = true;
i18nErrorMessage.value = null;
errorMessage.value = null;
try {
const response = await GET(`${appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`); // backend: GetIssueInfo
const respJson = await response.json();
if (!response.ok) {
i18nErrorMessage.value = respJson.message ?? i18n.network_error;
const resp = await GET(props.loadIssueInfoUrl);
if (!resp.ok) {
errorMessage.value = resp.status ? resp.statusText : 'Unknown network error';
return;
}
const respJson = await resp.json();
issue.value = respJson.convertedIssue;
renderedLabels.value = respJson.renderedLabels;
} catch {
i18nErrorMessage.value = i18n.network_error;
} finally {
loading.value = false;
}
}
});
</script>
<template>
<div ref="root">
<div class="tw-p-4">
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
<div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
<div v-else-if="issue" class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">
<a :href="repoLink" class="muted">{{ issue.repository.full_name }}</a>
on {{ createdAt }}
</div>
<div class="flex-text-block">
<svg-icon :name="getIssueIcon(issue)" :class="['text', getIssueColor(issue)]"/>
<span class="issue-title tw-font-semibold tw-break-anywhere">
@ -69,9 +60,8 @@ async function load(issuePathInfo: IssuePathInfo) {
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="issue.labels.length" v-html="renderedLabels"/>
</div>
<div class="tw-flex tw-flex-col tw-gap-2" v-if="!loading && issue === null">
<div class="tw-text-12">{{ i18nErrorOccurred }}</div>
<div>{{ i18nErrorMessage }}</div>
<div v-else>
{{ errorMessage }}
</div>
</div>
</template>

View File

@ -10,10 +10,11 @@ export function initCompSearchUserBox() {
const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true';
fomanticQuery(searchUserBox).search({
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}`,
url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`,
onResponse(response: any) {
const resultItems = [];
const searchQuery = searchUserBox.querySelector('input').value;

View File

@ -1,43 +0,0 @@
import {createApp} from 'vue';
import ContextPopup from '../components/ContextPopup.vue';
import {parseIssueHref} from '../utils.ts';
import {createTippy} from '../modules/tippy.ts';
export function initContextPopups() {
const refIssues = document.querySelectorAll<HTMLElement>('.ref-issue');
attachRefIssueContextPopup(refIssues);
}
export function attachRefIssueContextPopup(refIssues: NodeListOf<HTMLElement>) {
for (const refIssue of refIssues) {
if (refIssue.classList.contains('ref-external-issue')) continue;
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href'));
if (!issuePathInfo.ownerName) continue;
const el = document.createElement('div');
el.classList.add('tw-p-3');
refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
const view = createApp(ContextPopup);
try {
view.mount(el);
} catch (err) {
console.error(err);
el.textContent = 'ContextPopup failed to load';
}
createTippy(refIssue, {
theme: 'default',
content: el,
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
onShow: () => {
el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: issuePathInfo}));
},
});
}
}

View File

@ -12,8 +12,6 @@ import {invertFileFolding} from './file-fold.ts';
import {parseDom, sleep} from '../utils.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
const {i18n} = window.config;
function initRepoDiffFileBox(el: HTMLElement) {
// switch between "rendered" and "source", for image and CSV files
queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => {
@ -86,7 +84,7 @@ function initRepoDiffConversationForm() {
}
} catch (error) {
console.error('Error:', error);
showErrorToast(i18n.network_error);
showErrorToast(`Submit form failed: ${error}`);
} finally {
form?.classList.remove('is-loading');
}

View File

@ -1,7 +1,6 @@
import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
@ -199,5 +198,4 @@ export function initRepoEditor() {
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
// the content is from the server, so it is safe to use innerHTML
previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`;
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
}

View File

@ -3,7 +3,6 @@ import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} fr
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {hideElem, querySingleVisibleElem, showElem, type DOMEvent} from '../utils/dom.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
@ -62,8 +61,6 @@ async function tryOnEditContent(e: DOMEvent<MouseEvent>) {
renderContent = newRenderContent;
rawContent.textContent = comboMarkdownEditor.value();
const refIssues = renderContent.querySelectorAll<HTMLElement>('p .ref-issue');
attachRefIssueContextPopup(refIssues);
if (!commentContent.querySelector('.dropzone-attachments')) {
if (data.attachments !== '') {

View File

@ -5,7 +5,6 @@ import '../../node_modules/easymde/dist/easymde.min.css'; // TODO: lazy load in
import {initHtmx} from './htmx.ts';
import {initDashboardRepoList} from './features/dashboard.ts';
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
import {initContextPopups} from './features/contextpopup.ts';
import {initRepoGraphGit} from './features/repo-graph.ts';
import {initHeatmap} from './features/heatmap.ts';
import {initImageDiff} from './features/imagediff.ts';
@ -97,7 +96,6 @@ const initPerformanceTracer = callInitFunctions([
initHeadNavbarContentToggle,
initFootLanguageMenu,
initContextPopups,
initHeatmap,
initImageDiff,
initMarkupAnchors,

View File

@ -5,6 +5,7 @@ import {initMarkupRenderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initMarkupRenderIframe} from './render-iframe.ts';
import {initMarkupRefIssue} from './refissue.ts';
// code that runs for all markup content
export function initMarkupContent(): void {
@ -15,5 +16,6 @@ export function initMarkupContent(): void {
initMarkupCodeMath(el);
initMarkupRenderAsciicast(el);
initMarkupRenderIframe(el);
initMarkupRefIssue(el);
});
}

View File

@ -0,0 +1,41 @@
import {queryElems} from '../utils/dom.ts';
import {parseIssueHref} from '../utils.ts';
import {createApp} from 'vue';
import ContextPopup from '../components/ContextPopup.vue';
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
export function initMarkupRefIssue(el: HTMLElement) {
queryElems(el, '.ref-issue', (el) => {
el.addEventListener('mouseenter', showMarkupRefIssuePopup);
el.addEventListener('focus', showMarkupRefIssuePopup);
});
}
export function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
const refIssue = e.currentTarget as HTMLElement;
if (getAttachedTippyInstance(refIssue)) return;
if (refIssue.classList.contains('ref-external-issue')) return;
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href'));
if (!issuePathInfo.ownerName) return;
const el = document.createElement('div');
const tippy = createTippy(refIssue, {
theme: 'default',
content: el,
trigger: 'mouseenter focus',
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
// onHide() { return false }, // help to keep the popup and debug the layout
onShow: () => {
const view = createApp(ContextPopup, {
// backend: GetIssueInfo
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
});
view.mount(el);
},
});
tippy.show();
}

View File

@ -209,3 +209,7 @@ export function showTemporaryTooltip(target: Element, content: Content): void {
},
});
}
export function getAttachedTippyInstance(el: Element): Instance | null {
return el._tippy ?? null;
}