Merge branch 'main' into patch-18

This commit is contained in:
Kerwin Bryant 2024-11-28 15:06:28 +08:00 committed by GitHub
commit f9c54b8bf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
69 changed files with 2380 additions and 863 deletions

View File

@ -34,6 +34,7 @@ type ProtectedBranch struct {
RepoID int64 `xorm:"UNIQUE(s)"`
Repo *repo_model.Repository `xorm:"-"`
RuleName string `xorm:"'branch_name' UNIQUE(s)"` // a branch name or a glob match to branch name
Priority int64 `xorm:"NOT NULL DEFAULT 0"`
globRule glob.Glob `xorm:"-"`
isPlainName bool `xorm:"-"`
CanPush bool `xorm:"NOT NULL DEFAULT false"`
@ -413,14 +414,27 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
}
protectBranch.ApprovalsWhitelistTeamIDs = whitelist
// Make sure protectBranch.ID is not 0 for whitelists
// Looks like it's a new rule
if protectBranch.ID == 0 {
// as it's a new rule and if priority was not set, we need to calc it.
if protectBranch.Priority == 0 {
var lowestPrio int64
// because of mssql we can not use builder or save xorm syntax, so raw sql it is
if _, err := db.GetEngine(ctx).SQL(`SELECT MAX(priority) FROM protected_branch WHERE repo_id = ?`, protectBranch.RepoID).
Get(&lowestPrio); err != nil {
return err
}
log.Trace("Create new ProtectedBranch at repo[%d] and detect current lowest priority '%d'", protectBranch.RepoID, lowestPrio)
protectBranch.Priority = lowestPrio + 1
}
if _, err = db.GetEngine(ctx).Insert(protectBranch); err != nil {
return fmt.Errorf("Insert: %v", err)
}
return nil
}
// update the rule
if _, err = db.GetEngine(ctx).ID(protectBranch.ID).AllCols().Update(protectBranch); err != nil {
return fmt.Errorf("Update: %v", err)
}
@ -428,6 +442,24 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
return nil
}
func UpdateProtectBranchPriorities(ctx context.Context, repo *repo_model.Repository, ids []int64) error {
prio := int64(1)
return db.WithTx(ctx, func(ctx context.Context) error {
for _, id := range ids {
if _, err := db.GetEngine(ctx).
ID(id).Where("repo_id = ?", repo.ID).
Cols("priority").
Update(&ProtectedBranch{
Priority: prio,
}); err != nil {
return err
}
prio++
}
return nil
})
}
// updateApprovalWhitelist checks whether the user whitelist changed and returns a whitelist with
// the users from newWhitelist which have explicit read or write access to the repo.
func updateApprovalWhitelist(ctx context.Context, repo *repo_model.Repository, currentWhitelist, newWhitelist []int64) (whitelist []int64, err error) {

View File

@ -28,6 +28,13 @@ func (rules ProtectedBranchRules) sort() {
sort.Slice(rules, func(i, j int) bool {
rules[i].loadGlob()
rules[j].loadGlob()
// if priority differ, use that to sort
if rules[i].Priority != rules[j].Priority {
return rules[i].Priority < rules[j].Priority
}
// now we sort the old way
if rules[i].isPlainName != rules[j].isPlainName {
return rules[i].isPlainName // plain name comes first, so plain name means "less"
}

View File

@ -75,7 +75,7 @@ func TestBranchRuleMatchPriority(t *testing.T) {
}
}
func TestBranchRuleSort(t *testing.T) {
func TestBranchRuleSortLegacy(t *testing.T) {
in := []*ProtectedBranch{{
RuleName: "b",
CreatedUnix: 1,
@ -103,3 +103,37 @@ func TestBranchRuleSort(t *testing.T) {
}
assert.Equal(t, expect, got)
}
func TestBranchRuleSortPriority(t *testing.T) {
in := []*ProtectedBranch{{
RuleName: "b",
CreatedUnix: 1,
Priority: 4,
}, {
RuleName: "b/*",
CreatedUnix: 3,
Priority: 2,
}, {
RuleName: "a/*",
CreatedUnix: 2,
Priority: 1,
}, {
RuleName: "c",
CreatedUnix: 0,
Priority: 0,
}, {
RuleName: "a",
CreatedUnix: 4,
Priority: 3,
}}
expect := []string{"c", "a/*", "b/*", "a", "b"}
pbr := ProtectedBranchRules(in)
pbr.sort()
var got []string
for i := range pbr {
got = append(got, pbr[i].RuleName)
}
assert.Equal(t, expect, got)
}

View File

@ -7,6 +7,10 @@ import (
"fmt"
"testing"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
)
@ -76,3 +80,77 @@ func TestBranchRuleMatch(t *testing.T) {
)
}
}
func TestUpdateProtectBranchPriorities(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create some test protected branches with initial priorities
protectedBranches := []*ProtectedBranch{
{
RepoID: repo.ID,
RuleName: "master",
Priority: 1,
},
{
RepoID: repo.ID,
RuleName: "develop",
Priority: 2,
},
{
RepoID: repo.ID,
RuleName: "feature/*",
Priority: 3,
},
}
for _, pb := range protectedBranches {
_, err := db.GetEngine(db.DefaultContext).Insert(pb)
assert.NoError(t, err)
}
// Test updating priorities
newPriorities := []int64{protectedBranches[2].ID, protectedBranches[0].ID, protectedBranches[1].ID}
err := UpdateProtectBranchPriorities(db.DefaultContext, repo, newPriorities)
assert.NoError(t, err)
// Verify new priorities
pbs, err := FindRepoProtectedBranchRules(db.DefaultContext, repo.ID)
assert.NoError(t, err)
expectedPriorities := map[string]int64{
"feature/*": 1,
"master": 2,
"develop": 3,
}
for _, pb := range pbs {
assert.Equal(t, expectedPriorities[pb.RuleName], pb.Priority)
}
}
func TestNewProtectBranchPriority(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
err := UpdateProtectBranch(db.DefaultContext, repo, &ProtectedBranch{
RepoID: repo.ID,
RuleName: "branch-1",
Priority: 1,
}, WhitelistOptions{})
assert.NoError(t, err)
newPB := &ProtectedBranch{
RepoID: repo.ID,
RuleName: "branch-2",
// Priority intentionally omitted
}
err = UpdateProtectBranch(db.DefaultContext, repo, newPB, WhitelistOptions{})
assert.NoError(t, err)
savedPB2, err := GetFirstMatchProtectedBranchRule(db.DefaultContext, repo.ID, "branch-2")
assert.NoError(t, err)
assert.Equal(t, int64(2), savedPB2.Priority)
}

View File

@ -367,6 +367,7 @@ func prepareMigrationTasks() []*migration {
newMigration(307, "Fix milestone deadline_unix when there is no due date", v1_23.FixMilestoneNoDueDate),
newMigration(308, "Add index(user_id, is_deleted) for action table", v1_23.AddNewIndexForUserDashboard),
newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices),
newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch),
}
return preparedMigrations
}

View File

@ -0,0 +1,16 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_23 //nolint
import (
"xorm.io/xorm"
)
func AddPriorityToProtectedBranch(x *xorm.Engine) error {
type ProtectedBranch struct {
Priority int64 `xorm:"NOT NULL DEFAULT 0"`
}
return x.Sync(new(ProtectedBranch))
}

View File

@ -197,3 +197,8 @@ func TestUsersInTeamsCount(t *testing.T) {
test([]int64{1, 2, 3, 4, 5}, []int64{2, 5}, 2) // userid 2,4
test([]int64{1, 2, 3, 4, 5}, []int64{2, 3, 5}, 3) // userid 2,4,5
}
func TestIsUsableTeamName(t *testing.T) {
assert.NoError(t, organization.IsUsableTeamName("usable"))
assert.True(t, db.IsErrNameReserved(organization.IsUsableTeamName("new")))
}

View File

@ -25,6 +25,7 @@ type BranchProtection struct {
// Deprecated: true
BranchName string `json:"branch_name"`
RuleName string `json:"rule_name"`
Priority int64 `json:"priority"`
EnablePush bool `json:"enable_push"`
EnablePushWhitelist bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
@ -64,6 +65,7 @@ type CreateBranchProtectionOption struct {
// Deprecated: true
BranchName string `json:"branch_name"`
RuleName string `json:"rule_name"`
Priority int64 `json:"priority"`
EnablePush bool `json:"enable_push"`
EnablePushWhitelist bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
@ -96,6 +98,7 @@ type CreateBranchProtectionOption struct {
// EditBranchProtectionOption options for editing a branch protection
type EditBranchProtectionOption struct {
Priority *int64 `json:"priority"`
EnablePush *bool `json:"enable_push"`
EnablePushWhitelist *bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
@ -125,3 +128,8 @@ type EditBranchProtectionOption struct {
UnprotectedFilePatterns *string `json:"unprotected_file_patterns"`
BlockAdminMergeOverride *bool `json:"block_admin_merge_override"`
}
// UpdateBranchProtectionPriories a list to update the branch protection rule priorities
type UpdateBranchProtectionPriories struct {
IDs []int64 `json:"ids"`
}

View File

@ -41,6 +41,8 @@ func SplitStringAtByteN(input string, n int) (left, right string) {
// SplitTrimSpace splits the string at given separator and trims leading and trailing space
func SplitTrimSpace(input, sep string) []string {
// Trim initial leading & trailing space
input = strings.TrimSpace(input)
// replace CRLF with LF
input = strings.ReplaceAll(input, "\r\n", "\n")

View File

@ -10,6 +10,7 @@ import (
"code.gitea.io/gitea/modules/auth"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util"
"gitea.com/go-chi/binding"
"github.com/gobwas/glob"
@ -31,6 +32,7 @@ const (
// AddBindingRules adds additional binding rules
func AddBindingRules() {
addGitRefNameBindingRule()
addValidURLListBindingRule()
addValidURLBindingRule()
addValidSiteURLBindingRule()
addGlobPatternRule()
@ -44,7 +46,7 @@ func addGitRefNameBindingRule() {
// Git refname validation rule
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return strings.HasPrefix(rule, "GitRefName")
return rule == "GitRefName"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
@ -58,11 +60,38 @@ func addGitRefNameBindingRule() {
})
}
func addValidURLListBindingRule() {
// URL validation rule
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "ValidUrlList"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if len(str) == 0 {
errs.Add([]string{name}, binding.ERR_URL, "Url")
return false, errs
}
ok := true
urls := util.SplitTrimSpace(str, "\n")
for _, u := range urls {
if !IsValidURL(u) {
ok = false
errs.Add([]string{name}, binding.ERR_URL, u)
}
}
return ok, errs
},
})
}
func addValidURLBindingRule() {
// URL validation rule
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return strings.HasPrefix(rule, "ValidUrl")
return rule == "ValidUrl"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
@ -80,7 +109,7 @@ func addValidSiteURLBindingRule() {
// URL validation rule
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return strings.HasPrefix(rule, "ValidSiteUrl")
return rule == "ValidSiteUrl"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
@ -171,7 +200,7 @@ func addUsernamePatternRule() {
func addValidGroupTeamMapRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return strings.HasPrefix(rule, "ValidGroupTeamMap")
return rule == "ValidGroupTeamMap"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
_, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val))

View File

@ -27,6 +27,7 @@ type (
TestForm struct {
BranchName string `form:"BranchName" binding:"GitRefName"`
URL string `form:"ValidUrl" binding:"ValidUrl"`
URLs string `form:"ValidUrls" binding:"ValidUrlList"`
GlobPattern string `form:"GlobPattern" binding:"GlobPattern"`
RegexPattern string `form:"RegexPattern" binding:"RegexPattern"`
}

View File

@ -0,0 +1,157 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package validation
import (
"testing"
"gitea.com/go-chi/binding"
)
// This is a copy of all the URL tests cases, plus additional ones to
// account for multiple URLs
var urlListValidationTestCases = []validationTestCase{
{
description: "Empty URL",
data: TestForm{
URLs: "",
},
expectedErrors: binding.Errors{},
},
{
description: "URL without port",
data: TestForm{
URLs: "http://test.lan/",
},
expectedErrors: binding.Errors{},
},
{
description: "URL with port",
data: TestForm{
URLs: "http://test.lan:3000/",
},
expectedErrors: binding.Errors{},
},
{
description: "URL with IPv6 address without port",
data: TestForm{
URLs: "http://[::1]/",
},
expectedErrors: binding.Errors{},
},
{
description: "URL with IPv6 address with port",
data: TestForm{
URLs: "http://[::1]:3000/",
},
expectedErrors: binding.Errors{},
},
{
description: "Invalid URL",
data: TestForm{
URLs: "http//test.lan/",
},
expectedErrors: binding.Errors{
binding.Error{
FieldNames: []string{"URLs"},
Classification: binding.ERR_URL,
Message: "http//test.lan/",
},
},
},
{
description: "Invalid schema",
data: TestForm{
URLs: "ftp://test.lan/",
},
expectedErrors: binding.Errors{
binding.Error{
FieldNames: []string{"URLs"},
Classification: binding.ERR_URL,
Message: "ftp://test.lan/",
},
},
},
{
description: "Invalid port",
data: TestForm{
URLs: "http://test.lan:3x4/",
},
expectedErrors: binding.Errors{
binding.Error{
FieldNames: []string{"URLs"},
Classification: binding.ERR_URL,
Message: "http://test.lan:3x4/",
},
},
},
{
description: "Invalid port with IPv6 address",
data: TestForm{
URLs: "http://[::1]:3x4/",
},
expectedErrors: binding.Errors{
binding.Error{
FieldNames: []string{"URLs"},
Classification: binding.ERR_URL,
Message: "http://[::1]:3x4/",
},
},
},
{
description: "Multi URLs",
data: TestForm{
URLs: "http://test.lan:3000/\nhttp://test.local/",
},
expectedErrors: binding.Errors{},
},
{
description: "Multi URLs with newline",
data: TestForm{
URLs: "http://test.lan:3000/\nhttp://test.local/\n",
},
expectedErrors: binding.Errors{},
},
{
description: "List with invalid entry",
data: TestForm{
URLs: "http://test.lan:3000/\nhttp://[::1]:3x4/",
},
expectedErrors: binding.Errors{
binding.Error{
FieldNames: []string{"URLs"},
Classification: binding.ERR_URL,
Message: "http://[::1]:3x4/",
},
},
},
{
description: "List with two invalid entries",
data: TestForm{
URLs: "ftp://test.lan:3000/\nhttp://[::1]:3x4/\n",
},
expectedErrors: binding.Errors{
binding.Error{
FieldNames: []string{"URLs"},
Classification: binding.ERR_URL,
Message: "ftp://test.lan:3000/",
},
binding.Error{
FieldNames: []string{"URLs"},
Classification: binding.ERR_URL,
Message: "http://[::1]:3x4/",
},
},
},
}
func Test_ValidURLListValidation(t *testing.T) {
AddBindingRules()
for _, testCase := range urlListValidationTestCases {
t.Run(testCase.description, func(t *testing.T) {
performValidationTest(t, testCase)
})
}
}

View File

@ -765,6 +765,7 @@ uploaded_avatar_not_a_image = The uploaded file is not an image.
uploaded_avatar_is_too_big = The uploaded file size (%d KiB) exceeds the maximum size (%d KiB).
update_avatar_success = Your avatar has been updated.
update_user_avatar_success = The user's avatar has been updated.
cropper_prompt = You can edit the image before saving. The edited image will be saved as PNG.
change_password = Update Password
old_password = Current Password

7
package-lock.json generated
View File

@ -22,6 +22,7 @@
"chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.0.1",
"clippie": "4.1.3",
"cropperjs": "1.6.2",
"css-loader": "7.1.2",
"dayjs": "1.11.13",
"dropzone": "6.0.0-beta.2",
@ -6876,6 +6877,12 @@
}
}
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",

View File

@ -21,6 +21,7 @@
"chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.0.1",
"clippie": "4.1.3",
"cropperjs": "1.6.2",
"css-loader": "7.1.2",
"dayjs": "1.11.13",
"dropzone": "6.0.0-beta.2",

View File

@ -1204,6 +1204,7 @@ func Routes() *web.Router {
m.Patch("", bind(api.EditBranchProtectionOption{}), mustNotBeArchived, repo.EditBranchProtection)
m.Delete("", repo.DeleteBranchProtection)
})
m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories)
}, reqToken(), reqAdmin())
m.Group("/tags", func() {
m.Get("", repo.ListTags)

View File

@ -7,7 +7,6 @@ import (
"net/http"
"net/url"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
@ -15,6 +14,7 @@ import (
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
org_service "code.gitea.io/gitea/services/org"
)
// listMembers list an organization's members
@ -322,7 +322,7 @@ func DeleteMember(ctx *context.APIContext) {
if ctx.Written() {
return
}
if err := models.RemoveOrgUser(ctx, ctx.Org.Organization, member); err != nil {
if err := org_service.RemoveOrgUser(ctx, ctx.Org.Organization, member); err != nil {
ctx.Error(http.StatusInternalServerError, "RemoveOrgUser", err)
}
ctx.Status(http.StatusNoContent)

View File

@ -8,7 +8,6 @@ import (
"errors"
"net/http"
"code.gitea.io/gitea/models"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
@ -240,7 +239,7 @@ func CreateTeam(ctx *context.APIContext) {
attachAdminTeamUnits(team)
}
if err := models.NewTeam(ctx, team); err != nil {
if err := org_service.NewTeam(ctx, team); err != nil {
if organization.IsErrTeamAlreadyExist(err) {
ctx.Error(http.StatusUnprocessableEntity, "", err)
} else {
@ -331,7 +330,7 @@ func EditTeam(ctx *context.APIContext) {
attachAdminTeamUnits(team)
}
if err := models.UpdateTeam(ctx, team, isAuthChanged, isIncludeAllChanged); err != nil {
if err := org_service.UpdateTeam(ctx, team, isAuthChanged, isIncludeAllChanged); err != nil {
ctx.Error(http.StatusInternalServerError, "EditTeam", err)
return
}
@ -362,7 +361,7 @@ func DeleteTeam(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil {
if err := org_service.DeleteTeam(ctx, ctx.Org.Team); err != nil {
ctx.Error(http.StatusInternalServerError, "DeleteTeam", err)
return
}
@ -496,7 +495,7 @@ func AddTeamMember(ctx *context.APIContext) {
if ctx.Written() {
return
}
if err := models.AddTeamMember(ctx, ctx.Org.Team, u); err != nil {
if err := org_service.AddTeamMember(ctx, ctx.Org.Team, u); err != nil {
if errors.Is(err, user_model.ErrBlockedUser) {
ctx.Error(http.StatusForbidden, "AddTeamMember", err)
} else {
@ -537,7 +536,7 @@ func RemoveTeamMember(ctx *context.APIContext) {
return
}
if err := models.RemoveTeamMember(ctx, ctx.Org.Team, u); err != nil {
if err := org_service.RemoveTeamMember(ctx, ctx.Org.Team, u); err != nil {
ctx.Error(http.StatusInternalServerError, "RemoveTeamMember", err)
return
}
@ -700,7 +699,7 @@ func AddTeamRepository(ctx *context.APIContext) {
ctx.Error(http.StatusForbidden, "", "Must have admin-level access to the repository")
return
}
if err := org_service.TeamAddRepository(ctx, ctx.Org.Team, repo); err != nil {
if err := repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo); err != nil {
ctx.Error(http.StatusInternalServerError, "TeamAddRepository", err)
return
}

View File

@ -618,6 +618,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
protectBranch = &git_model.ProtectedBranch{
RepoID: ctx.Repo.Repository.ID,
RuleName: ruleName,
Priority: form.Priority,
CanPush: form.EnablePush,
EnableWhitelist: form.EnablePush && form.EnablePushWhitelist,
WhitelistDeployKeys: form.EnablePush && form.EnablePushWhitelist && form.PushWhitelistDeployKeys,
@ -640,7 +641,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
BlockAdminMergeOverride: form.BlockAdminMergeOverride,
}
err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
if err := git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
UserIDs: whitelistUsers,
TeamIDs: whitelistTeams,
ForcePushUserIDs: forcePushAllowlistUsers,
@ -649,14 +650,13 @@ func CreateBranchProtection(ctx *context.APIContext) {
MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers,
ApprovalsTeamIDs: approvalsWhitelistTeams,
})
if err != nil {
}); err != nil {
ctx.Error(http.StatusInternalServerError, "UpdateProtectBranch", err)
return
}
if isBranchExist {
if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, ruleName); err != nil {
if err := pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, ruleName); err != nil {
ctx.Error(http.StatusInternalServerError, "CheckPRsForBaseBranch", err)
return
}
@ -796,6 +796,10 @@ func EditBranchProtection(ctx *context.APIContext) {
}
}
if form.Priority != nil {
protectBranch.Priority = *form.Priority
}
if form.EnableMergeWhitelist != nil {
protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist
}
@ -1080,3 +1084,47 @@ func DeleteBranchProtection(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
// UpdateBranchProtectionPriories updates the priorities of branch protections for a repo
func UpdateBranchProtectionPriories(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/branch_protections/priority repository repoUpdateBranchProtectionPriories
// ---
// summary: Update the priorities of branch protections for a repository.
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/UpdateBranchProtectionPriories"
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
// "423":
// "$ref": "#/responses/repoArchivedError"
form := web.GetForm(ctx).(*api.UpdateBranchProtectionPriories)
repo := ctx.Repo.Repository
if err := git_model.UpdateProtectBranchPriorities(ctx, repo, form.IDs); err != nil {
ctx.Error(http.StatusInternalServerError, "UpdateProtectBranchPriorities", err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@ -10,7 +10,6 @@ import (
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
org_service "code.gitea.io/gitea/services/org"
repo_service "code.gitea.io/gitea/services/repository"
)
@ -205,7 +204,7 @@ func changeRepoTeam(ctx *context.APIContext, add bool) {
ctx.Error(http.StatusUnprocessableEntity, "alreadyAdded", fmt.Errorf("team '%s' is already added to repo", team.Name))
return
}
err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository)
err = repo_service.TeamAddRepository(ctx, team, ctx.Repo.Repository)
} else {
if !repoHasTeam {
ctx.Error(http.StatusUnprocessableEntity, "notAdded", fmt.Errorf("team '%s' was not added to repo", team.Name))

View File

@ -146,6 +146,9 @@ type swaggerParameterBodies struct {
// in:body
EditBranchProtectionOption api.EditBranchProtectionOption
// in:body
UpdateBranchProtectionPriories api.UpdateBranchProtectionPriories
// in:body
CreateOAuth2ApplicationOptions api.CreateOAuth2ApplicationOptions

View File

@ -7,7 +7,6 @@ package org
import (
"net/http"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
@ -15,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/setting"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
org_service "code.gitea.io/gitea/services/org"
)
const (
@ -108,14 +108,14 @@ func MembersAction(ctx *context.Context) {
ctx.Error(http.StatusNotFound)
return
}
err = models.RemoveOrgUser(ctx, org, member)
err = org_service.RemoveOrgUser(ctx, org, member)
if organization.IsErrLastOrgOwner(err) {
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
ctx.JSONRedirect(ctx.Org.OrgLink + "/members")
return
}
case "leave":
err = models.RemoveOrgUser(ctx, org, ctx.Doer)
err = org_service.RemoveOrgUser(ctx, org, ctx.Doer)
if err == nil {
ctx.Flash.Success(ctx.Tr("form.organization_leave_success", org.DisplayName()))
ctx.JSON(http.StatusOK, map[string]any{

View File

@ -13,7 +13,6 @@ import (
"strconv"
"strings"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
@ -78,9 +77,9 @@ func TeamsAction(ctx *context.Context) {
ctx.Error(http.StatusNotFound)
return
}
err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer)
err = org_service.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer)
case "leave":
err = models.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer)
err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer)
if err != nil {
if org_model.IsErrLastOrgOwner(err) {
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
@ -107,7 +106,7 @@ func TeamsAction(ctx *context.Context) {
return
}
err = models.RemoveTeamMember(ctx, ctx.Org.Team, user)
err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, user)
if err != nil {
if org_model.IsErrLastOrgOwner(err) {
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
@ -162,7 +161,7 @@ func TeamsAction(ctx *context.Context) {
if ctx.Org.Team.IsMember(ctx, u.ID) {
ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users"))
} else {
err = models.AddTeamMember(ctx, ctx.Org.Team, u)
err = org_service.AddTeamMember(ctx, ctx.Org.Team, u)
}
page = "team"
@ -249,13 +248,13 @@ func TeamsRepoAction(ctx *context.Context) {
ctx.ServerError("GetRepositoryByName", err)
return
}
err = org_service.TeamAddRepository(ctx, ctx.Org.Team, repo)
err = repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo)
case "remove":
err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, ctx.FormInt64("repoid"))
case "addall":
err = models.AddAllRepositories(ctx, ctx.Org.Team)
err = repo_service.AddAllRepositoriesToTeam(ctx, ctx.Org.Team)
case "removeall":
err = models.RemoveAllRepositories(ctx, ctx.Org.Team)
err = repo_service.RemoveAllRepositoriesFromTeam(ctx, ctx.Org.Team)
}
if err != nil {
@ -358,7 +357,7 @@ func NewTeamPost(ctx *context.Context) {
return
}
if err := models.NewTeam(ctx, t); err != nil {
if err := org_service.NewTeam(ctx, t); err != nil {
ctx.Data["Err_TeamName"] = true
switch {
case org_model.IsErrTeamAlreadyExist(err):
@ -536,7 +535,7 @@ func EditTeamPost(ctx *context.Context) {
return
}
if err := models.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil {
if err := org_service.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil {
ctx.Data["Err_TeamName"] = true
switch {
case org_model.IsErrTeamAlreadyExist(err):
@ -551,7 +550,7 @@ func EditTeamPost(ctx *context.Context) {
// DeleteTeam response for the delete team request
func DeleteTeam(ctx *context.Context) {
if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil {
if err := org_service.DeleteTeam(ctx, ctx.Org.Team); err != nil {
ctx.Flash.Error("DeleteTeam: " + err.Error())
} else {
ctx.Flash.Success(ctx.Tr("org.teams.delete_team_success"))
@ -593,7 +592,7 @@ func TeamInvitePost(ctx *context.Context) {
return
}
if err := models.AddTeamMember(ctx, team, ctx.Doer); err != nil {
if err := org_service.AddTeamMember(ctx, team, ctx.Doer); err != nil {
ctx.ServerError("AddTeamMember", err)
return
}

View File

@ -17,7 +17,6 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/mailer"
org_service "code.gitea.io/gitea/services/org"
repo_service "code.gitea.io/gitea/services/repository"
)
@ -185,7 +184,7 @@ func AddTeamPost(ctx *context.Context) {
return
}
if err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil {
if err = repo_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil {
ctx.ServerError("TeamAddRepository", err)
return
}

View File

@ -322,6 +322,16 @@ func DeleteProtectedBranchRulePost(ctx *context.Context) {
ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink))
}
func UpdateBranchProtectionPriories(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.ProtectBranchPriorityForm)
repo := ctx.Repo.Repository
if err := git_model.UpdateProtectBranchPriorities(ctx, repo, form.IDs); err != nil {
ctx.ServerError("UpdateProtectBranchPriorities", err)
return
}
}
// RenameBranchPost responses for rename a branch
func RenameBranchPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.RenameBranchForm)

View File

@ -47,7 +47,6 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
return
}
// TODO validate redirect URI
app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
Name: form.Name,
RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
@ -96,11 +95,25 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
if ctx.HasError() {
app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id"))
if err != nil {
if auth.IsErrOAuthApplicationNotFound(err) {
ctx.NotFound("Application not found", err)
return
}
ctx.ServerError("GetOAuth2ApplicationByID", err)
return
}
if app.UID != oa.OwnerID {
ctx.NotFound("Application not found", nil)
return
}
ctx.Data["App"] = app
oa.renderEditPage(ctx)
return
}
// TODO validate redirect URI
var err error
if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{
ID: ctx.PathParamInt64("id"),

View File

@ -1081,6 +1081,7 @@ func registerRoutes(m *web.Router) {
m.Combo("/edit").Get(repo_setting.SettingsProtectedBranch).
Post(web.Bind(forms.ProtectBranchForm{}), context.RepoMustNotBeArchived(), repo_setting.SettingsProtectedBranchPost)
m.Post("/{id}/delete", repo_setting.DeleteProtectedBranchRulePost)
m.Post("/priority", web.Bind(forms.ProtectBranchPriorityForm{}), context.RepoMustNotBeArchived(), repo_setting.UpdateBranchProtectionPriories)
})
m.Group("/tags", func() {

View File

@ -7,11 +7,11 @@ import (
"context"
"fmt"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
org_service "code.gitea.io/gitea/services/org"
)
type syncType int
@ -100,12 +100,12 @@ func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeam
}
if action == syncAdd && !isMember {
if err := models.AddTeamMember(ctx, team, user); err != nil {
if err := org_service.AddTeamMember(ctx, team, user); err != nil {
log.Error("group sync: Could not add user to team: %v", err)
return err
}
} else if action == syncRemove && isMember {
if err := models.RemoveTeamMember(ctx, team, user); err != nil {
if err := org_service.RemoveTeamMember(ctx, team, user); err != nil {
log.Error("group sync: Could not remove user from team: %v", err)
return err
}

View File

@ -158,6 +158,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
return &api.BranchProtection{
BranchName: branchName,
RuleName: bp.RuleName,
Priority: bp.Priority,
EnablePush: bp.CanPush,
EnablePushWhitelist: bp.EnableWhitelist,
PushWhitelistUsernames: pushWhitelistUsernames,

View File

@ -6,11 +6,11 @@ package doctor
import (
"context"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/modules/log"
org_service "code.gitea.io/gitea/services/org"
"xorm.io/builder"
)
@ -29,7 +29,7 @@ func fixOwnerTeamCreateOrgRepo(ctx context.Context, logger log.Logger, autofix b
return nil
}
return models.UpdateTeam(ctx, team, false, false)
return org_service.UpdateTeam(ctx, team, false, false)
},
)
if err != nil {

View File

@ -228,6 +228,10 @@ func (f *ProtectBranchForm) Validate(req *http.Request, errs binding.Errors) bin
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
type ProtectBranchPriorityForm struct {
IDs []int64
}
// __ __ ___. .__ __
// / \ / \ ____\_ |__ | |__ ____ ____ | | __
// \ \/\/ // __ \| __ \| | \ / _ \ / _ \| |/ /

View File

@ -366,7 +366,7 @@ func (f *NewAccessTokenForm) GetScope() (auth_model.AccessTokenScope, error) {
// EditOAuth2ApplicationForm form for editing oauth2 applications
type EditOAuth2ApplicationForm struct {
Name string `binding:"Required;MaxSize(255)" form:"application_name"`
RedirectURIs string `binding:"Required" form:"redirect_uris"`
RedirectURIs string `binding:"Required;ValidUrlList" form:"redirect_uris"`
ConfidentialClient bool `form:"confidential_client"`
SkipSecondaryAuthorization bool `form:"skip_secondary_authorization"`
}

View File

@ -1,27 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"context"
"errors"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo"
)
// TeamAddRepository adds new repository to team of organization.
func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
if repo.OwnerID != t.OrgID {
return errors.New("repository does not belong to organization")
} else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
return models.AddRepository(ctx, t, repo)
})
}

View File

@ -1,8 +1,7 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2016 The Gogs Authors. All rights reserved.
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package models
package org
import (
"context"
@ -19,138 +18,11 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
repo_service "code.gitea.io/gitea/services/repository"
"xorm.io/builder"
)
func AddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
if err = organization.AddTeamRepo(ctx, t.OrgID, t.ID, repo.ID); err != nil {
return err
}
if err = organization.IncrTeamRepoNum(ctx, t.ID); err != nil {
return fmt.Errorf("update team: %w", err)
}
t.NumRepos++
if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
return fmt.Errorf("recalculateAccesses: %w", err)
}
// Make all team members watch this repo if enabled in global settings
if setting.Service.AutoWatchNewRepos {
if err = t.LoadMembers(ctx); err != nil {
return fmt.Errorf("getMembers: %w", err)
}
for _, u := range t.Members {
if err = repo_model.WatchRepo(ctx, u, repo, true); err != nil {
return fmt.Errorf("watchRepo: %w", err)
}
}
}
return nil
}
// addAllRepositories adds all repositories to the team.
// If the team already has some repositories they will be left unchanged.
func addAllRepositories(ctx context.Context, t *organization.Team) error {
orgRepos, err := organization.GetOrgRepositories(ctx, t.OrgID)
if err != nil {
return fmt.Errorf("get org repos: %w", err)
}
for _, repo := range orgRepos {
if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
if err := AddRepository(ctx, t, repo); err != nil {
return fmt.Errorf("AddRepository: %w", err)
}
}
}
return nil
}
// AddAllRepositories adds all repositories to the team
func AddAllRepositories(ctx context.Context, t *organization.Team) (err error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err = addAllRepositories(ctx, t); err != nil {
return err
}
return committer.Commit()
}
// RemoveAllRepositories removes all repositories from team and recalculates access
func RemoveAllRepositories(ctx context.Context, t *organization.Team) (err error) {
if t.IncludesAllRepositories {
return nil
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err = removeAllRepositories(ctx, t); err != nil {
return err
}
return committer.Commit()
}
// removeAllRepositories removes all repositories from team and recalculates access
// Note: Shall not be called if team includes all repositories
func removeAllRepositories(ctx context.Context, t *organization.Team) (err error) {
e := db.GetEngine(ctx)
// Delete all accesses.
for _, repo := range t.Repos {
if err := access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
return err
}
// Remove watches from all users and now unaccessible repos
for _, user := range t.Members {
has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo)
if err != nil {
return err
} else if has {
continue
}
if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil {
return err
}
// Remove all IssueWatches a user has subscribed to in the repositories
if err = issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID); err != nil {
return err
}
}
}
// Delete team-repo
if _, err := e.
Where("team_id=?", t.ID).
Delete(new(organization.TeamRepo)); err != nil {
return err
}
t.NumRepos = 0
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
return err
}
return nil
}
// NewTeam creates a record of new team.
// It's caller's responsibility to assign organization ID.
func NewTeam(ctx context.Context, t *organization.Team) (err error) {
@ -204,7 +76,7 @@ func NewTeam(ctx context.Context, t *organization.Team) (err error) {
// Add all repositories to the team if it has access to all of them.
if t.IncludesAllRepositories {
err = addAllRepositories(ctx, t)
err = repo_service.AddAllRepositoriesToTeam(ctx, t)
if err != nil {
return fmt.Errorf("addAllRepositories: %w", err)
}
@ -282,7 +154,7 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA
// Add all repositories to the team if it has access to all of them.
if includeAllChanged && t.IncludesAllRepositories {
err = addAllRepositories(ctx, t)
err = repo_service.AddAllRepositoriesToTeam(ctx, t)
if err != nil {
return fmt.Errorf("addAllRepositories: %w", err)
}
@ -324,10 +196,8 @@ func DeleteTeam(ctx context.Context, t *organization.Team) error {
}
}
if !t.IncludesAllRepositories {
if err := removeAllRepositories(ctx, t); err != nil {
return err
}
if err := repo_service.RemoveAllRepositoriesFromTeam(ctx, t); err != nil {
return err
}
if err := db.DeleteBeans(ctx,
@ -486,12 +356,12 @@ func removeTeamMember(ctx context.Context, team *organization.Team, user *user_m
}
// Remove watches from now unaccessible
if err := ReconsiderWatches(ctx, repo, user); err != nil {
if err := repo_service.ReconsiderWatches(ctx, repo, user); err != nil {
return err
}
// Remove issue assignments from now unaccessible
if err := ReconsiderRepoIssuesAssignee(ctx, repo, user); err != nil {
if err := repo_service.ReconsiderRepoIssuesAssignee(ctx, repo, user); err != nil {
return err
}
}
@ -529,28 +399,3 @@ func RemoveTeamMember(ctx context.Context, team *organization.Team, user *user_m
}
return committer.Commit()
}
func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
if canAssigned, err := access_model.CanBeAssigned(ctx, user, repo, true); err != nil || canAssigned {
return err
}
if _, err := db.GetEngine(ctx).Where(builder.Eq{"assignee_id": user.ID}).
In("issue_id", builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repo.ID})).
Delete(&issues_model.IssueAssignees{}); err != nil {
return fmt.Errorf("Could not delete assignee[%d] %w", user.ID, err)
}
return nil
}
func ReconsiderWatches(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
if has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo); err != nil || has {
return err
}
if err := repo_model.WatchRepo(ctx, user, repo, false); err != nil {
return err
}
// Remove all IssueWatches a user has subscribed to in the repository
return issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID)
}

View File

@ -1,9 +1,10 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package models
package org
import (
"fmt"
"strings"
"testing"
@ -14,6 +15,8 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/structs"
repo_service "code.gitea.io/gitea/services/repository"
"github.com/stretchr/testify/assert"
)
@ -60,11 +63,6 @@ func TestTeam_RemoveMember(t *testing.T) {
assert.True(t, organization.IsErrLastOrgOwner(err))
}
func TestIsUsableTeamName(t *testing.T) {
assert.NoError(t, organization.IsUsableTeamName("usable"))
assert.True(t, db.IsErrNameReserved(organization.IsUsableTeamName("new")))
}
func TestNewTeam(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@ -185,3 +183,132 @@ func TestRepository_RecalculateAccesses3(t *testing.T) {
assert.NoError(t, err)
assert.True(t, has)
}
func TestIncludesAllRepositoriesTeams(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testTeamRepositories := func(teamID int64, repoIDs []int64) {
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
assert.NoError(t, team.LoadRepositories(db.DefaultContext), "%s: GetRepositories", team.Name)
assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name)
assert.Len(t, team.Repos, len(repoIDs), "%s: repo count", team.Name)
for i, rid := range repoIDs {
if rid > 0 {
assert.True(t, repo_service.HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i)
}
}
}
// Get an admin user.
user, err := user_model.GetUserByID(db.DefaultContext, 1)
assert.NoError(t, err, "GetUserByID")
// Create org.
org := &organization.Organization{
Name: "All_repo",
IsActive: true,
Type: user_model.UserTypeOrganization,
Visibility: structs.VisibleTypePublic,
}
assert.NoError(t, organization.CreateOrganization(db.DefaultContext, org, user), "CreateOrganization")
// Check Owner team.
ownerTeam, err := org.GetOwnerTeam(db.DefaultContext)
assert.NoError(t, err, "GetOwnerTeam")
assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories")
// Create repos.
repoIDs := make([]int64, 0)
for i := 0; i < 3; i++ {
r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)})
assert.NoError(t, err, "CreateRepository %d", i)
if r != nil {
repoIDs = append(repoIDs, r.ID)
}
}
// Get fresh copy of Owner team after creating repos.
ownerTeam, err = org.GetOwnerTeam(db.DefaultContext)
assert.NoError(t, err, "GetOwnerTeam")
// Create teams and check repositories.
teams := []*organization.Team{
ownerTeam,
{
OrgID: org.ID,
Name: "team one",
AccessMode: perm.AccessModeRead,
IncludesAllRepositories: true,
},
{
OrgID: org.ID,
Name: "team 2",
AccessMode: perm.AccessModeRead,
IncludesAllRepositories: false,
},
{
OrgID: org.ID,
Name: "team three",
AccessMode: perm.AccessModeWrite,
IncludesAllRepositories: true,
},
{
OrgID: org.ID,
Name: "team 4",
AccessMode: perm.AccessModeWrite,
IncludesAllRepositories: false,
},
}
teamRepos := [][]int64{
repoIDs,
repoIDs,
{},
repoIDs,
{},
}
for i, team := range teams {
if i > 0 { // first team is Owner.
assert.NoError(t, NewTeam(db.DefaultContext, team), "%s: NewTeam", team.Name)
}
testTeamRepositories(team.ID, teamRepos[i])
}
// Update teams and check repositories.
teams[3].IncludesAllRepositories = false
teams[4].IncludesAllRepositories = true
teamRepos[4] = repoIDs
for i, team := range teams {
assert.NoError(t, UpdateTeam(db.DefaultContext, team, false, true), "%s: UpdateTeam", team.Name)
testTeamRepositories(team.ID, teamRepos[i])
}
// Create repo and check teams repositories.
r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: "repo-last"})
assert.NoError(t, err, "CreateRepository last")
if r != nil {
repoIDs = append(repoIDs, r.ID)
}
teamRepos[0] = repoIDs
teamRepos[1] = repoIDs
teamRepos[4] = repoIDs
for i, team := range teams {
testTeamRepositories(team.ID, teamRepos[i])
}
// Remove repo and check teams repositories.
assert.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repoIDs[0]), "DeleteRepository")
teamRepos[0] = repoIDs[1:]
teamRepos[1] = repoIDs[1:]
teamRepos[3] = repoIDs[1:3]
teamRepos[4] = repoIDs[1:]
for i, team := range teams {
testTeamRepositories(team.ID, teamRepos[i])
}
// Wipe created items.
for i, rid := range repoIDs {
if i > 0 { // first repo already deleted.
assert.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, rid), "DeleteRepository %d", i)
}
}
assert.NoError(t, organization.DeleteOrganization(db.DefaultContext, org), "DeleteOrganization")
}

View File

@ -2,7 +2,7 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package models
package org
import (
"context"

View File

@ -1,7 +1,7 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package models
package org
import (
"testing"

View File

@ -6,9 +6,10 @@ package repository
import (
"context"
"fmt"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
@ -94,14 +95,39 @@ func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, colla
return err
}
if err = models.ReconsiderWatches(ctx, repo, collaborator); err != nil {
if err = ReconsiderWatches(ctx, repo, collaborator); err != nil {
return err
}
// Unassign a user from any issue (s)he has been assigned to in the repository
if err := models.ReconsiderRepoIssuesAssignee(ctx, repo, collaborator); err != nil {
if err := ReconsiderRepoIssuesAssignee(ctx, repo, collaborator); err != nil {
return err
}
return committer.Commit()
}
func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
if canAssigned, err := access_model.CanBeAssigned(ctx, user, repo, true); err != nil || canAssigned {
return err
}
if _, err := db.GetEngine(ctx).Where(builder.Eq{"assignee_id": user.ID}).
In("issue_id", builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repo.ID})).
Delete(&issues_model.IssueAssignees{}); err != nil {
return fmt.Errorf("Could not delete assignee[%d] %w", user.ID, err)
}
return nil
}
func ReconsiderWatches(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
if has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo); err != nil || has {
return err
}
if err := repo_model.WatchRepo(ctx, user, repo, false); err != nil {
return err
}
// Remove all IssueWatches a user has subscribed to in the repository
return issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID)
}

View File

@ -12,7 +12,6 @@ import (
"strings"
"time"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
@ -448,7 +447,7 @@ func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, re
}
for _, t := range teams {
if t.IncludesAllRepositories {
if err := models.AddRepository(ctx, t, repo); err != nil {
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
return fmt.Errorf("AddRepository: %w", err)
}
}

View File

@ -1,148 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"fmt"
"testing"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/structs"
"github.com/stretchr/testify/assert"
)
func TestIncludesAllRepositoriesTeams(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testTeamRepositories := func(teamID int64, repoIDs []int64) {
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
assert.NoError(t, team.LoadRepositories(db.DefaultContext), "%s: GetRepositories", team.Name)
assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name)
assert.Len(t, team.Repos, len(repoIDs), "%s: repo count", team.Name)
for i, rid := range repoIDs {
if rid > 0 {
assert.True(t, HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i)
}
}
}
// Get an admin user.
user, err := user_model.GetUserByID(db.DefaultContext, 1)
assert.NoError(t, err, "GetUserByID")
// Create org.
org := &organization.Organization{
Name: "All_repo",
IsActive: true,
Type: user_model.UserTypeOrganization,
Visibility: structs.VisibleTypePublic,
}
assert.NoError(t, organization.CreateOrganization(db.DefaultContext, org, user), "CreateOrganization")
// Check Owner team.
ownerTeam, err := org.GetOwnerTeam(db.DefaultContext)
assert.NoError(t, err, "GetOwnerTeam")
assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories")
// Create repos.
repoIDs := make([]int64, 0)
for i := 0; i < 3; i++ {
r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)})
assert.NoError(t, err, "CreateRepository %d", i)
if r != nil {
repoIDs = append(repoIDs, r.ID)
}
}
// Get fresh copy of Owner team after creating repos.
ownerTeam, err = org.GetOwnerTeam(db.DefaultContext)
assert.NoError(t, err, "GetOwnerTeam")
// Create teams and check repositories.
teams := []*organization.Team{
ownerTeam,
{
OrgID: org.ID,
Name: "team one",
AccessMode: perm.AccessModeRead,
IncludesAllRepositories: true,
},
{
OrgID: org.ID,
Name: "team 2",
AccessMode: perm.AccessModeRead,
IncludesAllRepositories: false,
},
{
OrgID: org.ID,
Name: "team three",
AccessMode: perm.AccessModeWrite,
IncludesAllRepositories: true,
},
{
OrgID: org.ID,
Name: "team 4",
AccessMode: perm.AccessModeWrite,
IncludesAllRepositories: false,
},
}
teamRepos := [][]int64{
repoIDs,
repoIDs,
{},
repoIDs,
{},
}
for i, team := range teams {
if i > 0 { // first team is Owner.
assert.NoError(t, models.NewTeam(db.DefaultContext, team), "%s: NewTeam", team.Name)
}
testTeamRepositories(team.ID, teamRepos[i])
}
// Update teams and check repositories.
teams[3].IncludesAllRepositories = false
teams[4].IncludesAllRepositories = true
teamRepos[4] = repoIDs
for i, team := range teams {
assert.NoError(t, models.UpdateTeam(db.DefaultContext, team, false, true), "%s: UpdateTeam", team.Name)
testTeamRepositories(team.ID, teamRepos[i])
}
// Create repo and check teams repositories.
r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: "repo-last"})
assert.NoError(t, err, "CreateRepository last")
if r != nil {
repoIDs = append(repoIDs, r.ID)
}
teamRepos[0] = repoIDs
teamRepos[1] = repoIDs
teamRepos[4] = repoIDs
for i, team := range teams {
testTeamRepositories(team.ID, teamRepos[i])
}
// Remove repo and check teams repositories.
assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, repoIDs[0]), "DeleteRepository")
teamRepos[0] = repoIDs[1:]
teamRepos[1] = repoIDs[1:]
teamRepos[3] = repoIDs[1:3]
teamRepos[4] = repoIDs[1:]
for i, team := range teams {
testTeamRepositories(team.ID, teamRepos[i])
}
// Wipe created items.
for i, rid := range repoIDs {
if i > 0 { // first repo already deleted.
assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, rid), "DeleteRepository %d", i)
}
}
assert.NoError(t, organization.DeleteOrganization(db.DefaultContext, org), "DeleteOrganization")
}

View File

@ -348,87 +348,6 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID
return nil
}
// removeRepositoryFromTeam removes a repository from a team and recalculates access
// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted)
func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository, recalculate bool) (err error) {
e := db.GetEngine(ctx)
if err = organization.RemoveTeamRepo(ctx, t.ID, repo.ID); err != nil {
return err
}
t.NumRepos--
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
return err
}
// Don't need to recalculate when delete a repository from organization.
if recalculate {
if err = access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
return err
}
}
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
TeamID: t.ID,
})
if err != nil {
return fmt.Errorf("GetTeamMembers: %w", err)
}
for _, member := range teamMembers {
has, err := access_model.HasAnyUnitAccess(ctx, member.ID, repo)
if err != nil {
return err
} else if has {
continue
}
if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil {
return err
}
// Remove all IssueWatches a user has subscribed to in the repositories
if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil {
return err
}
}
return nil
}
// HasRepository returns true if given repository belong to team.
func HasRepository(ctx context.Context, t *organization.Team, repoID int64) bool {
return organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID)
}
// RemoveRepositoryFromTeam removes repository from team of organization.
// If the team shall include all repositories the request is ignored.
func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error {
if !HasRepository(ctx, t, repoID) {
return nil
}
if t.IncludesAllRepositories {
return nil
}
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {
return err
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err = removeRepositoryFromTeam(ctx, t, repo, true); err != nil {
return err
}
return committer.Commit()
}
// DeleteOwnerRepositoriesDirectly calls DeleteRepositoryDirectly for all repos of the given owner
func DeleteOwnerRepositoriesDirectly(ctx context.Context, owner *user_model.User) error {
for {

View File

@ -0,0 +1,226 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"context"
"errors"
"fmt"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/setting"
)
// TeamAddRepository adds new repository to team of organization.
func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
if repo.OwnerID != t.OrgID {
return errors.New("repository does not belong to organization")
} else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
return addRepositoryToTeam(ctx, t, repo)
})
}
func addRepositoryToTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
if err = organization.AddTeamRepo(ctx, t.OrgID, t.ID, repo.ID); err != nil {
return err
}
if err = organization.IncrTeamRepoNum(ctx, t.ID); err != nil {
return fmt.Errorf("update team: %w", err)
}
t.NumRepos++
if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil {
return fmt.Errorf("recalculateAccesses: %w", err)
}
// Make all team members watch this repo if enabled in global settings
if setting.Service.AutoWatchNewRepos {
if err = t.LoadMembers(ctx); err != nil {
return fmt.Errorf("getMembers: %w", err)
}
for _, u := range t.Members {
if err = repo_model.WatchRepo(ctx, u, repo, true); err != nil {
return fmt.Errorf("watchRepo: %w", err)
}
}
}
return nil
}
// AddAllRepositoriesToTeam adds all repositories to the team.
// If the team already has some repositories they will be left unchanged.
func AddAllRepositoriesToTeam(ctx context.Context, t *organization.Team) error {
return db.WithTx(ctx, func(ctx context.Context) error {
orgRepos, err := organization.GetOrgRepositories(ctx, t.OrgID)
if err != nil {
return fmt.Errorf("get org repos: %w", err)
}
for _, repo := range orgRepos {
if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
return fmt.Errorf("AddRepository: %w", err)
}
}
}
return nil
})
}
// RemoveAllRepositoriesFromTeam removes all repositories from team and recalculates access
func RemoveAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (err error) {
if t.IncludesAllRepositories {
return nil
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err = removeAllRepositoriesFromTeam(ctx, t); err != nil {
return err
}
return committer.Commit()
}
// removeAllRepositoriesFromTeam removes all repositories from team and recalculates access
// Note: Shall not be called if team includes all repositories
func removeAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (err error) {
e := db.GetEngine(ctx)
// Delete all accesses.
for _, repo := range t.Repos {
if err := access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
return err
}
// Remove watches from all users and now unaccessible repos
for _, user := range t.Members {
has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo)
if err != nil {
return err
} else if has {
continue
}
if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil {
return err
}
// Remove all IssueWatches a user has subscribed to in the repositories
if err = issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID); err != nil {
return err
}
}
}
// Delete team-repo
if _, err := e.
Where("team_id=?", t.ID).
Delete(new(organization.TeamRepo)); err != nil {
return err
}
t.NumRepos = 0
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
return err
}
return nil
}
// RemoveRepositoryFromTeam removes repository from team of organization.
// If the team shall include all repositories the request is ignored.
func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error {
if !HasRepository(ctx, t, repoID) {
return nil
}
if t.IncludesAllRepositories {
return nil
}
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {
return err
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err = removeRepositoryFromTeam(ctx, t, repo, true); err != nil {
return err
}
return committer.Commit()
}
// removeRepositoryFromTeam removes a repository from a team and recalculates access
// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted)
func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository, recalculate bool) (err error) {
e := db.GetEngine(ctx)
if err = organization.RemoveTeamRepo(ctx, t.ID, repo.ID); err != nil {
return err
}
t.NumRepos--
if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil {
return err
}
// Don't need to recalculate when delete a repository from organization.
if recalculate {
if err = access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil {
return err
}
}
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
TeamID: t.ID,
})
if err != nil {
return fmt.Errorf("GetTeamMembers: %w", err)
}
for _, member := range teamMembers {
has, err := access_model.HasAnyUnitAccess(ctx, member.ID, repo)
if err != nil {
return err
} else if has {
continue
}
if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil {
return err
}
// Remove all IssueWatches a user has subscribed to in the repositories
if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil {
return err
}
}
return nil
}
// HasRepository returns true if given repository belong to team.
func HasRepository(ctx context.Context, t *organization.Team, repoID int64) bool {
return organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID)
}

