Add priority to protected branch (#32286)

## Solves

Currently for rules to re-order them you have to alter the creation
date. so you basicly have to delete and recreate them in the right
order. This is more than just inconvinient ...

## Solution

Add a new col for prioritization

## Demo WebUI Video

https://github.com/user-attachments/assets/92182a31-9705-4ac5-b6e3-9bb74108cbd1


---
*Sponsored by Kithara Software GmbH*
This commit is contained in:
6543 2024-11-27 05:41:06 +01:00 committed by GitHub
parent 3fc1bbe971
commit 846f618716
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 454 additions and 13 deletions

View File

@ -34,6 +34,7 @@ type ProtectedBranch struct {
RepoID int64 `xorm:"UNIQUE(s)"` RepoID int64 `xorm:"UNIQUE(s)"`
Repo *repo_model.Repository `xorm:"-"` Repo *repo_model.Repository `xorm:"-"`
RuleName string `xorm:"'branch_name' UNIQUE(s)"` // a branch name or a glob match to branch name 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:"-"` globRule glob.Glob `xorm:"-"`
isPlainName bool `xorm:"-"` isPlainName bool `xorm:"-"`
CanPush bool `xorm:"NOT NULL DEFAULT false"` CanPush bool `xorm:"NOT NULL DEFAULT false"`
@ -413,14 +414,27 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
} }
protectBranch.ApprovalsWhitelistTeamIDs = whitelist protectBranch.ApprovalsWhitelistTeamIDs = whitelist
// Make sure protectBranch.ID is not 0 for whitelists // Looks like it's a new rule
if protectBranch.ID == 0 { 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 { if _, err = db.GetEngine(ctx).Insert(protectBranch); err != nil {
return fmt.Errorf("Insert: %v", err) return fmt.Errorf("Insert: %v", err)
} }
return nil return nil
} }
// update the rule
if _, err = db.GetEngine(ctx).ID(protectBranch.ID).AllCols().Update(protectBranch); err != nil { if _, err = db.GetEngine(ctx).ID(protectBranch.ID).AllCols().Update(protectBranch); err != nil {
return fmt.Errorf("Update: %v", err) return fmt.Errorf("Update: %v", err)
} }
@ -428,6 +442,24 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
return nil 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 // 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. // 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) { 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 { sort.Slice(rules, func(i, j int) bool {
rules[i].loadGlob() rules[i].loadGlob()
rules[j].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 { if rules[i].isPlainName != rules[j].isPlainName {
return rules[i].isPlainName // plain name comes first, so plain name means "less" 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{{ in := []*ProtectedBranch{{
RuleName: "b", RuleName: "b",
CreatedUnix: 1, CreatedUnix: 1,
@ -103,3 +103,37 @@ func TestBranchRuleSort(t *testing.T) {
} }
assert.Equal(t, expect, got) 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" "fmt"
"testing" "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" "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(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(308, "Add index(user_id, is_deleted) for action table", v1_23.AddNewIndexForUserDashboard),
newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices), newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices),
newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch),
} }
return preparedMigrations 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

@ -25,6 +25,7 @@ type BranchProtection struct {
// Deprecated: true // Deprecated: true
BranchName string `json:"branch_name"` BranchName string `json:"branch_name"`
RuleName string `json:"rule_name"` RuleName string `json:"rule_name"`
Priority int64 `json:"priority"`
EnablePush bool `json:"enable_push"` EnablePush bool `json:"enable_push"`
EnablePushWhitelist bool `json:"enable_push_whitelist"` EnablePushWhitelist bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"` PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
@ -64,6 +65,7 @@ type CreateBranchProtectionOption struct {
// Deprecated: true // Deprecated: true
BranchName string `json:"branch_name"` BranchName string `json:"branch_name"`
RuleName string `json:"rule_name"` RuleName string `json:"rule_name"`
Priority int64 `json:"priority"`
EnablePush bool `json:"enable_push"` EnablePush bool `json:"enable_push"`
EnablePushWhitelist bool `json:"enable_push_whitelist"` EnablePushWhitelist bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"` PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
@ -96,6 +98,7 @@ type CreateBranchProtectionOption struct {
// EditBranchProtectionOption options for editing a branch protection // EditBranchProtectionOption options for editing a branch protection
type EditBranchProtectionOption struct { type EditBranchProtectionOption struct {
Priority *int64 `json:"priority"`
EnablePush *bool `json:"enable_push"` EnablePush *bool `json:"enable_push"`
EnablePushWhitelist *bool `json:"enable_push_whitelist"` EnablePushWhitelist *bool `json:"enable_push_whitelist"`
PushWhitelistUsernames []string `json:"push_whitelist_usernames"` PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
@ -125,3 +128,8 @@ type EditBranchProtectionOption struct {
UnprotectedFilePatterns *string `json:"unprotected_file_patterns"` UnprotectedFilePatterns *string `json:"unprotected_file_patterns"`
BlockAdminMergeOverride *bool `json:"block_admin_merge_override"` 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

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

View File

@ -618,6 +618,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
protectBranch = &git_model.ProtectedBranch{ protectBranch = &git_model.ProtectedBranch{
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
RuleName: ruleName, RuleName: ruleName,
Priority: form.Priority,
CanPush: form.EnablePush, CanPush: form.EnablePush,
EnableWhitelist: form.EnablePush && form.EnablePushWhitelist, EnableWhitelist: form.EnablePush && form.EnablePushWhitelist,
WhitelistDeployKeys: form.EnablePush && form.EnablePushWhitelist && form.PushWhitelistDeployKeys, WhitelistDeployKeys: form.EnablePush && form.EnablePushWhitelist && form.PushWhitelistDeployKeys,
@ -640,7 +641,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
BlockAdminMergeOverride: form.BlockAdminMergeOverride, 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, UserIDs: whitelistUsers,
TeamIDs: whitelistTeams, TeamIDs: whitelistTeams,
ForcePushUserIDs: forcePushAllowlistUsers, ForcePushUserIDs: forcePushAllowlistUsers,
@ -649,14 +650,13 @@ func CreateBranchProtection(ctx *context.APIContext) {
MergeTeamIDs: mergeWhitelistTeams, MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsUserIDs: approvalsWhitelistUsers,
ApprovalsTeamIDs: approvalsWhitelistTeams, ApprovalsTeamIDs: approvalsWhitelistTeams,
}) }); err != nil {
if err != nil {
ctx.Error(http.StatusInternalServerError, "UpdateProtectBranch", err) ctx.Error(http.StatusInternalServerError, "UpdateProtectBranch", err)
return return
} }
if isBranchExist { 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) ctx.Error(http.StatusInternalServerError, "CheckPRsForBaseBranch", err)
return return
} }
@ -796,6 +796,10 @@ func EditBranchProtection(ctx *context.APIContext) {
} }
} }
if form.Priority != nil {
protectBranch.Priority = *form.Priority
}
if form.EnableMergeWhitelist != nil { if form.EnableMergeWhitelist != nil {
protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist
} }
@ -1080,3 +1084,47 @@ func DeleteBranchProtection(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent) 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

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