View File

@ -1,7 +1,7 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
package repository
import (
"testing"

View File

@ -59,7 +59,7 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep
}
for _, team := range teams {
if err := models.AddRepository(ctx, team, newRepo); err != nil {
if err := addRepositoryToTeam(ctx, team, newRepo); err != nil {
return err
}
}
@ -205,7 +205,7 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
}
for _, t := range teams {
if t.IncludesAllRepositories {
if err := models.AddRepository(ctx, t, repo); err != nil {
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
return fmt.Errorf("AddRepository: %w", err)
}
}

View File

@ -188,7 +188,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
break
}
for _, org := range orgs {
if err := models.RemoveOrgUser(ctx, org, u); err != nil {
if err := org_service.RemoveOrgUser(ctx, org, u); err != nil {
if organization.IsErrLastOrgOwner(err) {
err = org_service.DeleteOrganization(ctx, org, true)
if err != nil {

View File

@ -18,6 +18,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
org_service "code.gitea.io/gitea/services/org"
"github.com/stretchr/testify/assert"
)
@ -44,7 +45,7 @@ func TestDeleteUser(t *testing.T) {
assert.NoError(t, db.GetEngine(db.DefaultContext).Find(&orgUsers, &organization.OrgUser{UID: userID}))
for _, orgUser := range orgUsers {
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: orgUser.OrgID})
if err := models.RemoveOrgUser(db.DefaultContext, org, user); err != nil {
if err := org_service.RemoveOrgUser(db.DefaultContext, org, user); err != nil {
assert.True(t, organization.IsErrLastOrgOwner(err))
return
}

View File

@ -37,9 +37,12 @@
</h4>
<div class="ui attached segment">
<div class="flex-list">
<div class="flex-list" id="protected-branches-list" data-update-priority-url="{{$.Repository.Link}}/settings/branches/priority">
{{range .ProtectedBranches}}
<div class="flex-item tw-items-center">
<div class="flex-item tw-items-center item" data-id="{{.ID}}" >
<div class="drag-handle tw-cursor-grab">
{{svg "octicon-grabber" 16}}
</div>
<div class="flex-item-main">
<div class="flex-item-title">
<div class="ui basic primary label">{{.RuleName}}</div>

View File

@ -4666,6 +4666,58 @@
}
}
},
"/repos/{owner}/{repo}/branch_protections/priority": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Update the priorities of branch protections for a repository.",
"operationId": "repoUpdateBranchProtectionPriories",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/UpdateBranchProtectionPriories"
}
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
},
"423": {
"$ref": "#/responses/repoArchivedError"
}
}
}
},
"/repos/{owner}/{repo}/branch_protections/{name}": {
"get": {
"produces": [
@ -18874,6 +18926,11 @@
},
"x-go-name": "MergeWhitelistUsernames"
},
"priority": {
"type": "integer",
"format": "int64",
"x-go-name": "Priority"
},
"protected_file_patterns": {
"type": "string",
"x-go-name": "ProtectedFilePatterns"
@ -19568,6 +19625,11 @@
},
"x-go-name": "MergeWhitelistUsernames"
},
"priority": {
"type": "integer",
"format": "int64",
"x-go-name": "Priority"
},
"protected_file_patterns": {
"type": "string",
"x-go-name": "ProtectedFilePatterns"
@ -20800,6 +20862,11 @@
},
"x-go-name": "MergeWhitelistUsernames"
},
"priority": {
"type": "integer",
"format": "int64",
"x-go-name": "Priority"
},
"protected_file_patterns": {
"type": "string",
"x-go-name": "ProtectedFilePatterns"
@ -24886,6 +24953,21 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"UpdateBranchProtectionPriories": {
"description": "UpdateBranchProtectionPriories a list to update the branch protection rule priorities",
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": {
"type": "integer",
"format": "int64"
},
"x-go-name": "IDs"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"UpdateFileOptions": {
"description": "UpdateFileOptions options for updating files\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)",
"type": "object",

View File

@ -127,6 +127,11 @@
<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
</div>
<div class="field tw-pl-4 cropper-panel tw-hidden">
<div>{{ctx.Locale.Tr "settings.cropper_prompt"}}</div>
<div class="cropper-wrapper"><img class="cropper-source" src alt></div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "settings.update_avatar"}}</button>
<button class="ui red button link-action" data-url="{{.Link}}/avatar/delete">{{ctx.Locale.Tr "settings.delete_current_avatar"}}</button>

View File

@ -49,7 +49,7 @@ func testAPIGetBranchProtection(t *testing.T, branchName string, expectedHTTPSta
return nil
}
func testAPICreateBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) {
func testAPICreateBranchProtection(t *testing.T, branchName string, expectedPriority, expectedHTTPStatus int) {
token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections", &api.BranchProtection{
RuleName: branchName,
@ -60,6 +60,7 @@ func testAPICreateBranchProtection(t *testing.T, branchName string, expectedHTTP
var branchProtection api.BranchProtection
DecodeJSON(t, resp, &branchProtection)
assert.EqualValues(t, branchName, branchProtection.RuleName)
assert.EqualValues(t, expectedPriority, branchProtection.Priority)
}
}
@ -189,13 +190,13 @@ func TestAPIBranchProtection(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Branch protection on branch that not exist
testAPICreateBranchProtection(t, "master/doesnotexist", http.StatusCreated)
testAPICreateBranchProtection(t, "master/doesnotexist", 1, http.StatusCreated)
// Get branch protection on branch that exist but not branch protection
testAPIGetBranchProtection(t, "master", http.StatusNotFound)
testAPICreateBranchProtection(t, "master", http.StatusCreated)
testAPICreateBranchProtection(t, "master", 2, http.StatusCreated)
// Can only create once
testAPICreateBranchProtection(t, "master", http.StatusForbidden)
testAPICreateBranchProtection(t, "master", 0, http.StatusForbidden)
// Can't delete a protected branch
testAPIDeleteBranch(t, "master", http.StatusForbidden)

View File

@ -7,13 +7,13 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/models"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
org_service "code.gitea.io/gitea/services/org"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@ -37,7 +37,7 @@ func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) {
ownerTeam1, err := org_model.OrgFromUser(limitedOrg).GetOwnerTeam(db.DefaultContext)
assert.NoError(t, err)
assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam1, user1))
assert.NoError(t, org_service.AddTeamMember(db.DefaultContext, ownerTeam1, user1))
user1Token := getTokenForLoggedInUser(t, user1Sess, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{
Organization: &limitedOrg.Name,
@ -52,7 +52,7 @@ func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) {
ownerTeam2, err := org_model.OrgFromUser(privateOrg).GetOwnerTeam(db.DefaultContext)
assert.NoError(t, err)
assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user4))
assert.NoError(t, org_service.AddTeamMember(db.DefaultContext, ownerTeam2, user4))
user4Token := getTokenForLoggedInUser(t, user4Sess, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
req = NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{
Organization: &privateOrg.Name,
@ -84,7 +84,7 @@ func TestAPIForkListLimitedAndPrivateRepos(t *testing.T) {
assert.Len(t, forks, 1)
assert.EqualValues(t, "1", resp.Header().Get("X-Total-Count"))
assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user1))
assert.NoError(t, org_service.AddTeamMember(db.DefaultContext, ownerTeam2, user1))
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
resp = MakeRequest(t, req, http.StatusOK)

View File

@ -10,7 +10,6 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/models"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
@ -19,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/auth/source/ldap"
org_service "code.gitea.io/gitea/services/org"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@ -429,9 +429,9 @@ func TestLDAPGroupTeamSyncAddMember(t *testing.T) {
isMember, err := organization.IsTeamMember(db.DefaultContext, usersOrgs[0].ID, team.ID, user.ID)
assert.NoError(t, err)
assert.True(t, isMember, "Membership should be added to the right team")
err = models.RemoveTeamMember(db.DefaultContext, team, user)
err = org_service.RemoveTeamMember(db.DefaultContext, team, user)
assert.NoError(t, err)
err = models.RemoveOrgUser(db.DefaultContext, usersOrgs[0], user)
err = org_service.RemoveOrgUser(db.DefaultContext, usersOrgs[0], user)
assert.NoError(t, err)
} else {
// assert members of LDAP group "cn=admin_staff" keep initial team membership since mapped team does not exist
@ -461,7 +461,7 @@ func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) {
})
err = organization.AddOrgUser(db.DefaultContext, org.ID, user.ID)
assert.NoError(t, err)
err = models.AddTeamMember(db.DefaultContext, team, user)
err = org_service.AddTeamMember(db.DefaultContext, team, user)
assert.NoError(t, err)
isMember, err := organization.IsOrganizationMember(db.DefaultContext, org.ID, user.ID)
assert.NoError(t, err)