View File

@ -322,6 +322,16 @@ func DeleteProtectedBranchRulePost(ctx *context.Context) {
ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) 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 // RenameBranchPost responses for rename a branch
func RenameBranchPost(ctx *context.Context) { func RenameBranchPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.RenameBranchForm) form := web.GetForm(ctx).(*forms.RenameBranchForm)

View File

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

View File

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

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) return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
} }
type ProtectBranchPriorityForm struct {
IDs []int64
}
// __ __ ___. .__ __ // __ __ ___. .__ __
// / \ / \ ____\_ |__ | |__ ____ ____ | | __ // / \ / \ ____\_ |__ | |__ ____ ____ | | __
// \ \/\/ // __ \| __ \| | \ / _ \ / _ \| |/ / // \ \/\/ // __ \| __ \| | \ / _ \ / _ \| |/ /

View File

@ -37,9 +37,12 @@
</h4> </h4>
<div class="ui attached segment"> <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}} {{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-main">
<div class="flex-item-title"> <div class="flex-item-title">
<div class="ui basic primary label">{{.RuleName}}</div> <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}": { "/repos/{owner}/{repo}/branch_protections/{name}": {
"get": { "get": {
"produces": [ "produces": [
@ -18874,6 +18926,11 @@
}, },
"x-go-name": "MergeWhitelistUsernames" "x-go-name": "MergeWhitelistUsernames"
}, },
"priority": {
"type": "integer",
"format": "int64",
"x-go-name": "Priority"
},
"protected_file_patterns": { "protected_file_patterns": {
"type": "string", "type": "string",
"x-go-name": "ProtectedFilePatterns" "x-go-name": "ProtectedFilePatterns"
@ -19568,6 +19625,11 @@
}, },
"x-go-name": "MergeWhitelistUsernames" "x-go-name": "MergeWhitelistUsernames"
}, },
"priority": {
"type": "integer",
"format": "int64",
"x-go-name": "Priority"
},
"protected_file_patterns": { "protected_file_patterns": {
"type": "string", "type": "string",
"x-go-name": "ProtectedFilePatterns" "x-go-name": "ProtectedFilePatterns"
@ -20800,6 +20862,11 @@
}, },
"x-go-name": "MergeWhitelistUsernames" "x-go-name": "MergeWhitelistUsernames"
}, },
"priority": {
"type": "integer",
"format": "int64",
"x-go-name": "Priority"
},
"protected_file_patterns": { "protected_file_patterns": {
"type": "string", "type": "string",
"x-go-name": "ProtectedFilePatterns" "x-go-name": "ProtectedFilePatterns"
@ -24886,6 +24953,21 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "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": { "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)", "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", "type": "object",

View File

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

View File

@ -196,7 +196,11 @@ async function initIssuePinSort() {
createSortable(pinDiv, { createSortable(pinDiv, {
group: 'shared', 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 {initRepoBranchesSettings} 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', () => {
initRepoBranchesSettings();
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 = '';
initRepoBranchesSettings();
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()};
});
initRepoBranchesSettings();
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 initRepoBranchesSettings() {
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 {createMonaco} from './codeeditor.ts';
import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts'; import {POST} from '../modules/fetch.ts';
import {initRepoBranchesSettings} from './repo-settings-branches.ts';
const {appSubUrl, csrfToken} = window.config; const {appSubUrl, csrfToken} = window.config;
@ -154,4 +155,5 @@ export function initRepoSettings() {
initRepoSettingsCollaboration(); initRepoSettingsCollaboration();
initRepoSettingsSearchTeamBox(); initRepoSettingsSearchTeamBox();
initRepoSettingsGitHook(); initRepoSettingsGitHook();
initRepoBranchesSettings();
} }

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