View File

@ -9,12 +9,12 @@ import (
"net/http/httptest"
"testing"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/structs"
org_service "code.gitea.io/gitea/services/org"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@ -91,7 +91,7 @@ func TestForkListLimitedAndPrivateRepos(t *testing.T) {
assert.EqualValues(t, structs.VisibleTypeLimited, limitedOrg.Visibility)
ownerTeam1, err := org_model.OrgFromUser(limitedOrg).GetOwnerTeam(db.DefaultContext)
assert.NoError(t, err)
assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam1, user1))
assert.NoError(t, org_service.AddTeamMember(db.DefaultContext, ownerTeam1, user1))
testRepoFork(t, user1Sess, "user2", "repo1", limitedOrg.Name, "repo1", "")
// fork to a private org
@ -101,7 +101,7 @@ func TestForkListLimitedAndPrivateRepos(t *testing.T) {
assert.EqualValues(t, structs.VisibleTypePrivate, privateOrg.Visibility)
ownerTeam2, err := org_model.OrgFromUser(privateOrg).GetOwnerTeam(db.DefaultContext)
assert.NoError(t, err)
assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user4))
assert.NoError(t, org_service.AddTeamMember(db.DefaultContext, ownerTeam2, user4))
testRepoFork(t, user4Sess, "user2", "repo1", privateOrg.Name, "repo1", "")
t.Run("Anonymous", func(t *testing.T) {
@ -120,7 +120,7 @@ func TestForkListLimitedAndPrivateRepos(t *testing.T) {
htmlDoc := NewHTMLParser(t, resp.Body)
assert.EqualValues(t, 1, htmlDoc.Find(forkItemSelector).Length())
assert.NoError(t, models.AddTeamMember(db.DefaultContext, ownerTeam2, user1))
assert.NoError(t, org_service.AddTeamMember(db.DefaultContext, ownerTeam2, user1))
resp = user1Sess.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
assert.EqualValues(t, 2, htmlDoc.Find(forkItemSelector).Length())

View File

@ -10,6 +10,8 @@ import (
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
// Validate that each navbar setting is correct. This checks that the
@ -51,8 +53,10 @@ func WithDisabledFeatures(t *testing.T, features ...string) {
}
func TestUserSettingsAccount(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("all features enabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user/settings/account")
@ -68,7 +72,7 @@ func TestUserSettingsAccount(t *testing.T) {
})
t.Run("credentials disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageCredentials)
@ -85,7 +89,7 @@ func TestUserSettingsAccount(t *testing.T) {
})
t.Run("deletion disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureDeletion)
@ -102,7 +106,7 @@ func TestUserSettingsAccount(t *testing.T) {
})
t.Run("deletion, credentials and email notifications are disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
mail := setting.Service.EnableNotifyMail
setting.Service.EnableNotifyMail = false
@ -119,8 +123,10 @@ func TestUserSettingsAccount(t *testing.T) {
}
func TestUserSettingsUpdatePassword(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("enabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
@ -138,7 +144,7 @@ func TestUserSettingsUpdatePassword(t *testing.T) {
})
t.Run("credentials disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageCredentials)
@ -156,8 +162,10 @@ func TestUserSettingsUpdatePassword(t *testing.T) {
}
func TestUserSettingsUpdateEmail(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("credentials disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageCredentials)
@ -175,8 +183,10 @@ func TestUserSettingsUpdateEmail(t *testing.T) {
}
func TestUserSettingsDeleteEmail(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("credentials disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageCredentials)
@ -194,8 +204,10 @@ func TestUserSettingsDeleteEmail(t *testing.T) {
}
func TestUserSettingsDelete(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("deletion disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureDeletion)
@ -224,9 +236,10 @@ func TestUserSettingsAppearance(t *testing.T) {
}
func TestUserSettingsSecurity(t *testing.T) {
t.Run("credentials disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrepareTestEnv(t)()
t.Run("credentials disabled", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageCredentials)
session := loginUser(t, "user2")
@ -240,8 +253,7 @@ func TestUserSettingsSecurity(t *testing.T) {
})
t.Run("mfa disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageMFA)
session := loginUser(t, "user2")
@ -255,8 +267,7 @@ func TestUserSettingsSecurity(t *testing.T) {
})
t.Run("credentials and mfa disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageCredentials, setting.UserFeatureManageMFA)
session := loginUser(t, "user2")
@ -268,17 +279,75 @@ func TestUserSettingsSecurity(t *testing.T) {
func TestUserSettingsApplications(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user/settings/applications")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
t.Run("Applications", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
assertNavbar(t, doc)
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user/settings/applications")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
assertNavbar(t, doc)
})
t.Run("OAuth2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
t.Run("OAuth2ApplicationShow", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user/settings/applications/oauth2/2")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
assertNavbar(t, doc)
})
t.Run("OAuthApplicationsEdit", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user/settings/applications/oauth2/2")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
t.Run("Invalid URL", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", "/user/settings/applications/oauth2/2", map[string]string{
"_csrf": doc.GetCSRF(),
"application_name": "Test native app",
"redirect_uris": "ftp://127.0.0.1",
"confidential_client": "false",
})
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
msg := doc.Find(".flash-error p").Text()
assert.Equal(t, `form.RedirectURIs"ftp://127.0.0.1" is not a valid URL.`, msg)
})
t.Run("OK", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", "/user/settings/applications/oauth2/2", map[string]string{
"_csrf": doc.GetCSRF(),
"application_name": "Test native app",
"redirect_uris": "http://127.0.0.1",
"confidential_client": "false",
})
session.MakeRequest(t, req, http.StatusSeeOther)
})
})
})
}
func TestUserSettingsKeys(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("all enabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user/settings/keys")
@ -292,7 +361,7 @@ func TestUserSettingsKeys(t *testing.T) {
})
t.Run("ssh keys disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageSSHKeys)
@ -308,7 +377,7 @@ func TestUserSettingsKeys(t *testing.T) {
})
t.Run("gpg keys disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageGPGKeys)
@ -324,7 +393,7 @@ func TestUserSettingsKeys(t *testing.T) {
})
t.Run("ssh & gpg keys disabled", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer tests.PrintCurrentTest(t)()
WithDisabledFeatures(t, setting.UserFeatureManageSSHKeys, setting.UserFeatureManageGPGKeys)

View File

@ -0,0 +1,6 @@
@import "cropperjs/dist/cropper.css";
.page-content.user.profile .cropper-panel .cropper-wrapper {
max-width: 400px;
max-height: 400px;
}

View File

@ -40,6 +40,7 @@
@import "./features/codeeditor.css";
@import "./features/projects.css";
@import "./features/tribute.css";
@import "./features/cropper.css";
@import "./features/console.css";
@import "./markup/content.css";

File diff suppressed because it is too large Load Diff

View File

@ -122,6 +122,6 @@ async function linkAction(el: HTMLElement, e: Event) {
}
export function initGlobalFetchAction() {
addDelegatedEventListener(document, 'click', '.form-fetch-action', formFetchAction);
addDelegatedEventListener(document, 'submit', '.form-fetch-action', formFetchAction);
addDelegatedEventListener(document, 'click', '.link-action', linkAction);
}

View File

@ -0,0 +1,40 @@
import {showElem} from '../../utils/dom.ts';
type CropperOpts = {
container: HTMLElement,
imageSource: HTMLImageElement,
fileInput: HTMLInputElement,
}
export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
aspectRatio: 1,
viewMode: 2,
autoCrop: false,
crop() {
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(croppedFile);
fileInput.files = dataTransfer.files;
});
},
});
fileInput.addEventListener('input', (e: Event & {target: HTMLInputElement}) => {
const files = e.target.files;
if (files?.length > 0) {
currentFileName = files[0].name;
currentFileLastModified = files[0].lastModified;
const fileURL = URL.createObjectURL(files[0]);
imageSource.src = fileURL;
cropper.replace(fileURL);
showElem(container);
}
});
}

View File

@ -196,7 +196,11 @@ async function initIssuePinSort() {
createSortable(pinDiv, {
group: 'shared',
onEnd: pinMoveEnd, // eslint-disable-line @typescript-eslint/no-misused-promises
onEnd: (e) => {
(async () => {
await pinMoveEnd(e);
})();
},
});
}

View File

@ -0,0 +1,71 @@
import {beforeEach, describe, expect, test, vi} from 'vitest';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {POST} from '../modules/fetch.ts';
import {createSortable} from '../modules/sortable.ts';
vi.mock('../modules/fetch.ts', () => ({
POST: vi.fn(),
}));
vi.mock('../modules/sortable.ts', () => ({
createSortable: vi.fn(),
}));
describe('Repository Branch Settings', () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="protected-branches-list" data-update-priority-url="some/repo/branches/priority">
<div class="flex-item tw-items-center item" data-id="1" >
<div class="drag-handle"></div>
</div>
<div class="flex-item tw-items-center item" data-id="2" >
<div class="drag-handle"></div>
</div>
<div class="flex-item tw-items-center item" data-id="3" >
<div class="drag-handle"></div>
</div>
</div>
`;
vi.clearAllMocks();
});
test('should initialize sortable for protected branches list', () => {
initRepoSettingsBranchesDrag();
expect(createSortable).toHaveBeenCalledWith(
document.querySelector('#protected-branches-list'),
expect.objectContaining({
handle: '.drag-handle',
animation: 150,
}),
);
});
test('should not initialize if protected branches list is not present', () => {
document.body.innerHTML = '';
initRepoSettingsBranchesDrag();
expect(createSortable).not.toHaveBeenCalled();
});
test('should post new order after sorting', async () => {
vi.mocked(POST).mockResolvedValue({ok: true} as Response);
// Mock createSortable to capture and execute the onEnd callback
vi.mocked(createSortable).mockImplementation((_el, options) => {
options.onEnd();
return {destroy: vi.fn()};
});
initRepoSettingsBranchesDrag();
expect(POST).toHaveBeenCalledWith(
'some/repo/branches/priority',
expect.objectContaining({
data: {ids: [1, 2, 3]},
}),
);
});
});

View File

@ -0,0 +1,32 @@
import {createSortable} from '../modules/sortable.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {queryElemChildren} from '../utils/dom.ts';
export function initRepoSettingsBranchesDrag() {
const protectedBranchesList = document.querySelector('#protected-branches-list');
if (!protectedBranchesList) return;
createSortable(protectedBranchesList, {
handle: '.drag-handle',
animation: 150,
onEnd: () => {
(async () => {
const itemElems = queryElemChildren(protectedBranchesList, '.item[data-id]');
const itemIds = Array.from(itemElems, (el) => parseInt(el.getAttribute('data-id')));
try {
await POST(protectedBranchesList.getAttribute('data-update-priority-url'), {
data: {
ids: itemIds,
},
});
} catch (err) {
const errorMessage = String(err);
showErrorToast(`Failed to update branch protection rule priority:, error: ${errorMessage}`);
}
})();
},
});
}

View File

@ -3,6 +3,7 @@ import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.ts';
import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
const {appSubUrl, csrfToken} = window.config;
@ -154,4 +155,5 @@ export function initRepoSettings() {
initRepoSettingsCollaboration();
initRepoSettingsSearchTeamBox();
initRepoSettingsGitHook();
initRepoSettingsBranchesDrag();
}

View File

@ -1,7 +1,17 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {initCompCropper} from './comp/Cropper.ts';
function initUserSettingsAvatarCropper() {
const fileInput = document.querySelector<HTMLInputElement>('#new-avatar');
const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel');
const imageSource = container.querySelector<HTMLImageElement>('.cropper-source');
initCompCropper({container, fileInput, imageSource});
}
export function initUserSettings() {
if (!document.querySelectorAll('.user.settings.profile').length) return;
if (!document.querySelector('.user.settings.profile')) return;
initUserSettingsAvatarCropper();
const usernameInput = document.querySelector('#username');
if (!usernameInput) return;

View File

@ -1,6 +1,6 @@
import type {SortableOptions, SortableEvent} from 'sortablejs';
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}) {
export async function createSortable(el: Element, opts: {handle?: string} & SortableOptions = {}) {
// @ts-expect-error: wrong type derived by typescript
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');

View File

@ -34,6 +34,7 @@ import octiconGitCommit from '../../public/assets/img/svg/octicon-git-commit.svg
import octiconGitMerge from '../../public/assets/img/svg/octicon-git-merge.svg';
import octiconGitPullRequest from '../../public/assets/img/svg/octicon-git-pull-request.svg';
import octiconGitPullRequestDraft from '../../public/assets/img/svg/octicon-git-pull-request-draft.svg';
import octiconGrabber from '../../public/assets/img/svg/octicon-grabber.svg';
import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
import octiconHorizontalRule from '../../public/assets/img/svg/octicon-horizontal-rule.svg';
import octiconImage from '../../public/assets/img/svg/octicon-image.svg';
@ -107,6 +108,7 @@ const svgs = {
'octicon-git-merge': octiconGitMerge,
'octicon-git-pull-request': octiconGitPullRequest,
'octicon-git-pull-request-draft': octiconGitPullRequestDraft,
'octicon-grabber': octiconGrabber,
'octicon-heading': octiconHeading,
'octicon-horizontal-rule': octiconHorizontalRule,
'octicon-image': octiconImage,

View File

@ -1,6 +1,7 @@
import {debounce} from 'throttle-debounce';
import type {Promisable} from 'type-fest';
import type $ from 'jquery';
import {isInFrontendUnitTest} from './testhelper.ts';
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
type ElementArg = Element | string | ArrayLikeIterable<Element> | ReturnType<typeof $>;
@ -76,8 +77,8 @@ export function queryElemSiblings<T extends Element>(el: Element, selector = '*'
// it works like jQuery.children: only the direct children are selected
export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
if (window.vitest) {
// bypass the vitest bug: it doesn't support ":scope >"
if (isInFrontendUnitTest()) {
// https://github.com/capricorn86/happy-dom/issues/1620 : ":scope" doesn't work
const selected = Array.from<T>(parent.children as any).filter((child) => child.matches(selector));
return applyElemsCallback<T>(selected, fn);
}
@ -357,6 +358,6 @@ export function addDelegatedEventListener<T extends HTMLElement, E extends Event
parent.addEventListener(type, (e: Event) => {
const elem = (e.target as HTMLElement).closest(selector);
if (!elem) return;
listener(elem as T, e);
listener(elem as T, e as E);
}, options);
}

View File

@ -0,0 +1,6 @@
// there could be different "testing" concepts, for example: backend's "setting.IsInTesting"
// even if backend is in testing mode, frontend could be complied in production mode
// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files)
export function isInFrontendUnitTest() {
return process.env.TEST === 'true';
}

View File

@ -17,7 +17,7 @@ import tailwindcssNesting from 'tailwindcss/nesting/index.js';
import postcssNesting from 'postcss-nesting';
const {EsbuildPlugin} = EsBuildLoader;
const {SourceMapDevToolPlugin, DefinePlugin} = webpack;
const {SourceMapDevToolPlugin, DefinePlugin, EnvironmentPlugin} = webpack;
const formatLicenseText = (licenseText) => wrapAnsi(licenseText || '', 80).trim();
const glob = (pattern) => fastGlob.sync(pattern, {
@ -213,6 +213,10 @@ export default {
__VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // https://github.com/vuejs/vue-cli/pull/7443
}),
// all environment variables used in bundled js via process.env must be declared here
new EnvironmentPlugin({
TEST: 'false',
}),
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: 'css/[name].css',