初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrBranchNotExist represents an error that branch with such name does not exist.
|
||||
type ErrBranchNotExist struct {
|
||||
RepoID int64
|
||||
BranchName string
|
||||
}
|
||||
|
||||
// IsErrBranchNotExist checks if an error is an ErrBranchDoesNotExist.
|
||||
func IsErrBranchNotExist(err error) bool {
|
||||
_, ok := err.(ErrBranchNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBranchNotExist) Error() string {
|
||||
return fmt.Sprintf("branch does not exist [repo_id: %d name: %s]", err.RepoID, err.BranchName)
|
||||
}
|
||||
|
||||
func (err ErrBranchNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrBranchAlreadyExists represents an error that branch with such name already exists.
|
||||
type ErrBranchAlreadyExists struct {
|
||||
BranchName string
|
||||
}
|
||||
|
||||
// IsErrBranchAlreadyExists checks if an error is an ErrBranchAlreadyExists.
|
||||
func IsErrBranchAlreadyExists(err error) bool {
|
||||
_, ok := err.(ErrBranchAlreadyExists)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBranchAlreadyExists) Error() string {
|
||||
return fmt.Sprintf("branch already exists [name: %s]", err.BranchName)
|
||||
}
|
||||
|
||||
func (err ErrBranchAlreadyExists) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrBranchNameConflict represents an error that branch name conflicts with other branch.
|
||||
type ErrBranchNameConflict struct {
|
||||
BranchName string
|
||||
}
|
||||
|
||||
// IsErrBranchNameConflict checks if an error is an ErrBranchNameConflict.
|
||||
func IsErrBranchNameConflict(err error) bool {
|
||||
_, ok := err.(ErrBranchNameConflict)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBranchNameConflict) Error() string {
|
||||
return fmt.Sprintf("branch conflicts with existing branch [name: %s]", err.BranchName)
|
||||
}
|
||||
|
||||
func (err ErrBranchNameConflict) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrBranchesEqual represents an error that base branch is equal to the head branch.
|
||||
type ErrBranchesEqual struct {
|
||||
BaseBranchName string
|
||||
HeadBranchName string
|
||||
}
|
||||
|
||||
// IsErrBranchesEqual checks if an error is an ErrBranchesEqual.
|
||||
func IsErrBranchesEqual(err error) bool {
|
||||
_, ok := err.(ErrBranchesEqual)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBranchesEqual) Error() string {
|
||||
return fmt.Sprintf("branches are equal [head: %sm base: %s]", err.HeadBranchName, err.BaseBranchName)
|
||||
}
|
||||
|
||||
func (err ErrBranchesEqual) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// Branch represents a branch of a repository
|
||||
// For those repository who have many branches, stored into database is a good choice
|
||||
// for pagination, keyword search and filtering
|
||||
type Branch struct {
|
||||
ID int64
|
||||
RepoID int64 `xorm:"UNIQUE(s)"`
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
Name string `xorm:"UNIQUE(s) NOT NULL"` // git's ref-name is case-sensitive internally, however, in some databases (mssql, mysql, by default), it's case-insensitive at the moment
|
||||
CommitID string
|
||||
CommitMessage string `xorm:"TEXT"` // it only stores the message summary (the first line)
|
||||
PusherID int64
|
||||
Pusher *user_model.User `xorm:"-"`
|
||||
IsDeleted bool `xorm:"index"`
|
||||
DeletedByID int64
|
||||
DeletedBy *user_model.User `xorm:"-"`
|
||||
DeletedUnix timeutil.TimeStamp `xorm:"index"`
|
||||
CommitTime timeutil.TimeStamp // The commit
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func (b *Branch) LoadDeletedBy(ctx context.Context) (err error) {
|
||||
if b.DeletedBy == nil {
|
||||
b.DeletedBy, err = user_model.GetUserByID(ctx, b.DeletedByID)
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
b.DeletedBy = user_model.NewGhostUser()
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Branch) LoadPusher(ctx context.Context) (err error) {
|
||||
if b.Pusher == nil && b.PusherID > 0 {
|
||||
b.Pusher, err = user_model.GetUserByID(ctx, b.PusherID)
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
b.Pusher = user_model.NewGhostUser()
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *Branch) LoadRepo(ctx context.Context) (err error) {
|
||||
if b.Repo != nil || b.RepoID == 0 {
|
||||
return nil
|
||||
}
|
||||
b.Repo, err = repo_model.GetRepositoryByID(ctx, b.RepoID)
|
||||
return err
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Branch))
|
||||
db.RegisterModel(new(RenamedBranch))
|
||||
}
|
||||
|
||||
func GetBranch(ctx context.Context, repoID int64, branchName string) (*Branch, error) {
|
||||
var branch Branch
|
||||
has, err := db.GetEngine(ctx).Where("repo_id=?", repoID).And("name=?", branchName).Get(&branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrBranchNotExist{
|
||||
RepoID: repoID,
|
||||
BranchName: branchName,
|
||||
}
|
||||
}
|
||||
// FIXME: this design is not right: it doesn't check `branch.IsDeleted`, it doesn't make sense to make callers to check IsDeleted again and again.
|
||||
// It causes inconsistency with `GetBranches` and `git.GetBranch`, and will lead to strange bugs
|
||||
// In the future, there should be 2 functions: `GetBranchExisting` and `GetBranchWithDeleted`
|
||||
return &branch, nil
|
||||
}
|
||||
|
||||
// IsBranchExist returns true if the branch exists in the repository.
|
||||
func IsBranchExist(ctx context.Context, repoID int64, branchName string) (bool, error) {
|
||||
var branch Branch
|
||||
has, err := db.GetEngine(ctx).Where("repo_id=?", repoID).And("name=?", branchName).Get(&branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
} else if !has {
|
||||
return false, nil
|
||||
}
|
||||
return !branch.IsDeleted, nil
|
||||
}
|
||||
|
||||
func GetBranches(ctx context.Context, repoID int64, branchNames []string, includeDeleted bool) ([]*Branch, error) {
|
||||
branches := make([]*Branch, 0, len(branchNames))
|
||||
|
||||
sess := db.GetEngine(ctx).Where("repo_id=?", repoID).In("name", branchNames)
|
||||
if !includeDeleted {
|
||||
sess.And("is_deleted=?", false)
|
||||
}
|
||||
return branches, sess.Find(&branches)
|
||||
}
|
||||
|
||||
func BranchesToNamesSet(branches []*Branch) container.Set[string] {
|
||||
names := make(container.Set[string], len(branches))
|
||||
for _, branch := range branches {
|
||||
names.Add(branch.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func AddBranches(ctx context.Context, branches []*Branch) error {
|
||||
for _, branch := range branches {
|
||||
if _, err := db.GetEngine(ctx).Insert(branch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetDeletedBranchByID(ctx context.Context, repoID, branchID int64) (*Branch, error) {
|
||||
var branch Branch
|
||||
has, err := db.GetEngine(ctx).ID(branchID).Get(&branch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrBranchNotExist{
|
||||
RepoID: repoID,
|
||||
}
|
||||
}
|
||||
if branch.RepoID != repoID {
|
||||
return nil, ErrBranchNotExist{
|
||||
RepoID: repoID,
|
||||
}
|
||||
}
|
||||
if !branch.IsDeleted {
|
||||
return nil, ErrBranchNotExist{
|
||||
RepoID: repoID,
|
||||
}
|
||||
}
|
||||
return &branch, nil
|
||||
}
|
||||
|
||||
func DeleteRepoBranches(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id=?", repoID).Delete(new(Branch))
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteBranches(ctx context.Context, repoID, doerID int64, branchIDs []int64) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
branches := make([]*Branch, 0, len(branchIDs))
|
||||
if err := db.GetEngine(ctx).In("id", branchIDs).Find(&branches); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, branch := range branches {
|
||||
if err := MarkBranchAsDeleted(ctx, repoID, branch.Name, doerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBranch updates the branch information in the database.
|
||||
func UpdateBranch(ctx context.Context, repoID, pusherID int64, branchName string, commit *git.Commit) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("repo_id=? AND name=?", repoID, branchName).
|
||||
Cols("commit_id, commit_message, pusher_id, commit_time, is_deleted, updated_unix").
|
||||
Update(&Branch{
|
||||
CommitID: commit.ID.String(),
|
||||
CommitMessage: commit.MessageTitle(),
|
||||
PusherID: pusherID,
|
||||
CommitTime: timeutil.TimeStamp(commit.Committer.When.Unix()),
|
||||
IsDeleted: false,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkBranchAsDeleted marks branch as deleted
|
||||
func MarkBranchAsDeleted(ctx context.Context, repoID int64, branchName string, deletedByID int64) error {
|
||||
branch, err := GetBranch(ctx, repoID, branchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if branch.IsDeleted {
|
||||
return nil
|
||||
}
|
||||
|
||||
cnt, err := db.GetEngine(ctx).Where("repo_id=? AND name=? AND is_deleted=?", repoID, branchName, false).
|
||||
Cols("is_deleted, deleted_by_id, deleted_unix").
|
||||
Update(&Branch{
|
||||
IsDeleted: true,
|
||||
DeletedByID: deletedByID,
|
||||
DeletedUnix: timeutil.TimeStampNow(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cnt == 0 {
|
||||
return fmt.Errorf("branch %s not found or has been deleted", branchName)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func RemoveDeletedBranchByID(ctx context.Context, repoID, branchID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id=? AND id=? AND is_deleted = ?", repoID, branchID, true).Delete(new(Branch))
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveOldDeletedBranches removes old deleted branches
|
||||
func RemoveOldDeletedBranches(ctx context.Context, olderThan time.Duration) {
|
||||
// Nothing to do for shutdown or terminate
|
||||
log.Trace("Doing: DeletedBranchesCleanup")
|
||||
|
||||
deleteBefore := time.Now().Add(-olderThan)
|
||||
_, err := db.GetEngine(ctx).Where("is_deleted=? AND deleted_unix < ?", true, deleteBefore.Unix()).Delete(new(Branch))
|
||||
if err != nil {
|
||||
log.Error("DeletedBranchesCleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RenamedBranch provide renamed branch log
|
||||
// will check it when a branch can't be found
|
||||
type RenamedBranch struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
From string
|
||||
To string
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
// FindRenamedBranch check if a branch was renamed
|
||||
func FindRenamedBranch(ctx context.Context, repoID int64, from string) (branch *RenamedBranch, exist bool, err error) {
|
||||
branch = &RenamedBranch{
|
||||
RepoID: repoID,
|
||||
From: from,
|
||||
}
|
||||
exist, err = db.GetEngine(ctx).Get(branch)
|
||||
|
||||
return branch, exist, err
|
||||
}
|
||||
|
||||
// RenameBranch rename a branch
|
||||
func RenameBranch(ctx context.Context, repo *repo_model.Repository, from, to string, gitAction func(ctx context.Context, isDefault bool) error) (err error) {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
// check whether from branch exist
|
||||
var branch Branch
|
||||
exist, err := db.GetEngine(ctx).Where("repo_id=? AND name=?", repo.ID, from).Get(&branch)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !exist || branch.IsDeleted {
|
||||
return ErrBranchNotExist{
|
||||
RepoID: repo.ID,
|
||||
BranchName: from,
|
||||
}
|
||||
}
|
||||
|
||||
// check whether to branch exist or is_deleted
|
||||
var dstBranch Branch
|
||||
exist, err = db.GetEngine(ctx).Where("repo_id=? AND name=?", repo.ID, to).Get(&dstBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
if !dstBranch.IsDeleted {
|
||||
return ErrBranchAlreadyExists{
|
||||
BranchName: to,
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).ID(dstBranch.ID).NoAutoCondition().Delete(&dstBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 1. update branch in database
|
||||
if n, err := sess.Where("repo_id=? AND name=?", repo.ID, from).Cols("name").Update(&Branch{
|
||||
Name: to,
|
||||
}); err != nil {
|
||||
return err
|
||||
} else if n <= 0 {
|
||||
return ErrBranchNotExist{
|
||||
RepoID: repo.ID,
|
||||
BranchName: from,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. update default branch if needed
|
||||
isDefault := repo.DefaultBranch == from
|
||||
if isDefault {
|
||||
repo.DefaultBranch = to
|
||||
_, err = sess.ID(repo.ID).Cols("default_branch").Update(repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update protected branch if needed
|
||||
protectedBranch, err := GetProtectedBranchRuleByName(ctx, repo.ID, from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if protectedBranch != nil {
|
||||
// there is a protect rule for this branch
|
||||
existingRule, err := GetProtectedBranchRuleByName(ctx, repo.ID, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existingRule == nil || existingRule.ID == protectedBranch.ID {
|
||||
protectedBranch.RuleName = to
|
||||
if _, err = sess.ID(protectedBranch.ID).Cols("branch_name").Update(protectedBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// some glob protect rules may match this branch
|
||||
protected, err := IsBranchProtected(ctx, repo.ID, from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if protected {
|
||||
return ErrBranchIsProtected
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update all not merged pull request base branch name
|
||||
_, err = sess.Table("pull_request").Where("base_repo_id=? AND base_branch=? AND has_merged=?",
|
||||
repo.ID, from, false).
|
||||
Update(map[string]any{"base_branch": to})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4.1 Update all not merged pull request head branch name
|
||||
if _, err = sess.Table("pull_request").Where("head_repo_id=? AND head_branch=? AND has_merged=?",
|
||||
repo.ID, from, false).
|
||||
Update(map[string]any{"head_branch": to}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. insert renamed branch record
|
||||
if err = db.Insert(ctx, &RenamedBranch{
|
||||
RepoID: repo.ID,
|
||||
From: from,
|
||||
To: to,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6. do git action
|
||||
return gitAction(ctx, isDefault)
|
||||
})
|
||||
}
|
||||
|
||||
type FindRecentlyPushedNewBranchesOptions struct {
|
||||
Repo *repo_model.Repository
|
||||
BaseRepo *repo_model.Repository
|
||||
PushedAfterUnix int64
|
||||
MaxCount int
|
||||
}
|
||||
|
||||
type RecentlyPushedNewBranch struct {
|
||||
BranchRepo *repo_model.Repository
|
||||
BranchName string
|
||||
BranchDisplayName string
|
||||
BranchLink string
|
||||
BranchCompareURL string
|
||||
PushedTime timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// FindRecentlyPushedNewBranches return at most 2 new branches pushed by the user in 2 hours which has no opened PRs created
|
||||
// if opts.PushedAfterUnix is 0, we will find the branches that were pushed in the last 2 hours
|
||||
// if opts.ListOptions is not set, we will only display top 2 latest branches.
|
||||
// Protected branches will be skipped since they are unlikely to be used to create new PRs.
|
||||
func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, opts FindRecentlyPushedNewBranchesOptions) ([]*RecentlyPushedNewBranch, error) {
|
||||
if doer == nil {
|
||||
return []*RecentlyPushedNewBranch{}, nil
|
||||
}
|
||||
|
||||
// find all related repo ids
|
||||
repoOpts := repo_model.SearchRepoOptions{
|
||||
Actor: doer,
|
||||
Private: true,
|
||||
AllPublic: false, // Include also all public repositories of users and public organisations
|
||||
AllLimited: false, // Include also all public repositories of limited organisations
|
||||
Fork: optional.Some(true),
|
||||
ForkFrom: opts.BaseRepo.ID,
|
||||
Archived: optional.Some(false),
|
||||
}
|
||||
repoCond := repo_model.SearchRepositoryCondition(repoOpts).And(repo_model.AccessibleRepositoryCondition(doer, unit.TypeCode))
|
||||
if opts.Repo.ID == opts.BaseRepo.ID {
|
||||
// should also include the base repo's branches
|
||||
repoCond = repoCond.Or(builder.Eq{"id": opts.BaseRepo.ID})
|
||||
} else {
|
||||
// in fork repo, we only detect the fork repo's branch
|
||||
repoCond = repoCond.And(builder.Eq{"id": opts.Repo.ID})
|
||||
}
|
||||
repoIDs := builder.Select("id").From("repository").Where(repoCond)
|
||||
|
||||
if opts.PushedAfterUnix == 0 {
|
||||
opts.PushedAfterUnix = time.Now().Add(-time.Hour * 2).Unix()
|
||||
}
|
||||
|
||||
var ignoredCommitIDs []string
|
||||
baseDefaultBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch)
|
||||
if err != nil {
|
||||
log.Warn("GetBranch:DefaultBranch: %v", err)
|
||||
} else {
|
||||
ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultBranch.CommitID)
|
||||
}
|
||||
|
||||
baseDefaultTargetBranchName := opts.BaseRepo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig().DefaultTargetBranch
|
||||
if baseDefaultTargetBranchName != "" && baseDefaultTargetBranchName != opts.BaseRepo.DefaultBranch {
|
||||
baseDefaultTargetBranch, err := GetBranch(ctx, opts.BaseRepo.ID, baseDefaultTargetBranchName)
|
||||
if err != nil {
|
||||
log.Warn("GetBranch:DefaultTargetBranch: %v", err)
|
||||
} else {
|
||||
ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultTargetBranch.CommitID)
|
||||
}
|
||||
}
|
||||
|
||||
// find all related branches, these branches may already have PRs, we will check later
|
||||
var branches []*Branch
|
||||
if err := db.GetEngine(ctx).
|
||||
Where(builder.And(
|
||||
builder.Eq{
|
||||
"pusher_id": doer.ID,
|
||||
"is_deleted": false,
|
||||
},
|
||||
builder.Gte{"updated_unix": opts.PushedAfterUnix},
|
||||
builder.In("repo_id", repoIDs),
|
||||
// newly created branch have no changes, so skip them
|
||||
builder.NotIn("commit_id", ignoredCommitIDs),
|
||||
)).
|
||||
OrderBy(db.SearchOrderByRecentUpdated.String()).
|
||||
Find(&branches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newBranches := make([]*RecentlyPushedNewBranch, 0, len(branches))
|
||||
opts.MaxCount = util.IfZero(opts.MaxCount, 2) // by default, we display 2 recently pushed new branch
|
||||
baseTargetBranchName := opts.BaseRepo.GetPullRequestTargetBranch(ctx)
|
||||
for _, branch := range branches {
|
||||
// whether the branch is protected
|
||||
protected, err := IsBranchProtected(ctx, branch.RepoID, branch.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("IsBranchProtected: %v", err)
|
||||
}
|
||||
if protected {
|
||||
// Skip protected branches,
|
||||
// since updates to protected branches often come from PR merges,
|
||||
// and they are unlikely to be used to create new PRs.
|
||||
continue
|
||||
}
|
||||
|
||||
// whether branch have already created PR
|
||||
count, err := db.GetEngine(ctx).Table("pull_request").
|
||||
// we should not only use branch name here, because if there are branches with same name in other repos,
|
||||
// we can not detect them correctly
|
||||
Where(builder.Eq{"head_repo_id": branch.RepoID, "head_branch": branch.Name}).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// if no PR, we add to the result
|
||||
if count == 0 {
|
||||
if err := branch.LoadRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branchDisplayName := branch.Name
|
||||
if branch.Repo.ID != opts.BaseRepo.ID && branch.Repo.ID != opts.Repo.ID {
|
||||
branchDisplayName = fmt.Sprintf("%s:%s", branch.Repo.FullName(), branchDisplayName)
|
||||
}
|
||||
newBranches = append(newBranches, &RecentlyPushedNewBranch{
|
||||
BranchRepo: branch.Repo,
|
||||
BranchDisplayName: branchDisplayName,
|
||||
BranchName: branch.Name,
|
||||
BranchLink: fmt.Sprintf("%s/src/branch/%s", branch.Repo.Link(), util.PathEscapeSegments(branch.Name)),
|
||||
BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, baseTargetBranchName, branch.Name),
|
||||
PushedTime: branch.UpdatedUnix,
|
||||
})
|
||||
}
|
||||
if len(newBranches) == opts.MaxCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return newBranches, nil
|
||||
}
|
||||
|
||||
// CountBranches returns the number of branches in the repository
|
||||
func CountBranches(ctx context.Context, repoID int64, includeDeleted bool) (int64, error) {
|
||||
sess := db.GetEngine(ctx).Where("repo_id=?", repoID)
|
||||
if !includeDeleted {
|
||||
sess.And("is_deleted=?", false)
|
||||
}
|
||||
return sess.Count(new(Branch))
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
type BranchList []*Branch
|
||||
|
||||
func (branches BranchList) LoadDeletedBy(ctx context.Context) error {
|
||||
ids := container.FilterSlice(branches, func(branch *Branch) (int64, bool) {
|
||||
return branch.DeletedByID, branch.IsDeleted
|
||||
})
|
||||
|
||||
usersMap := make(map[int64]*user_model.User, len(ids))
|
||||
if err := db.GetEngine(ctx).In("id", ids).Find(&usersMap); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, branch := range branches {
|
||||
if !branch.IsDeleted {
|
||||
continue
|
||||
}
|
||||
branch.DeletedBy = usersMap[branch.DeletedByID]
|
||||
if branch.DeletedBy == nil {
|
||||
branch.DeletedBy = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (branches BranchList) LoadPusher(ctx context.Context) error {
|
||||
ids := container.FilterSlice(branches, func(branch *Branch) (int64, bool) {
|
||||
// pusher_id maybe zero because some branches are sync by backend with no pusher
|
||||
return branch.PusherID, branch.PusherID > 0
|
||||
})
|
||||
|
||||
usersMap := make(map[int64]*user_model.User, len(ids))
|
||||
if err := db.GetEngine(ctx).In("id", ids).Find(&usersMap); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, branch := range branches {
|
||||
if branch.PusherID <= 0 {
|
||||
continue
|
||||
}
|
||||
branch.Pusher = usersMap[branch.PusherID]
|
||||
if branch.Pusher == nil {
|
||||
branch.Pusher = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FindBranchOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
ExcludeBranchNames []string
|
||||
IsDeletedBranch optional.Option[bool]
|
||||
OrderBy string
|
||||
Keyword string
|
||||
}
|
||||
|
||||
func (opts FindBranchOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.RepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
|
||||
}
|
||||
|
||||
if len(opts.ExcludeBranchNames) > 0 {
|
||||
cond = cond.And(builder.NotIn("name", opts.ExcludeBranchNames))
|
||||
}
|
||||
if opts.IsDeletedBranch.Has() {
|
||||
cond = cond.And(builder.Eq{"is_deleted": opts.IsDeletedBranch.Value()})
|
||||
}
|
||||
if opts.Keyword != "" {
|
||||
cond = cond.And(builder.Like{"name", opts.Keyword})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts FindBranchOptions) ToOrders() string {
|
||||
orderBy := opts.OrderBy
|
||||
if orderBy == "" {
|
||||
// the commit_time might be the same, so add the "name" to make sure the order is stable
|
||||
orderBy = "commit_time DESC, name ASC"
|
||||
}
|
||||
if opts.IsDeletedBranch.ValueOrDefault(true) { // if deleted branch included, put them at the beginning
|
||||
orderBy = "is_deleted ASC, " + orderBy
|
||||
}
|
||||
return orderBy
|
||||
}
|
||||
|
||||
func FindBranchNames(ctx context.Context, opts FindBranchOptions) ([]string, error) {
|
||||
sess := db.GetEngine(ctx).Select("name").Where(opts.ToConds())
|
||||
if opts.PageSize > 0 && !opts.IsListAll() {
|
||||
db.SetSessionPagination(sess, &opts.ListOptions)
|
||||
}
|
||||
|
||||
var branches []string
|
||||
if err := sess.Table("branch").OrderBy(opts.ToOrders()).Find(&branches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
func FindBranchesByRepoAndBranchName(ctx context.Context, repoBranches map[int64]string) (map[int64]string, error) {
|
||||
cond := builder.NewCond()
|
||||
for repoID, branchName := range repoBranches {
|
||||
cond = cond.Or(builder.And(builder.Eq{"repo_id": repoID}, builder.Eq{"name": branchName}))
|
||||
}
|
||||
var branches []*Branch
|
||||
if err := db.GetEngine(ctx).
|
||||
Where(cond).Find(&branches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
branchMap := make(map[int64]string, len(branches))
|
||||
for _, branch := range branches {
|
||||
branchMap[branch.RepoID] = branch.CommitID
|
||||
}
|
||||
return branchMap, nil
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAddDeletedBranch(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.Equal(t, git.Sha1ObjectFormat.Name(), repo.ObjectFormatName)
|
||||
firstBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 1})
|
||||
|
||||
assert.True(t, firstBranch.IsDeleted)
|
||||
assert.NoError(t, git_model.MarkBranchAsDeleted(t.Context(), repo.ID, firstBranch.Name, firstBranch.DeletedByID))
|
||||
assert.NoError(t, git_model.MarkBranchAsDeleted(t.Context(), repo.ID, "branch2", int64(1)))
|
||||
|
||||
secondBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: "branch2"})
|
||||
assert.True(t, secondBranch.IsDeleted)
|
||||
|
||||
commit := &git.Commit{
|
||||
ID: git.MustIDFromString(secondBranch.CommitID),
|
||||
CommitMessage: git.CommitMessage{MessageRaw: secondBranch.CommitMessage},
|
||||
Committer: &git.Signature{
|
||||
When: secondBranch.CommitTime.AsLocalTime(),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := git_model.UpdateBranch(t.Context(), repo.ID, secondBranch.PusherID, secondBranch.Name, commit)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGetDeletedBranches(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
branches, err := db.Find[git_model.Branch](t.Context(), git_model.FindBranchOptions{
|
||||
ListOptions: db.ListOptionsAll,
|
||||
RepoID: repo.ID,
|
||||
IsDeletedBranch: optional.Some(true),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, branches, 2)
|
||||
}
|
||||
|
||||
func TestGetDeletedBranch(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
firstBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 1})
|
||||
|
||||
assert.NotNil(t, getDeletedBranch(t, firstBranch))
|
||||
}
|
||||
|
||||
func TestFindRecentlyPushedNewBranchesUsesPushTime(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 12})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: "outdated-new-branch"})
|
||||
|
||||
commitUnix := time.Now().Add(-3 * time.Hour).Unix()
|
||||
pushUnix := time.Now().Add(-30 * time.Minute).Unix()
|
||||
_, err := db.GetEngine(t.Context()).Exec(
|
||||
"UPDATE branch SET commit_time = ?, updated_unix = ? WHERE id = ?",
|
||||
commitUnix,
|
||||
pushUnix,
|
||||
branch.ID,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
branches, err := git_model.FindRecentlyPushedNewBranches(t.Context(), doer, git_model.FindRecentlyPushedNewBranchesOptions{
|
||||
Repo: repo,
|
||||
BaseRepo: repo,
|
||||
PushedAfterUnix: time.Now().Add(-time.Hour).Unix(),
|
||||
MaxCount: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, branches, 1) {
|
||||
assert.Equal(t, branch.Name, branches[0].BranchName)
|
||||
assert.Equal(t, timeutil.TimeStamp(pushUnix), branches[0].PushedTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedBranchLoadUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
firstBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 1})
|
||||
secondBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 2})
|
||||
|
||||
branch := getDeletedBranch(t, firstBranch)
|
||||
assert.Nil(t, branch.DeletedBy)
|
||||
branch.LoadDeletedBy(t.Context())
|
||||
assert.NotNil(t, branch.DeletedBy)
|
||||
assert.Equal(t, "user1", branch.DeletedBy.Name)
|
||||
|
||||
branch = getDeletedBranch(t, secondBranch)
|
||||
assert.Nil(t, branch.DeletedBy)
|
||||
branch.LoadDeletedBy(t.Context())
|
||||
assert.NotNil(t, branch.DeletedBy)
|
||||
assert.Equal(t, "Ghost", branch.DeletedBy.Name)
|
||||
}
|
||||
|
||||
func TestRemoveDeletedBranch(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
firstBranch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 1})
|
||||
|
||||
err := git_model.RemoveDeletedBranchByID(t.Context(), repo.ID, 1)
|
||||
assert.NoError(t, err)
|
||||
unittest.AssertNotExistsBean(t, firstBranch)
|
||||
unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 2})
|
||||
}
|
||||
|
||||
func getDeletedBranch(t *testing.T, branch *git_model.Branch) *git_model.Branch {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
deletedBranch, err := git_model.GetDeletedBranchByID(t.Context(), repo.ID, branch.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, branch.ID, deletedBranch.ID)
|
||||
assert.Equal(t, branch.Name, deletedBranch.Name)
|
||||
assert.Equal(t, branch.CommitID, deletedBranch.CommitID)
|
||||
assert.Equal(t, branch.DeletedByID, deletedBranch.DeletedByID)
|
||||
|
||||
return deletedBranch
|
||||
}
|
||||
|
||||
func TestFindRenamedBranch(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
branch, exist, err := git_model.FindRenamedBranch(t.Context(), 1, "dev")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exist)
|
||||
assert.Equal(t, "master", branch.To)
|
||||
|
||||
_, exist, err = git_model.FindRenamedBranch(t.Context(), 1, "unknown")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exist)
|
||||
}
|
||||
|
||||
func TestRenameBranch(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
_isDefault := false
|
||||
|
||||
ctx, committer, err := db.TxContext(t.Context())
|
||||
defer committer.Close()
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, git_model.UpdateProtectBranch(ctx, repo1, &git_model.ProtectedBranch{
|
||||
RepoID: repo1.ID,
|
||||
RuleName: "master",
|
||||
}, git_model.WhitelistOptions{}))
|
||||
assert.NoError(t, committer.Commit())
|
||||
|
||||
assert.NoError(t, git_model.RenameBranch(t.Context(), repo1, "master", "main", func(ctx context.Context, isDefault bool) error {
|
||||
_isDefault = isDefault
|
||||
return nil
|
||||
}))
|
||||
|
||||
assert.True(t, _isDefault)
|
||||
repo1 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.Equal(t, "main", repo1.DefaultBranch)
|
||||
|
||||
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) // merged
|
||||
assert.Equal(t, "master", pull.BaseBranch)
|
||||
|
||||
pull = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) // open
|
||||
assert.Equal(t, "main", pull.BaseBranch)
|
||||
|
||||
renamedBranch := unittest.AssertExistsAndLoadBean(t, &git_model.RenamedBranch{ID: 2})
|
||||
assert.Equal(t, "master", renamedBranch.From)
|
||||
assert.Equal(t, "main", renamedBranch.To)
|
||||
assert.Equal(t, int64(1), renamedBranch.RepoID)
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &git_model.ProtectedBranch{
|
||||
RepoID: repo1.ID,
|
||||
RuleName: "main",
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenameBranchProtectedRuleConflict(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
master := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "master"})
|
||||
|
||||
devBranch := &git_model.Branch{
|
||||
RepoID: repo1.ID,
|
||||
Name: "dev",
|
||||
CommitID: master.CommitID,
|
||||
CommitMessage: master.CommitMessage,
|
||||
CommitTime: master.CommitTime,
|
||||
PusherID: master.PusherID,
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), devBranch))
|
||||
|
||||
pbDev := git_model.ProtectedBranch{
|
||||
RepoID: repo1.ID,
|
||||
RuleName: "dev",
|
||||
CanPush: true,
|
||||
}
|
||||
assert.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, &pbDev, git_model.WhitelistOptions{}))
|
||||
|
||||
pbMain := git_model.ProtectedBranch{
|
||||
RepoID: repo1.ID,
|
||||
RuleName: "main",
|
||||
CanPush: true,
|
||||
}
|
||||
assert.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, &pbMain, git_model.WhitelistOptions{}))
|
||||
|
||||
assert.NoError(t, git_model.RenameBranch(t.Context(), repo1, "dev", "main", func(ctx context.Context, isDefault bool) error {
|
||||
return nil
|
||||
}))
|
||||
|
||||
unittest.AssertNotExistsBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "dev"})
|
||||
unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "main"})
|
||||
|
||||
protectedDev, err := git_model.GetProtectedBranchRuleByName(t.Context(), repo1.ID, "dev")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, protectedDev)
|
||||
assert.Equal(t, "dev", protectedDev.RuleName)
|
||||
|
||||
protectedMainByID, err := git_model.GetProtectedBranchRuleByID(t.Context(), repo1.ID, pbMain.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, protectedMainByID)
|
||||
assert.Equal(t, "main", protectedMainByID.RuleName)
|
||||
}
|
||||
|
||||
func TestOnlyGetDeletedBranchOnCorrectRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Get deletedBranch with ID of 1 on repo with ID 2.
|
||||
// This should return a nil branch as this deleted branch
|
||||
// is actually on repo with ID 1.
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
|
||||
deletedBranch, err := git_model.GetDeletedBranchByID(t.Context(), repo2.ID, 1)
|
||||
|
||||
// Expect error, and the returned branch is nil.
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, deletedBranch)
|
||||
|
||||
// Now get the deletedBranch with ID of 1 on repo with ID 1.
|
||||
// This should return the deletedBranch.
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
deletedBranch, err = git_model.GetDeletedBranchByID(t.Context(), repo1.ID, 1)
|
||||
|
||||
// Expect no error, and the returned branch to be not nil.
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, deletedBranch)
|
||||
}
|
||||
|
||||
func TestCountBranches(t *testing.T) {
|
||||
// 1. Setup - Exactly like TestAddDeletedBranch
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
// 2. Execution - Using t.Context() to match the rest of the file
|
||||
initialCount, err := git_model.CountBranches(t.Context(), repo.ID, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 3. Database Action - Using t.Context()
|
||||
err = db.Insert(t.Context(), &git_model.Branch{
|
||||
RepoID: repo.ID,
|
||||
Name: "test-branch-for-counting",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 4. Verification
|
||||
newCount, err := git_model.CountBranches(t.Context(), repo.ID, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, initialCount+1, newCount)
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
// Copyright 2017 Gitea. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// CommitStatus holds a single Status of a single Commit
|
||||
type CommitStatus struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
|
||||
RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
|
||||
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"`
|
||||
|
||||
// TargetURL points to the commit status page reported by a CI system
|
||||
// If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}"
|
||||
TargetURL string `xorm:"TEXT"`
|
||||
|
||||
Description string `xorm:"TEXT"`
|
||||
ContextHash string `xorm:"VARCHAR(64) index"`
|
||||
Context string `xorm:"TEXT"`
|
||||
Creator *user_model.User `xorm:"-"`
|
||||
CreatorID int64
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CommitStatus))
|
||||
db.RegisterModel(new(CommitStatusIndex))
|
||||
}
|
||||
|
||||
func postgresGetCommitStatusIndex(ctx context.Context, repoID int64, sha string) (int64, error) {
|
||||
res, err := db.GetEngine(ctx).Query("INSERT INTO `commit_status_index` (repo_id, sha, max_index) "+
|
||||
"VALUES (?,?,1) ON CONFLICT (repo_id, sha) DO UPDATE SET max_index = `commit_status_index`.max_index+1 RETURNING max_index",
|
||||
repoID, sha)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return 0, db.ErrGetResourceIndexFailed
|
||||
}
|
||||
return strconv.ParseInt(string(res[0]["max_index"]), 10, 64)
|
||||
}
|
||||
|
||||
func mysqlGetCommitStatusIndex(ctx context.Context, repoID int64, sha string) (int64, error) {
|
||||
if _, err := db.GetEngine(ctx).Exec("INSERT INTO `commit_status_index` (repo_id, sha, max_index) "+
|
||||
"VALUES (?,?,1) ON DUPLICATE KEY UPDATE max_index = max_index+1",
|
||||
repoID, sha); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var idx int64
|
||||
_, err := db.GetEngine(ctx).SQL("SELECT max_index FROM `commit_status_index` WHERE repo_id = ? AND sha = ?",
|
||||
repoID, sha).Get(&idx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if idx == 0 {
|
||||
return 0, errors.New("cannot get the correct index")
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func mssqlGetCommitStatusIndex(ctx context.Context, repoID int64, sha string) (int64, error) {
|
||||
if _, err := db.GetEngine(ctx).Exec(`
|
||||
MERGE INTO commit_status_index WITH (HOLDLOCK) AS target
|
||||
USING (SELECT ? AS repo_id, ? AS sha) AS source
|
||||
(repo_id, sha)
|
||||
ON target.repo_id = source.repo_id AND target.sha = source.sha
|
||||
WHEN MATCHED
|
||||
THEN UPDATE
|
||||
SET max_index = max_index + 1
|
||||
WHEN NOT MATCHED
|
||||
THEN INSERT (repo_id, sha, max_index)
|
||||
VALUES (?, ?, 1);
|
||||
`, repoID, sha, repoID, sha); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var idx int64
|
||||
_, err := db.GetEngine(ctx).SQL("SELECT max_index FROM `commit_status_index` WHERE repo_id = ? AND sha = ?",
|
||||
repoID, sha).Get(&idx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if idx == 0 {
|
||||
return 0, errors.New("cannot get the correct index")
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// GetNextCommitStatusIndex retried 3 times to generate a resource index
|
||||
func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (int64, error) {
|
||||
_, err := git.NewIDFromString(sha)
|
||||
if err != nil {
|
||||
return 0, git.ErrInvalidSHA{SHA: sha}
|
||||
}
|
||||
|
||||
switch {
|
||||
case setting.Database.Type.IsPostgreSQL():
|
||||
return postgresGetCommitStatusIndex(ctx, repoID, sha)
|
||||
case setting.Database.Type.IsMySQL():
|
||||
return mysqlGetCommitStatusIndex(ctx, repoID, sha)
|
||||
case setting.Database.Type.IsMSSQL():
|
||||
return mssqlGetCommitStatusIndex(ctx, repoID, sha)
|
||||
}
|
||||
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
// try to update the max_index to next value, and acquire the write-lock for the record
|
||||
res, err := e.Exec("UPDATE `commit_status_index` SET max_index=max_index+1 WHERE repo_id=? AND sha=?", repoID, sha)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("update failed: %w", err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if affected == 0 {
|
||||
// this slow path is only for the first time of creating a resource index
|
||||
_, errIns := e.Exec("INSERT INTO `commit_status_index` (repo_id, sha, max_index) VALUES (?, ?, 0)", repoID, sha)
|
||||
res, err = e.Exec("UPDATE `commit_status_index` SET max_index=max_index+1 WHERE repo_id=? AND sha=?", repoID, sha)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("update2 failed: %w", err)
|
||||
}
|
||||
affected, err = res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("RowsAffected failed: %w", err)
|
||||
}
|
||||
// if the update still can not update any records, the record must not exist and there must be some errors (insert error)
|
||||
if affected == 0 {
|
||||
if errIns == nil {
|
||||
return 0, errors.New("impossible error when GetNextCommitStatusIndex, insert and update both succeeded but no record is updated")
|
||||
}
|
||||
return 0, fmt.Errorf("insert failed: %w", errIns)
|
||||
}
|
||||
}
|
||||
|
||||
// now, the new index is in database (protected by the transaction and write-lock)
|
||||
var newIdx int64
|
||||
has, err := e.SQL("SELECT max_index FROM `commit_status_index` WHERE repo_id=? AND sha=?", repoID, sha).Get(&newIdx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("select failed: %w", err)
|
||||
}
|
||||
if !has {
|
||||
return 0, errors.New("impossible error when GetNextCommitStatusIndex, upsert succeeded but no record can be selected")
|
||||
}
|
||||
return newIdx, nil
|
||||
}
|
||||
|
||||
func (status *CommitStatus) loadRepository(ctx context.Context) (err error) {
|
||||
if status.Repo == nil {
|
||||
status.Repo, err = repo_model.GetRepositoryByID(ctx, status.RepoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (status *CommitStatus) loadCreator(ctx context.Context) (err error) {
|
||||
if status.Creator == nil && status.CreatorID > 0 {
|
||||
status.Creator, err = user_model.GetUserByID(ctx, status.CreatorID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getUserByID [%d]: %w", status.CreatorID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (status *CommitStatus) loadAttributes(ctx context.Context) (err error) {
|
||||
if err := status.loadRepository(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return status.loadCreator(ctx)
|
||||
}
|
||||
|
||||
// APIURL returns the absolute APIURL to this commit-status.
|
||||
func (status *CommitStatus) APIURL(ctx context.Context) string {
|
||||
_ = status.loadAttributes(ctx)
|
||||
return status.Repo.APIURL() + "/statuses/" + url.PathEscape(status.SHA)
|
||||
}
|
||||
|
||||
// LocaleString returns the locale string name of the Status
|
||||
func (status *CommitStatus) LocaleString(lang translation.Locale) string {
|
||||
return lang.TrString("repo.commitstatus." + status.State.String())
|
||||
}
|
||||
|
||||
// HideActionsURL set `TargetURL` to an empty string if the status comes from Gitea Actions
|
||||
func (status *CommitStatus) HideActionsURL(ctx context.Context) {
|
||||
if _, ok := status.cutTargetURLGiteaActionsPrefix(ctx); ok {
|
||||
status.TargetURL = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context) (string, bool) {
|
||||
if status.RepoID == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if status.Repo == nil {
|
||||
if err := status.loadRepository(ctx); err != nil {
|
||||
log.Error("loadRepository: %v", err)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
prefix := status.Repo.Link() + "/actions"
|
||||
return strings.CutPrefix(status.TargetURL, prefix)
|
||||
}
|
||||
|
||||
// ParseGiteaActionsTargetURL parses the commit status target URL as Gitea Actions link
|
||||
func (status *CommitStatus) ParseGiteaActionsTargetURL(ctx context.Context) (runID, jobID int64, ok bool) {
|
||||
s, ok := status.cutTargetURLGiteaActionsPrefix(ctx)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
parts := strings.Split(s, "/") // expect: /runs/{runID}/jobs/{jobID}
|
||||
if len(parts) < 5 || parts[1] != "runs" || parts[3] != "jobs" {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
runID, err1 := strconv.ParseInt(parts[2], 10, 64)
|
||||
jobID, err2 := strconv.ParseInt(parts[4], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return runID, jobID, true
|
||||
}
|
||||
|
||||
// CalcCommitStatus returns commit status state via some status, the commit statues should order by id desc
|
||||
func CalcCommitStatus(statuses []*CommitStatus) *CommitStatus {
|
||||
if len(statuses) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
states := make(commitstatus.CommitStatusStates, 0, len(statuses))
|
||||
targetURL := ""
|
||||
for _, status := range statuses {
|
||||
states = append(states, status.State)
|
||||
if status.TargetURL != "" {
|
||||
targetURL = status.TargetURL
|
||||
}
|
||||
}
|
||||
|
||||
return &CommitStatus{
|
||||
RepoID: statuses[0].RepoID,
|
||||
SHA: statuses[0].SHA,
|
||||
State: states.Combine(),
|
||||
TargetURL: targetURL,
|
||||
}
|
||||
}
|
||||
|
||||
// CommitStatusOptions holds the options for query commit statuses
|
||||
type CommitStatusOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
SHA string
|
||||
State string
|
||||
SortType string
|
||||
}
|
||||
|
||||
func (opts *CommitStatusOptions) ToConds() builder.Cond {
|
||||
var cond builder.Cond = builder.Eq{
|
||||
"repo_id": opts.RepoID,
|
||||
"sha": opts.SHA,
|
||||
}
|
||||
|
||||
switch opts.State {
|
||||
case "pending", "success", "error", "failure", "warning":
|
||||
cond = cond.And(builder.Eq{
|
||||
"state": opts.State,
|
||||
})
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts *CommitStatusOptions) ToOrders() string {
|
||||
switch opts.SortType {
|
||||
case "oldest":
|
||||
return "created_unix ASC"
|
||||
case "recentupdate":
|
||||
return "updated_unix DESC"
|
||||
case "leastupdate":
|
||||
return "updated_unix ASC"
|
||||
case "leastindex":
|
||||
return "`index` DESC"
|
||||
case "highestindex":
|
||||
return "`index` ASC"
|
||||
default:
|
||||
return "created_unix DESC"
|
||||
}
|
||||
}
|
||||
|
||||
// CommitStatusIndex represents a table for commit status index
|
||||
type CommitStatusIndex struct {
|
||||
ID int64
|
||||
RepoID int64 `xorm:"unique(repo_sha)"`
|
||||
SHA string `xorm:"unique(repo_sha)"`
|
||||
MaxIndex int64 `xorm:"index"`
|
||||
}
|
||||
|
||||
func makeRepoCommitQuery(ctx context.Context, repoID int64, sha string) db.Session {
|
||||
return db.GetEngine(ctx).Table(&CommitStatus{}).
|
||||
Where("repo_id = ?", repoID).And("sha = ?", sha)
|
||||
}
|
||||
|
||||
// GetLatestCommitStatus returns all statuses with a unique context for a given commit.
|
||||
func GetLatestCommitStatus(ctx context.Context, repoID int64, sha string, listOptions db.ListOptions) ([]*CommitStatus, error) {
|
||||
indices := make([]int64, 0, 10)
|
||||
sess := makeRepoCommitQuery(ctx, repoID, sha)
|
||||
sess.Select("max( `index` ) as `index`").GroupBy("context_hash").OrderBy("max( `index` ) desc")
|
||||
if !listOptions.IsListAll() {
|
||||
db.SetSessionPagination(sess, &listOptions)
|
||||
}
|
||||
if err := sess.Find(&indices); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statuses := make([]*CommitStatus, 0, len(indices))
|
||||
if len(indices) == 0 {
|
||||
return statuses, nil
|
||||
}
|
||||
err := makeRepoCommitQuery(ctx, repoID, sha).And(builder.In("`index`", indices)).Find(&statuses)
|
||||
return statuses, err
|
||||
}
|
||||
|
||||
func CountLatestCommitStatus(ctx context.Context, repoID int64, sha string) (int64, error) {
|
||||
return makeRepoCommitQuery(ctx, repoID, sha).
|
||||
Select("count(context_hash)").
|
||||
GroupBy("context_hash").
|
||||
Count()
|
||||
}
|
||||
|
||||
// GetLatestCommitStatusForPairs returns all statuses with a unique context for a given list of repo-sha pairs
|
||||
func GetLatestCommitStatusForPairs(ctx context.Context, repoSHAs []RepoSHA) (map[int64][]*CommitStatus, error) {
|
||||
type result struct {
|
||||
Index int64
|
||||
RepoID int64
|
||||
SHA string
|
||||
}
|
||||
|
||||
results := make([]result, 0, len(repoSHAs))
|
||||
|
||||
getBase := func() db.Session {
|
||||
return db.GetEngine(ctx).Table(&CommitStatus{})
|
||||
}
|
||||
|
||||
// Create a disjunction of conditions for each repoID and SHA pair
|
||||
conds := make([]builder.Cond, 0, len(repoSHAs))
|
||||
for _, repoSHA := range repoSHAs {
|
||||
conds = append(conds, builder.Eq{"repo_id": repoSHA.RepoID, "sha": repoSHA.SHA})
|
||||
}
|
||||
sess := getBase().Where(builder.Or(conds...)).
|
||||
Select("max( `index` ) as `index`, repo_id, sha").
|
||||
GroupBy("context_hash, repo_id, sha").OrderBy("max( `index` ) desc")
|
||||
|
||||
err := sess.Find(&results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repoStatuses := make(map[int64][]*CommitStatus)
|
||||
|
||||
if len(results) > 0 {
|
||||
statuses := make([]*CommitStatus, 0, len(results))
|
||||
|
||||
conds = make([]builder.Cond, 0, len(results))
|
||||
for _, result := range results {
|
||||
cond := builder.Eq{
|
||||
"`index`": result.Index,
|
||||
"repo_id": result.RepoID,
|
||||
"sha": result.SHA,
|
||||
}
|
||||
conds = append(conds, cond)
|
||||
}
|
||||
err = getBase().Where(builder.Or(conds...)).Find(&statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Group the statuses by repo ID
|
||||
for _, status := range statuses {
|
||||
repoStatuses[status.RepoID] = append(repoStatuses[status.RepoID], status)
|
||||
}
|
||||
}
|
||||
|
||||
return repoStatuses, nil
|
||||
}
|
||||
|
||||
// GetLatestCommitStatusForRepoCommitIDs returns all statuses with a unique context for a given list of repo-sha pairs
|
||||
func GetLatestCommitStatusForRepoCommitIDs(ctx context.Context, repoID int64, commitIDs []string) (map[string][]*CommitStatus, error) {
|
||||
type result struct {
|
||||
Index int64
|
||||
SHA string
|
||||
}
|
||||
|
||||
getBase := func() db.Session {
|
||||
return db.GetEngine(ctx).Table(&CommitStatus{}).Where("repo_id = ?", repoID)
|
||||
}
|
||||
results := make([]result, 0, len(commitIDs))
|
||||
|
||||
conds := make([]builder.Cond, 0, len(commitIDs))
|
||||
for _, sha := range commitIDs {
|
||||
conds = append(conds, builder.Eq{"sha": sha})
|
||||
}
|
||||
sess := getBase().And(builder.Or(conds...)).
|
||||
Select("max( `index` ) as `index`, sha").
|
||||
GroupBy("context_hash, sha").OrderBy("max( `index` ) desc")
|
||||
|
||||
err := sess.Find(&results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repoStatuses := make(map[string][]*CommitStatus)
|
||||
|
||||
if len(results) > 0 {
|
||||
statuses := make([]*CommitStatus, 0, len(results))
|
||||
|
||||
conds = make([]builder.Cond, 0, len(results))
|
||||
for _, result := range results {
|
||||
conds = append(conds, builder.Eq{"`index`": result.Index, "sha": result.SHA})
|
||||
}
|
||||
err = getBase().And(builder.Or(conds...)).Find(&statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Group the statuses by commit
|
||||
for _, status := range statuses {
|
||||
repoStatuses[status.SHA] = append(repoStatuses[status.SHA], status)
|
||||
}
|
||||
}
|
||||
|
||||
return repoStatuses, nil
|
||||
}
|
||||
|
||||
// FindRepoRecentCommitStatusContexts returns repository's recent commit status contexts
|
||||
func FindRepoRecentCommitStatusContexts(ctx context.Context, repoID int64, before time.Duration) ([]string, error) {
|
||||
start := timeutil.TimeStampNow().AddDuration(-before)
|
||||
|
||||
var contexts []string
|
||||
if err := db.GetEngine(ctx).Table("commit_status").
|
||||
Where("repo_id = ?", repoID).And("updated_unix >= ?", start).
|
||||
Cols("context").Distinct().Find(&contexts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return contexts, nil
|
||||
}
|
||||
|
||||
// NewCommitStatusOptions holds options for creating a CommitStatus
|
||||
type NewCommitStatusOptions struct {
|
||||
Repo *repo_model.Repository
|
||||
Creator *user_model.User
|
||||
SHA git.ObjectID
|
||||
CommitStatus *CommitStatus
|
||||
}
|
||||
|
||||
// NewCommitStatus save commit statuses into database
|
||||
func NewCommitStatus(ctx context.Context, opts NewCommitStatusOptions) error {
|
||||
if opts.Repo == nil {
|
||||
return fmt.Errorf("NewCommitStatus[nil, %s]: no repository specified", opts.SHA)
|
||||
}
|
||||
|
||||
if opts.Creator == nil {
|
||||
return fmt.Errorf("NewCommitStatus[%s, %s]: no user specified", opts.Repo.FullName(), opts.SHA)
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// Get the next Status Index
|
||||
idx, err := GetNextCommitStatusIndex(ctx, opts.Repo.ID, opts.SHA.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate commit status index failed: %w", err)
|
||||
}
|
||||
|
||||
opts.CommitStatus.Description = strings.TrimSpace(opts.CommitStatus.Description)
|
||||
opts.CommitStatus.Context = strings.TrimSpace(opts.CommitStatus.Context)
|
||||
opts.CommitStatus.TargetURL = strings.TrimSpace(opts.CommitStatus.TargetURL)
|
||||
opts.CommitStatus.SHA = opts.SHA.String()
|
||||
opts.CommitStatus.CreatorID = opts.Creator.ID
|
||||
opts.CommitStatus.RepoID = opts.Repo.ID
|
||||
opts.CommitStatus.Index = idx
|
||||
log.Debug("NewCommitStatus[%s, %s]: %d", opts.Repo.FullName(), opts.SHA, opts.CommitStatus.Index)
|
||||
|
||||
opts.CommitStatus.ContextHash = hashCommitStatusContext(opts.CommitStatus.Context)
|
||||
|
||||
// Insert new CommitStatus
|
||||
if err = db.Insert(ctx, opts.CommitStatus); err != nil {
|
||||
return fmt.Errorf("insert CommitStatus[%s, %s]: %w", opts.Repo.FullName(), opts.SHA, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SignCommitWithStatuses represents a commit with validation of signature and status state.
|
||||
type SignCommitWithStatuses struct {
|
||||
Status *CommitStatus
|
||||
Statuses []*CommitStatus
|
||||
*asymkey_model.SignCommit
|
||||
}
|
||||
|
||||
// hashCommitStatusContext hash context
|
||||
func hashCommitStatusContext(context string) string {
|
||||
return fmt.Sprintf("%x", sha1.Sum([]byte(context)))
|
||||
}
|
||||
|
||||
// CommitStatusesHideActionsURL hide Gitea Actions urls
|
||||
func CommitStatusesHideActionsURL(ctx context.Context, statuses []*CommitStatus) {
|
||||
idToRepos := make(map[int64]*repo_model.Repository)
|
||||
for _, status := range statuses {
|
||||
if status == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if status.Repo == nil {
|
||||
status.Repo = idToRepos[status.RepoID]
|
||||
}
|
||||
status.HideActionsURL(ctx)
|
||||
idToRepos[status.RepoID] = status.Repo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2024 Gitea. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// CommitStatusSummary holds the latest commit Status of a single Commit
|
||||
type CommitStatusSummary struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX UNIQUE(repo_id_sha)"`
|
||||
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_id_sha)"`
|
||||
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
|
||||
TargetURL string `xorm:"TEXT"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(CommitStatusSummary))
|
||||
}
|
||||
|
||||
type RepoSHA struct {
|
||||
RepoID int64
|
||||
SHA string
|
||||
}
|
||||
|
||||
func GetLatestCommitStatusForRepoAndSHAs(ctx context.Context, repoSHAs []RepoSHA) ([]*CommitStatus, error) {
|
||||
cond := builder.NewCond()
|
||||
for _, rs := range repoSHAs {
|
||||
cond = cond.Or(builder.Eq{"repo_id": rs.RepoID, "sha": rs.SHA})
|
||||
}
|
||||
|
||||
var summaries []CommitStatusSummary
|
||||
if err := db.GetEngine(ctx).Where(cond).Find(&summaries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commitStatuses := make([]*CommitStatus, 0, len(repoSHAs))
|
||||
for _, summary := range summaries {
|
||||
commitStatuses = append(commitStatuses, &CommitStatus{
|
||||
RepoID: summary.RepoID,
|
||||
SHA: summary.SHA,
|
||||
State: summary.State,
|
||||
TargetURL: summary.TargetURL,
|
||||
})
|
||||
}
|
||||
return commitStatuses, nil
|
||||
}
|
||||
|
||||
func UpdateCommitStatusSummary(ctx context.Context, repoID int64, sha string) error {
|
||||
commitStatuses, err := GetLatestCommitStatus(ctx, repoID, sha, db.ListOptionsAll)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// it guarantees that commitStatuses is not empty because this function is always called after a commit status is created
|
||||
if len(commitStatuses) == 0 {
|
||||
setting.PanicInDevOrTesting("no commit statuses found for repo %d and sha %s", repoID, sha)
|
||||
}
|
||||
state := CalcCommitStatus(commitStatuses) // non-empty commitStatuses is guaranteed
|
||||
// mysql will return 0 when update a record which state hasn't been changed which behaviour is different from other database,
|
||||
// so we need to use insert in on duplicate
|
||||
if setting.Database.Type.IsMySQL() {
|
||||
_, err := db.GetEngine(ctx).Exec("INSERT INTO commit_status_summary (repo_id,sha,state,target_url) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE state=?",
|
||||
repoID, sha, state.State, state.TargetURL, state.State)
|
||||
return err
|
||||
}
|
||||
|
||||
if cnt, err := db.GetEngine(ctx).Where("repo_id=? AND sha=?", repoID, sha).
|
||||
Cols("state, target_url").
|
||||
Update(&CommitStatusSummary{
|
||||
State: state.State,
|
||||
TargetURL: state.TargetURL,
|
||||
}); err != nil {
|
||||
return err
|
||||
} else if cnt == 0 {
|
||||
_, err = db.GetEngine(ctx).Insert(&CommitStatusSummary{
|
||||
RepoID: repoID,
|
||||
SHA: sha,
|
||||
State: state.State,
|
||||
TargetURL: state.TargetURL,
|
||||
})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright 2017 Gitea. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetCommitStatuses(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
sha1 := "1234123412341234123412341234123412341234" // the mocked commit ID in test fixtures
|
||||
|
||||
statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: 50},
|
||||
RepoID: repo1.ID,
|
||||
SHA: sha1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 5, int(maxResults))
|
||||
assert.Len(t, statuses, 5)
|
||||
|
||||
assert.Equal(t, "ci/awesomeness", statuses[0].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "cov/awesomeness", statuses[1].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusWarning, statuses[1].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[1].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "cov/awesomeness", statuses[2].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[2].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "ci/awesomeness", statuses[3].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[3].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[3].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "deploy/awesomeness", statuses[4].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusError, statuses[4].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(t.Context()))
|
||||
|
||||
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
||||
ListOptions: db.ListOptions{Page: 2, PageSize: 50},
|
||||
RepoID: repo1.ID,
|
||||
SHA: sha1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 5, int(maxResults))
|
||||
assert.Empty(t, statuses)
|
||||
}
|
||||
|
||||
func Test_CalcCommitStatus(t *testing.T) {
|
||||
kases := []struct {
|
||||
statuses []*git_model.CommitStatus
|
||||
expected *git_model.CommitStatus
|
||||
}{
|
||||
{
|
||||
statuses: []*git_model.CommitStatus{
|
||||
{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
},
|
||||
},
|
||||
expected: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
},
|
||||
},
|
||||
{
|
||||
statuses: []*git_model.CommitStatus{
|
||||
{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
},
|
||||
},
|
||||
expected: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
},
|
||||
},
|
||||
{
|
||||
statuses: []*git_model.CommitStatus{
|
||||
{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
},
|
||||
expected: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
},
|
||||
},
|
||||
{
|
||||
statuses: []*git_model.CommitStatus{
|
||||
{
|
||||
State: commitstatus.CommitStatusError,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
},
|
||||
expected: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusFailure,
|
||||
},
|
||||
},
|
||||
{
|
||||
statuses: []*git_model.CommitStatus{
|
||||
{
|
||||
State: commitstatus.CommitStatusWarning,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusPending,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
},
|
||||
expected: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusFailure,
|
||||
},
|
||||
},
|
||||
{
|
||||
statuses: []*git_model.CommitStatus{
|
||||
{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
},
|
||||
expected: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
},
|
||||
{
|
||||
statuses: []*git_model.CommitStatus{
|
||||
{
|
||||
State: commitstatus.CommitStatusFailure,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusError,
|
||||
},
|
||||
{
|
||||
State: commitstatus.CommitStatusWarning,
|
||||
},
|
||||
},
|
||||
expected: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusFailure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, kase := range kases {
|
||||
assert.Equal(t, kase.expected, git_model.CalcCommitStatus(kase.statuses), "statuses: %v", kase.statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRepoRecentCommitStatusContexts(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo2)
|
||||
assert.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(repo2.DefaultBranch)
|
||||
assert.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
_, err := db.DeleteByBean(t.Context(), &git_model.CommitStatus{
|
||||
RepoID: repo2.ID,
|
||||
CreatorID: user2.ID,
|
||||
SHA: commit.ID.String(),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
err = git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
|
||||
Repo: repo2,
|
||||
Creator: user2,
|
||||
SHA: commit.ID,
|
||||
CommitStatus: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusFailure,
|
||||
TargetURL: "https://example.com/tests/",
|
||||
Context: "compliance/lint-backend",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
|
||||
Repo: repo2,
|
||||
Creator: user2,
|
||||
SHA: commit.ID,
|
||||
CommitStatus: &git_model.CommitStatus{
|
||||
State: commitstatus.CommitStatusSuccess,
|
||||
TargetURL: "https://example.com/tests/",
|
||||
Context: "compliance/lint-backend",
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
contexts, err := git_model.FindRepoRecentCommitStatusContexts(t.Context(), repo2.ID, time.Hour)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, contexts, 1) {
|
||||
assert.Equal(t, "compliance/lint-backend", contexts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitStatusesHideActionsURL(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791, RepoID: repo.ID})
|
||||
assert.NoError(t, run.LoadAttributes(t.Context()))
|
||||
|
||||
statuses := []*git_model.CommitStatus{
|
||||
{
|
||||
RepoID: repo.ID,
|
||||
TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), run.ID),
|
||||
},
|
||||
{
|
||||
RepoID: repo.ID,
|
||||
TargetURL: "https://mycicd.org/1",
|
||||
},
|
||||
}
|
||||
|
||||
git_model.CommitStatusesHideActionsURL(t.Context(), statuses)
|
||||
assert.Empty(t, statuses[0].TargetURL)
|
||||
assert.Equal(t, "https://mycicd.org/1", statuses[1].TargetURL)
|
||||
}
|
||||
|
||||
func TestGetCountLatestCommitStatus(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
sha1 := "1234123412341234123412341234123412341234" // the mocked commit ID in test fixtures
|
||||
|
||||
commitStatuses, err := git_model.GetLatestCommitStatus(t.Context(), repo1.ID, sha1, db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, commitStatuses, 2)
|
||||
assert.Equal(t, commitstatus.CommitStatusFailure, commitStatuses[0].State)
|
||||
assert.Equal(t, "ci/awesomeness", commitStatuses[0].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusError, commitStatuses[1].State)
|
||||
assert.Equal(t, "deploy/awesomeness", commitStatuses[1].Context)
|
||||
|
||||
count, err := git_model.CountLatestCommitStatus(t.Context(), repo1.ID, sha1)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 3, count)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrLFSLockNotExist represents a "LFSLockNotExist" kind of error.
|
||||
type ErrLFSLockNotExist struct {
|
||||
ID int64
|
||||
RepoID int64
|
||||
Path string
|
||||
}
|
||||
|
||||
// IsErrLFSLockNotExist checks if an error is a ErrLFSLockNotExist.
|
||||
func IsErrLFSLockNotExist(err error) bool {
|
||||
_, ok := err.(ErrLFSLockNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrLFSLockNotExist) Error() string {
|
||||
return fmt.Sprintf("lfs lock does not exist [id: %d, rid: %d, path: %s]", err.ID, err.RepoID, err.Path)
|
||||
}
|
||||
|
||||
func (err ErrLFSLockNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrLFSLockAlreadyExist represents a "LFSLockAlreadyExist" kind of error.
|
||||
type ErrLFSLockAlreadyExist struct {
|
||||
RepoID int64
|
||||
Path string
|
||||
}
|
||||
|
||||
// IsErrLFSLockAlreadyExist checks if an error is a ErrLFSLockAlreadyExist.
|
||||
func IsErrLFSLockAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrLFSLockAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrLFSLockAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("lfs lock already exists [rid: %d, path: %s]", err.RepoID, err.Path)
|
||||
}
|
||||
|
||||
func (err ErrLFSLockAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrLFSFileLocked represents a "LFSFileLocked" kind of error.
|
||||
type ErrLFSFileLocked struct {
|
||||
RepoID int64
|
||||
Path string
|
||||
UserName string
|
||||
}
|
||||
|
||||
func (err ErrLFSFileLocked) Error() string {
|
||||
return fmt.Sprintf("File is lfs locked [repo: %d, locked by: %s, path: %s]", err.RepoID, err.UserName, err.Path)
|
||||
}
|
||||
|
||||
func (err ErrLFSFileLocked) Unwrap() error {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// LFSMetaObject stores metadata for LFS tracked files.
|
||||
type LFSMetaObject struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
lfs.Pointer `xorm:"extends"`
|
||||
RepositoryID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(LFSMetaObject))
|
||||
}
|
||||
|
||||
// LFSTokenResponse defines the JSON structure in which the JWT token is stored.
|
||||
// This structure is fetched via SSH and passed by the Git LFS client to the server
|
||||
// endpoint for authorization.
|
||||
type LFSTokenResponse struct {
|
||||
Header map[string]string `json:"header"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
// ErrLFSObjectNotExist is returned from lfs models functions in order
|
||||
// to differentiate between database and missing object errors.
|
||||
var ErrLFSObjectNotExist = db.ErrNotExist{Resource: "LFS Meta object"}
|
||||
|
||||
// NewLFSMetaObject stores a given populated LFSMetaObject structure in the database
|
||||
// if it is not already present.
|
||||
func NewLFSMetaObject(ctx context.Context, repoID int64, p lfs.Pointer) (*LFSMetaObject, error) {
|
||||
m, exist, err := db.Get[LFSMetaObject](ctx, builder.Eq{"repository_id": repoID, "oid": p.Oid})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if exist {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m = &LFSMetaObject{Pointer: p, RepositoryID: repoID}
|
||||
if err = db.Insert(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetLFSMetaObjectByOid selects a LFSMetaObject entry from database by its OID.
|
||||
// It may return ErrLFSObjectNotExist or a database error. If the error is nil,
|
||||
// the returned pointer is a valid LFSMetaObject.
|
||||
func GetLFSMetaObjectByOid(ctx context.Context, repoID int64, oid string) (*LFSMetaObject, error) {
|
||||
if len(oid) == 0 {
|
||||
return nil, ErrLFSObjectNotExist
|
||||
}
|
||||
|
||||
m := &LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}, RepositoryID: repoID}
|
||||
has, err := db.GetEngine(ctx).Get(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrLFSObjectNotExist
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// RemoveLFSMetaObjectByOid removes a LFSMetaObject entry from database by its OID.
|
||||
// It may return ErrLFSObjectNotExist or a database error.
|
||||
func RemoveLFSMetaObjectByOid(ctx context.Context, repoID int64, oid string) (int64, error) {
|
||||
return RemoveLFSMetaObjectByOidFn(ctx, repoID, oid, nil)
|
||||
}
|
||||
|
||||
// RemoveLFSMetaObjectByOidFn removes a LFSMetaObject entry from database by its OID.
|
||||
// It may return ErrLFSObjectNotExist or a database error. It will run Fn with the current count within the transaction
|
||||
func RemoveLFSMetaObjectByOidFn(ctx context.Context, repoID int64, oid string, fn func(count int64) error) (int64, error) {
|
||||
if len(oid) == 0 {
|
||||
return 0, ErrLFSObjectNotExist
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (int64, error) {
|
||||
m := &LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}, RepositoryID: repoID}
|
||||
if _, err := db.DeleteByBean(ctx, m); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
count, err := db.CountByBean(ctx, &LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
|
||||
if fn != nil {
|
||||
if err := fn(count); err != nil {
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetLFSMetaObjects returns all LFSMetaObjects associated with a repository
|
||||
func GetLFSMetaObjects(ctx context.Context, repoID int64, page, pageSize int) ([]*LFSMetaObject, error) {
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
if page >= 0 && pageSize > 0 {
|
||||
start := 0
|
||||
if page > 0 {
|
||||
start = (page - 1) * pageSize
|
||||
}
|
||||
sess.Limit(pageSize, start)
|
||||
}
|
||||
lfsObjects := make([]*LFSMetaObject, 0, pageSize)
|
||||
return lfsObjects, sess.Find(&lfsObjects, &LFSMetaObject{RepositoryID: repoID})
|
||||
}
|
||||
|
||||
// CountLFSMetaObjects returns a count of all LFSMetaObjects associated with a repository
|
||||
func CountLFSMetaObjects(ctx context.Context, repoID int64) (int64, error) {
|
||||
return db.GetEngine(ctx).Count(&LFSMetaObject{RepositoryID: repoID})
|
||||
}
|
||||
|
||||
// LFSObjectAccessible checks if a provided Oid is accessible to the user
|
||||
func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string) (bool, error) {
|
||||
if user.IsAdmin {
|
||||
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
cond := repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)
|
||||
count, err := db.GetEngine(ctx).Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// ExistsLFSObject checks if a provided Oid exists within the DB
|
||||
func ExistsLFSObject(ctx context.Context, oid string) (bool, error) {
|
||||
return db.GetEngine(ctx).Exist(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
|
||||
}
|
||||
|
||||
// LFSAutoAssociate auto associates accessible LFSMetaObjects
|
||||
func LFSAutoAssociate(ctx context.Context, metas []*LFSMetaObject, user *user_model.User, repoID int64) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
oids := make([]any, len(metas))
|
||||
oidMap := make(map[string]*LFSMetaObject, len(metas))
|
||||
for i, meta := range metas {
|
||||
oids[i] = meta.Oid
|
||||
oidMap[meta.Oid] = meta
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
newMetas := make([]*LFSMetaObject, 0, len(metas))
|
||||
cond := builder.In(
|
||||
"`lfs_meta_object`.repository_id",
|
||||
builder.Select("`repository`.id").From("repository").Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)),
|
||||
)
|
||||
if err := db.GetEngine(ctx).Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(newMetas) != len(oidMap) {
|
||||
return fmt.Errorf("unable collect all LFS objects from database, expected %d, actually %d", len(oidMap), len(newMetas))
|
||||
}
|
||||
for i := range newMetas {
|
||||
newMetas[i].Size = oidMap[newMetas[i].Oid].Size
|
||||
newMetas[i].RepositoryID = repoID
|
||||
}
|
||||
return db.Insert(ctx, newMetas)
|
||||
}
|
||||
|
||||
// admin can associate any LFS object to any repository, and we do not care about errors (eg: duplicated unique key),
|
||||
// even if error occurs, it won't hurt users and won't make things worse
|
||||
for i := range metas {
|
||||
p := lfs.Pointer{Oid: metas[i].Oid, Size: metas[i].Size}
|
||||
if err := db.Insert(ctx, &LFSMetaObject{
|
||||
Pointer: p,
|
||||
RepositoryID: repoID,
|
||||
}); err != nil {
|
||||
log.Warn("failed to insert LFS meta object %-v for repo_id: %d into database, err=%v", p, repoID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CopyLFS copies LFS data from one repo to another
|
||||
func CopyLFS(ctx context.Context, newRepo, oldRepo *repo_model.Repository) error {
|
||||
var lfsObjects []*LFSMetaObject
|
||||
if err := db.GetEngine(ctx).Where("repository_id=?", oldRepo.ID).Find(&lfsObjects); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, v := range lfsObjects {
|
||||
v.ID = 0
|
||||
v.RepositoryID = newRepo.ID
|
||||
if err := db.Insert(ctx, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRepoLFSSize return a repository's lfs files size
|
||||
func GetRepoLFSSize(ctx context.Context, repoID int64) (int64, error) {
|
||||
lfsSize, err := db.GetEngine(ctx).Where("repository_id = ?", repoID).SumInt(new(LFSMetaObject), "size")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("updateSize: GetLFSMetaObjects: %w", err)
|
||||
}
|
||||
return lfsSize, nil
|
||||
}
|
||||
|
||||
// IterateRepositoryIDsWithLFSMetaObjects iterates across the repositories that have LFSMetaObjects
|
||||
func IterateRepositoryIDsWithLFSMetaObjects(ctx context.Context, f func(ctx context.Context, repoID, count int64) error) error {
|
||||
batchSize := setting.Database.IterateBufferSize
|
||||
sess := db.GetEngine(ctx)
|
||||
id := int64(0)
|
||||
type RepositoryCount struct {
|
||||
RepositoryID int64
|
||||
Count int64
|
||||
}
|
||||
for {
|
||||
counts := make([]*RepositoryCount, 0, batchSize)
|
||||
sess.Select("repository_id, COUNT(id) AS count").
|
||||
Table("lfs_meta_object").
|
||||
Where("repository_id > ?", id).
|
||||
GroupBy("repository_id").
|
||||
OrderBy("repository_id ASC")
|
||||
|
||||
if err := sess.Limit(batchSize, 0).Find(&counts); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(counts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, count := range counts {
|
||||
if err := f(ctx, count.RepositoryID, count.Count); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
id = counts[len(counts)-1].RepositoryID
|
||||
}
|
||||
}
|
||||
|
||||
// IterateLFSMetaObjectsForRepoOptions provides options for IterateLFSMetaObjectsForRepo
|
||||
type IterateLFSMetaObjectsForRepoOptions struct {
|
||||
OlderThan timeutil.TimeStamp
|
||||
UpdatedLessRecentlyThan timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// IterateLFSMetaObjectsForRepo provides a iterator for LFSMetaObjects per Repo
|
||||
func IterateLFSMetaObjectsForRepo(ctx context.Context, repoID int64, f func(context.Context, *LFSMetaObject, int64) error, opts *IterateLFSMetaObjectsForRepoOptions) error {
|
||||
batchSize := setting.Database.IterateBufferSize
|
||||
engine := db.GetEngine(ctx)
|
||||
type CountLFSMetaObject struct {
|
||||
Count int64
|
||||
LFSMetaObject `xorm:"extends"`
|
||||
}
|
||||
|
||||
lastID := int64(0)
|
||||
|
||||
for {
|
||||
beans := make([]*CountLFSMetaObject, 0, batchSize)
|
||||
sess := engine.Table("lfs_meta_object").Select("`lfs_meta_object`.*, COUNT(`l1`.oid) AS `count`").
|
||||
Join("INNER", "`lfs_meta_object` AS l1", "`lfs_meta_object`.oid = `l1`.oid").
|
||||
Where("`lfs_meta_object`.repository_id = ?", repoID)
|
||||
if !opts.OlderThan.IsZero() {
|
||||
sess.And("`lfs_meta_object`.created_unix < ?", opts.OlderThan)
|
||||
}
|
||||
if !opts.UpdatedLessRecentlyThan.IsZero() {
|
||||
sess.And("`lfs_meta_object`.updated_unix < ?", opts.UpdatedLessRecentlyThan)
|
||||
}
|
||||
sess.GroupBy("`lfs_meta_object`.id").
|
||||
And("`lfs_meta_object`.id > ?", lastID).
|
||||
OrderBy("`lfs_meta_object`.id ASC")
|
||||
|
||||
if err := sess.Limit(batchSize).Find(&beans); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(beans) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, bean := range beans {
|
||||
if err := f(ctx, &bean.LFSMetaObject, bean.Count); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
lastID = beans[len(beans)-1].ID
|
||||
}
|
||||
}
|
||||
|
||||
// MarkLFSMetaObject updates the updated time for the provided LFSMetaObject
|
||||
func MarkLFSMetaObject(ctx context.Context, id int64) error {
|
||||
obj := &LFSMetaObject{
|
||||
UpdatedUnix: timeutil.TimeStampNow(),
|
||||
}
|
||||
count, err := db.GetEngine(ctx).ID(id).Update(obj)
|
||||
if count != 1 {
|
||||
log.Error("Unexpectedly updated %d LFSMetaObjects with ID: %d", count, id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// LFSLock represents a git lfs lock of repository.
|
||||
type LFSLock struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
OwnerID int64 `xorm:"INDEX NOT NULL"`
|
||||
Owner *user_model.User `xorm:"-"`
|
||||
Path string `xorm:"TEXT"`
|
||||
Created time.Time `xorm:"created"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(LFSLock))
|
||||
}
|
||||
|
||||
// BeforeInsert is invoked from XORM before inserting an object of this type.
|
||||
func (l *LFSLock) BeforeInsert() {
|
||||
l.Path = util.PathJoinRel(l.Path)
|
||||
}
|
||||
|
||||
// LoadAttributes loads attributes of the lock.
|
||||
func (l *LFSLock) LoadAttributes(ctx context.Context) error {
|
||||
// Load owner
|
||||
if err := l.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("load owner: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadOwner loads owner of the lock.
|
||||
func (l *LFSLock) LoadOwner(ctx context.Context) error {
|
||||
if l.Owner != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
owner, err := user_model.GetUserByID(ctx, l.OwnerID)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
l.Owner = user_model.NewGhostUser()
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
l.Owner = owner
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateLFSLock creates a new lock.
|
||||
func CreateLFSLock(ctx context.Context, repo *repo_model.Repository, lock *LFSLock) (*LFSLock, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) {
|
||||
lock.Path = util.PathJoinRel(lock.Path)
|
||||
lock.RepoID = repo.ID
|
||||
|
||||
l, err := GetLFSLock(ctx, repo, lock.Path)
|
||||
if err == nil {
|
||||
return l, ErrLFSLockAlreadyExist{lock.RepoID, lock.Path}
|
||||
}
|
||||
if !IsErrLFSLockNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, lock); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lock, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetLFSLock returns release by given path.
|
||||
func GetLFSLock(ctx context.Context, repo *repo_model.Repository, path string) (*LFSLock, error) {
|
||||
path = util.PathJoinRel(path)
|
||||
rel := &LFSLock{RepoID: repo.ID}
|
||||
has, err := db.GetEngine(ctx).Where("lower(path) = ?", strings.ToLower(path)).Get(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, ErrLFSLockNotExist{0, repo.ID, path}
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// GetLFSLockByIDAndRepo returns lfs lock by given id and repository id.
|
||||
func GetLFSLockByIDAndRepo(ctx context.Context, id, repoID int64) (*LFSLock, error) {
|
||||
lock := new(LFSLock)
|
||||
has, err := db.GetEngine(ctx).ID(id).And("repo_id = ?", repoID).Get(lock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrLFSLockNotExist{id, 0, ""}
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
// GetLFSLockByRepoID returns a list of locks of repository.
|
||||
func GetLFSLockByRepoID(ctx context.Context, repoID int64, page, pageSize int) (LFSLockList, error) {
|
||||
e := db.GetEngine(ctx)
|
||||
if page >= 0 && pageSize > 0 {
|
||||
start := 0
|
||||
if page > 0 {
|
||||
start = (page - 1) * pageSize
|
||||
}
|
||||
e.Limit(pageSize, start)
|
||||
}
|
||||
lfsLocks := make(LFSLockList, 0, pageSize)
|
||||
return lfsLocks, e.Find(&lfsLocks, &LFSLock{RepoID: repoID})
|
||||
}
|
||||
|
||||
// GetTreePathLock returns LSF lock for the treePath
|
||||
func GetTreePathLock(ctx context.Context, repoID int64, treePath string) (*LFSLock, error) {
|
||||
if !setting.LFS.StartServer {
|
||||
return nil, nil //nolint:nilnil // return nil when LFS is not started
|
||||
}
|
||||
|
||||
locks, err := GetLFSLockByRepoID(ctx, repoID, 0, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lock := range locks {
|
||||
if lock.Path == treePath {
|
||||
return lock, nil
|
||||
}
|
||||
}
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
||||
}
|
||||
|
||||
// CountLFSLockByRepoID returns a count of all LFSLocks associated with a repository.
|
||||
func CountLFSLockByRepoID(ctx context.Context, repoID int64) (int64, error) {
|
||||
return db.GetEngine(ctx).Count(&LFSLock{RepoID: repoID})
|
||||
}
|
||||
|
||||
// DeleteLFSLockByID deletes a lock by given ID.
|
||||
func DeleteLFSLockByID(ctx context.Context, id int64, repo *repo_model.Repository, u *user_model.User, force bool) (*LFSLock, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*LFSLock, error) {
|
||||
lock, err := GetLFSLockByIDAndRepo(ctx, id, repo.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !force && u.ID != lock.OwnerID {
|
||||
return nil, errors.New("user doesn't own lock and force flag is not set")
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).ID(id).Delete(new(LFSLock)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lock, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
)
|
||||
|
||||
// LFSLockList is a list of LFSLock
|
||||
type LFSLockList []*LFSLock
|
||||
|
||||
// LoadAttributes loads the attributes for the given locks
|
||||
func (locks LFSLockList) LoadAttributes(ctx context.Context) error {
|
||||
if len(locks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := locks.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("load owner: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadOwner loads the owner of the locks
|
||||
func (locks LFSLockList) LoadOwner(ctx context.Context) error {
|
||||
if len(locks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
usersIDs := container.FilterSlice(locks, func(lock *LFSLock) (int64, bool) {
|
||||
return lock.OwnerID, true
|
||||
})
|
||||
users := make(map[int64]*user_model.User, len(usersIDs))
|
||||
if err := db.GetEngine(ctx).
|
||||
In("id", usersIDs).
|
||||
Find(&users); err != nil {
|
||||
return fmt.Errorf("find users: %w", err)
|
||||
}
|
||||
for _, v := range locks {
|
||||
v.Owner = users[v.OwnerID]
|
||||
if v.Owner == nil { // not exist
|
||||
v.Owner = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createTestLock(t *testing.T, repo *repo_model.Repository, owner *user_model.User) *LFSLock {
|
||||
t.Helper()
|
||||
|
||||
path := fmt.Sprintf("%s-%d-%d", t.Name(), repo.ID, time.Now().UnixNano())
|
||||
lock, err := CreateLFSLock(t.Context(), repo, &LFSLock{
|
||||
OwnerID: owner.ID,
|
||||
Path: path,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return lock
|
||||
}
|
||||
|
||||
func TestGetLFSLockByIDAndRepo(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
lockRepo1 := createTestLock(t, repo1, user2)
|
||||
lockRepo3 := createTestLock(t, repo3, user4)
|
||||
|
||||
fetched, err := GetLFSLockByIDAndRepo(t.Context(), lockRepo1.ID, repo1.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, lockRepo1.ID, fetched.ID)
|
||||
assert.Equal(t, repo1.ID, fetched.RepoID)
|
||||
|
||||
_, err = GetLFSLockByIDAndRepo(t.Context(), lockRepo1.ID, repo3.ID)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, IsErrLFSLockNotExist(err))
|
||||
|
||||
_, err = GetLFSLockByIDAndRepo(t.Context(), lockRepo3.ID, repo1.ID)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, IsErrLFSLockNotExist(err))
|
||||
}
|
||||
|
||||
func TestDeleteLFSLockByIDRequiresRepoMatch(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
lockRepo1 := createTestLock(t, repo1, user2)
|
||||
lockRepo3 := createTestLock(t, repo3, user4)
|
||||
|
||||
_, err := DeleteLFSLockByID(t.Context(), lockRepo3.ID, repo1, user2, true)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, IsErrLFSLockNotExist(err))
|
||||
|
||||
existing, err := GetLFSLockByIDAndRepo(t.Context(), lockRepo3.ID, repo3.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, lockRepo3.ID, existing.ID)
|
||||
|
||||
deleted, err := DeleteLFSLockByID(t.Context(), lockRepo3.ID, repo3, user4, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, lockRepo3.ID, deleted.ID)
|
||||
|
||||
deleted, err = DeleteLFSLockByID(t.Context(), lockRepo1.ID, repo1, user2, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, lockRepo1.ID, deleted.ID)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIterateLFSMetaObjectsForRepoUpdatesDoNotSkip(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ctx := t.Context()
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, "user2", "repo1")
|
||||
assert.NoError(t, err)
|
||||
|
||||
defer test.MockVariableValue(&setting.Database.IterateBufferSize, 1)()
|
||||
|
||||
created := make([]*git_model.LFSMetaObject, 0, 3)
|
||||
for i := range 3 {
|
||||
content := []byte("gitea-lfs-" + strconv.Itoa(i))
|
||||
pointer, err := lfs.GeneratePointer(bytes.NewReader(content))
|
||||
assert.NoError(t, err)
|
||||
|
||||
meta, err := git_model.NewLFSMetaObject(ctx, repo.ID, pointer)
|
||||
assert.NoError(t, err)
|
||||
created = append(created, meta)
|
||||
}
|
||||
|
||||
iterated := make([]int64, 0, len(created))
|
||||
cutoff := time.Now().Add(24 * time.Hour)
|
||||
iterErr := git_model.IterateLFSMetaObjectsForRepo(ctx, repo.ID, func(ctx context.Context, meta *git_model.LFSMetaObject, count int64) error {
|
||||
iterated = append(iterated, meta.ID)
|
||||
_, err := db.GetEngine(ctx).ID(meta.ID).Cols("updated_unix").Update(&git_model.LFSMetaObject{
|
||||
UpdatedUnix: timeutil.TimeStamp(time.Now().Unix()),
|
||||
})
|
||||
return err
|
||||
}, &git_model.IterateLFSMetaObjectsForRepoOptions{
|
||||
OlderThan: timeutil.TimeStamp(cutoff.Unix()),
|
||||
UpdatedLessRecentlyThan: timeutil.TimeStamp(cutoff.Unix()),
|
||||
})
|
||||
assert.NoError(t, iterErr)
|
||||
|
||||
expected := []int64{created[0].ID, created[1].ID, created[2].ID}
|
||||
assert.Equal(t, expected, iterated)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
_ "gitea.dev/models"
|
||||
_ "gitea.dev/models/actions"
|
||||
_ "gitea.dev/models/activities"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
var ErrBranchIsProtected = util.ErrorWrap(util.ErrPermissionDenied, "branch is protected")
|
||||
|
||||
// ProtectedBranch struct
|
||||
type ProtectedBranch struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
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"`
|
||||
EnableWhitelist bool
|
||||
WhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||
WhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
WhitelistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
|
||||
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||
BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
ForcePushAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
|
||||
StatusCheckContexts []string `xorm:"JSON TEXT"`
|
||||
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"`
|
||||
BlockOnOfficialReviewRequests bool `xorm:"NOT NULL DEFAULT false"`
|
||||
BlockOnOutdatedBranch bool `xorm:"NOT NULL DEFAULT false"`
|
||||
DismissStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
|
||||
IgnoreStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
|
||||
RequireSignedCommits bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ProtectedFilePatterns string `xorm:"TEXT"`
|
||||
UnprotectedFilePatterns string `xorm:"TEXT"`
|
||||
BlockAdminMergeOverride bool `xorm:"NOT NULL DEFAULT false"`
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ProtectedBranch))
|
||||
}
|
||||
|
||||
// IsRuleNameSpecial return true if it contains special character
|
||||
func IsRuleNameSpecial(ruleName string) bool {
|
||||
for i := 0; i < len(ruleName); i++ {
|
||||
if glob.IsSpecialByte(ruleName[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (protectBranch *ProtectedBranch) loadGlob() {
|
||||
if protectBranch.isPlainName || protectBranch.globRule != nil {
|
||||
return
|
||||
}
|
||||
// detect if it is not glob
|
||||
if !IsRuleNameSpecial(protectBranch.RuleName) {
|
||||
protectBranch.isPlainName = true
|
||||
return
|
||||
}
|
||||
// now we load the glob
|
||||
var err error
|
||||
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
|
||||
protectBranch.globRule = glob.MustCompile(glob.QuoteMeta(protectBranch.RuleName), '/')
|
||||
}
|
||||
}
|
||||
|
||||
// Match tests if branchName matches the rule
|
||||
func (protectBranch *ProtectedBranch) Match(branchName string) bool {
|
||||
protectBranch.loadGlob()
|
||||
if protectBranch.isPlainName {
|
||||
return strings.EqualFold(protectBranch.RuleName, branchName)
|
||||
}
|
||||
|
||||
return protectBranch.globRule.Match(branchName)
|
||||
}
|
||||
|
||||
func (protectBranch *ProtectedBranch) LoadRepo(ctx context.Context) (err error) {
|
||||
if protectBranch.Repo != nil {
|
||||
return nil
|
||||
}
|
||||
protectBranch.Repo, err = repo_model.GetRepositoryByID(ctx, protectBranch.RepoID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CanUserPush returns if some user could push to this protected branch
|
||||
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User) bool {
|
||||
if !protectBranch.CanPush {
|
||||
return false
|
||||
}
|
||||
|
||||
if !protectBranch.EnableWhitelist {
|
||||
if err := protectBranch.LoadRepo(ctx); err != nil {
|
||||
log.Error("LoadRepo: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
writeAccess, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeWrite)
|
||||
if err != nil {
|
||||
log.Error("HasAccessUnit: %v", err)
|
||||
return false
|
||||
}
|
||||
return writeAccess
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(protectBranch.WhitelistTeamIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.WhitelistTeamIDs)
|
||||
if err != nil {
|
||||
log.Error("IsUserInTeams: %v", err)
|
||||
return false
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// CanUserForcePush returns if some user could force push to this protected branch
|
||||
// Since force-push extends normal push, we also check if user has regular push access
|
||||
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User) bool {
|
||||
if !protectBranch.CanForcePush {
|
||||
return false
|
||||
}
|
||||
|
||||
if !protectBranch.EnableForcePushAllowlist {
|
||||
return protectBranch.CanUserPush(ctx, user)
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
|
||||
return protectBranch.CanUserPush(ctx, user)
|
||||
}
|
||||
|
||||
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.ForcePushAllowlistTeamIDs)
|
||||
if err != nil {
|
||||
log.Error("IsUserInTeams: %v", err)
|
||||
return false
|
||||
}
|
||||
return in && protectBranch.CanUserPush(ctx, user)
|
||||
}
|
||||
|
||||
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
|
||||
func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch, userID int64, permissionInRepo access_model.Permission) bool {
|
||||
if !protectBranch.EnableMergeWhitelist {
|
||||
// Then we need to fall back on whether the user has write permission
|
||||
return permissionInRepo.CanWrite(unit.TypeCode)
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.MergeWhitelistUserIDs, userID) {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(protectBranch.MergeWhitelistTeamIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
in, err := organization.IsUserInTeams(ctx, userID, protectBranch.MergeWhitelistTeamIDs)
|
||||
if err != nil {
|
||||
log.Error("IsUserInTeams: %v", err)
|
||||
return false
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// CanBypassBranchProtection reports whether the user can bypass branch protection checks (status checks, approvals, protected files)
|
||||
// Either a repo admin (when not blocked) or a user/team on the bypass allowlist can bypass.
|
||||
func CanBypassBranchProtection(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User, isRepoAdmin bool) bool {
|
||||
if isRepoAdmin && !protectBranch.BlockAdminMergeOverride {
|
||||
return true
|
||||
}
|
||||
if !protectBranch.EnableBypassAllowlist {
|
||||
return false
|
||||
}
|
||||
if slices.Contains(protectBranch.BypassAllowlistUserIDs, user.ID) {
|
||||
return true
|
||||
}
|
||||
if len(protectBranch.BypassAllowlistTeamIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.BypassAllowlistTeamIDs)
|
||||
if err != nil {
|
||||
log.Error("IsUserInTeams failed: userID=%d, repoID=%d, allowlistTeamIDs=%v, err=%v", user.ID, protectBranch.RepoID, protectBranch.BypassAllowlistTeamIDs, err)
|
||||
return false
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// IsUserOfficialReviewer check if user is official reviewer for the branch (counts towards required approvals)
|
||||
func IsUserOfficialReviewer(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User) (bool, error) {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, protectBranch.RepoID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !protectBranch.EnableApprovalsWhitelist {
|
||||
// Anyone with write access is considered official reviewer
|
||||
writeAccess, err := access_model.HasAccessUnit(ctx, user, repo, unit.TypeCode, perm.AccessModeWrite)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return writeAccess, nil
|
||||
}
|
||||
|
||||
if slices.Contains(protectBranch.ApprovalsWhitelistUserIDs, user.ID) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
inTeam, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.ApprovalsWhitelistTeamIDs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return inTeam, nil
|
||||
}
|
||||
|
||||
// GetProtectedFilePatterns parses a semicolon separated list of protected file patterns and returns a glob.Glob slice
|
||||
func (protectBranch *ProtectedBranch) GetProtectedFilePatterns() []glob.Glob {
|
||||
return getFilePatterns(protectBranch.ProtectedFilePatterns)
|
||||
}
|
||||
|
||||
// GetUnprotectedFilePatterns parses a semicolon separated list of unprotected file patterns and returns a glob.Glob slice
|
||||
func (protectBranch *ProtectedBranch) GetUnprotectedFilePatterns() []glob.Glob {
|
||||
return getFilePatterns(protectBranch.UnprotectedFilePatterns)
|
||||
}
|
||||
|
||||
func getFilePatterns(filePatterns string) []glob.Glob {
|
||||
extarr := make([]glob.Glob, 0, 10)
|
||||
for expr := range strings.SplitSeq(strings.ToLower(filePatterns), ";") {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr != "" {
|
||||
if g, err := glob.Compile(expr, '.', '/'); err != nil {
|
||||
log.Info("Invalid glob expression '%s' (skipped): %v", expr, err)
|
||||
} else {
|
||||
extarr = append(extarr, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return extarr
|
||||
}
|
||||
|
||||
// MergeBlockedByProtectedFiles returns true if merge is blocked by protected files change
|
||||
func (protectBranch *ProtectedBranch) MergeBlockedByProtectedFiles(changedProtectedFiles []string) bool {
|
||||
glob := protectBranch.GetProtectedFilePatterns()
|
||||
if len(glob) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return len(changedProtectedFiles) > 0
|
||||
}
|
||||
|
||||
// IsProtectedFile return if path is protected
|
||||
func (protectBranch *ProtectedBranch) IsProtectedFile(patterns []glob.Glob, path string) bool {
|
||||
if len(patterns) == 0 {
|
||||
patterns = protectBranch.GetProtectedFilePatterns()
|
||||
if len(patterns) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
lpath := strings.ToLower(strings.TrimSpace(path))
|
||||
|
||||
r := false
|
||||
for _, pat := range patterns {
|
||||
if pat.Match(lpath) {
|
||||
r = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// IsUnprotectedFile return if path is unprotected
|
||||
func (protectBranch *ProtectedBranch) IsUnprotectedFile(patterns []glob.Glob, path string) bool {
|
||||
if len(patterns) == 0 {
|
||||
patterns = protectBranch.GetUnprotectedFilePatterns()
|
||||
if len(patterns) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
lpath := strings.ToLower(strings.TrimSpace(path))
|
||||
|
||||
r := false
|
||||
for _, pat := range patterns {
|
||||
if pat.Match(lpath) {
|
||||
r = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// GetProtectedBranchRuleByName getting protected branch rule by name
|
||||
func GetProtectedBranchRuleByName(ctx context.Context, repoID int64, ruleName string) (*ProtectedBranch, error) {
|
||||
// branch_name is legacy name, it actually is rule name
|
||||
rel, exist, err := db.Get[ProtectedBranch](ctx, builder.Eq{"repo_id": repoID, "branch_name": ruleName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// GetProtectedBranchRuleByID getting protected branch rule by rule ID
|
||||
func GetProtectedBranchRuleByID(ctx context.Context, repoID, ruleID int64) (*ProtectedBranch, error) {
|
||||
rel, exist, err := db.Get[ProtectedBranch](ctx, builder.Eq{"repo_id": repoID, "id": ruleID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// WhitelistOptions represent all sorts of whitelists used for protected branches
|
||||
type WhitelistOptions struct {
|
||||
UserIDs []int64
|
||||
TeamIDs []int64
|
||||
|
||||
ForcePushUserIDs []int64
|
||||
ForcePushTeamIDs []int64
|
||||
|
||||
MergeUserIDs []int64
|
||||
MergeTeamIDs []int64
|
||||
|
||||
ApprovalsUserIDs []int64
|
||||
ApprovalsTeamIDs []int64
|
||||
|
||||
BypassUserIDs []int64
|
||||
BypassTeamIDs []int64
|
||||
}
|
||||
|
||||
// UpdateProtectBranch saves branch protection options of repository.
|
||||
// If ID is 0, it creates a new record. Otherwise, updates existing record.
|
||||
// This function also performs check if whitelist user and team's IDs have been changed
|
||||
// to avoid unnecessary whitelist delete and regenerate.
|
||||
func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, protectBranch *ProtectedBranch, opts WhitelistOptions) (err error) {
|
||||
err = repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("LoadOwner: %v", err)
|
||||
}
|
||||
|
||||
whitelist, err := updateUserWhitelist(ctx, repo, protectBranch.WhitelistUserIDs, opts.UserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.WhitelistUserIDs = whitelist
|
||||
|
||||
whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.ForcePushAllowlistUserIDs, opts.ForcePushUserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.ForcePushAllowlistUserIDs = whitelist
|
||||
|
||||
whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.MergeWhitelistUserIDs, opts.MergeUserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.MergeWhitelistUserIDs = whitelist
|
||||
|
||||
whitelist, err = updateApprovalWhitelist(ctx, repo, protectBranch.ApprovalsWhitelistUserIDs, opts.ApprovalsUserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.ApprovalsWhitelistUserIDs = whitelist
|
||||
|
||||
whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.BypassAllowlistUserIDs, opts.BypassUserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.BypassAllowlistUserIDs = whitelist
|
||||
|
||||
// if the repo is in an organization
|
||||
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.WhitelistTeamIDs, opts.TeamIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.WhitelistTeamIDs = whitelist
|
||||
|
||||
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.ForcePushAllowlistTeamIDs, opts.ForcePushTeamIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.ForcePushAllowlistTeamIDs = whitelist
|
||||
|
||||
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.MergeWhitelistTeamIDs, opts.MergeTeamIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.MergeWhitelistTeamIDs = whitelist
|
||||
|
||||
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.ApprovalsWhitelistTeamIDs, opts.ApprovalsTeamIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.ApprovalsWhitelistTeamIDs = whitelist
|
||||
|
||||
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.BypassAllowlistTeamIDs, opts.BypassTeamIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
protectBranch.BypassAllowlistTeamIDs = whitelist
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
hasUsersChanged := !util.SliceSortedEqual(currentWhitelist, newWhitelist)
|
||||
if !hasUsersChanged {
|
||||
return currentWhitelist, nil
|
||||
}
|
||||
|
||||
prUserIDs, err := access_model.GetUserIDsWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
whitelist = make([]int64, 0, len(newWhitelist))
|
||||
for _, userID := range newWhitelist {
|
||||
if !prUserIDs.Contains(userID) {
|
||||
continue
|
||||
}
|
||||
whitelist = append(whitelist, userID)
|
||||
}
|
||||
|
||||
return whitelist, err
|
||||
}
|
||||
|
||||
// updateUserWhitelist checks whether the user whitelist changed and returns a whitelist with
|
||||
// the users from newWhitelist which have write access to the repo.
|
||||
func updateUserWhitelist(ctx context.Context, repo *repo_model.Repository, currentWhitelist, newWhitelist []int64) (whitelist []int64, err error) {
|
||||
hasUsersChanged := !util.SliceSortedEqual(currentWhitelist, newWhitelist)
|
||||
if !hasUsersChanged {
|
||||
return currentWhitelist, nil
|
||||
}
|
||||
|
||||
whitelist = make([]int64, 0, len(newWhitelist))
|
||||
for _, userID := range newWhitelist {
|
||||
user, err := user_model.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetUserByID [user_id: %d, repo_id: %d]: %v", userID, repo.ID, err)
|
||||
}
|
||||
perm, err := access_model.GetIndividualUserRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetIndividualUserRepoPermission [user_id: %d, repo_id: %d]: %v", userID, repo.ID, err)
|
||||
}
|
||||
|
||||
if !perm.CanWrite(unit.TypeCode) {
|
||||
continue // Drop invalid user ID
|
||||
}
|
||||
|
||||
whitelist = append(whitelist, userID)
|
||||
}
|
||||
|
||||
return whitelist, err
|
||||
}
|
||||
|
||||
// updateTeamWhitelist checks whether the team whitelist changed and returns a whitelist with
|
||||
// the teams from newWhitelist which have write access to the repo.
|
||||
func updateTeamWhitelist(ctx context.Context, repo *repo_model.Repository, currentWhitelist, newWhitelist []int64) (whitelist []int64, err error) {
|
||||
hasTeamsChanged := !util.SliceSortedEqual(currentWhitelist, newWhitelist)
|
||||
if !hasTeamsChanged {
|
||||
return currentWhitelist, nil
|
||||
}
|
||||
|
||||
teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetTeamsWithAccessToRepo [org_id: %d, repo_id: %d]: %v", repo.OwnerID, repo.ID, err)
|
||||
}
|
||||
|
||||
whitelist = make([]int64, 0, len(teams))
|
||||
for i := range teams {
|
||||
if slices.Contains(newWhitelist, teams[i].ID) {
|
||||
whitelist = append(whitelist, teams[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
return whitelist, err
|
||||
}
|
||||
|
||||
// DeleteProtectedBranch removes ProtectedBranch relation between the user and repository.
|
||||
func DeleteProtectedBranch(ctx context.Context, repo *repo_model.Repository, id int64) (err error) {
|
||||
err = repo.MustNotBeArchived()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
protectedBranch := &ProtectedBranch{
|
||||
RepoID: repo.ID,
|
||||
ID: id,
|
||||
}
|
||||
|
||||
if affected, err := db.GetEngine(ctx).Delete(protectedBranch); err != nil {
|
||||
return err
|
||||
} else if affected != 1 {
|
||||
return fmt.Errorf("delete protected branch ID(%v) failed", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeIDsFromProtectedBranch is a helper function to remove IDs from protected branch options
|
||||
func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID, teamID int64, columnNames []string) error {
|
||||
lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs)
|
||||
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs)
|
||||
|
||||
if userID > 0 {
|
||||
p.WhitelistUserIDs = util.SliceRemoveAll(p.WhitelistUserIDs, userID)
|
||||
p.ForcePushAllowlistUserIDs = util.SliceRemoveAll(p.ForcePushAllowlistUserIDs, userID)
|
||||
p.ApprovalsWhitelistUserIDs = util.SliceRemoveAll(p.ApprovalsWhitelistUserIDs, userID)
|
||||
p.MergeWhitelistUserIDs = util.SliceRemoveAll(p.MergeWhitelistUserIDs, userID)
|
||||
}
|
||||
|
||||
if teamID > 0 {
|
||||
p.WhitelistTeamIDs = util.SliceRemoveAll(p.WhitelistTeamIDs, teamID)
|
||||
p.ForcePushAllowlistTeamIDs = util.SliceRemoveAll(p.ForcePushAllowlistTeamIDs, teamID)
|
||||
p.ApprovalsWhitelistTeamIDs = util.SliceRemoveAll(p.ApprovalsWhitelistTeamIDs, teamID)
|
||||
p.MergeWhitelistTeamIDs = util.SliceRemoveAll(p.MergeWhitelistTeamIDs, teamID)
|
||||
}
|
||||
|
||||
if (lenUserIDs != len(p.WhitelistUserIDs) ||
|
||||
lenForcePushIDs != len(p.ForcePushAllowlistUserIDs) ||
|
||||
lenApprovalIDs != len(p.ApprovalsWhitelistUserIDs) ||
|
||||
lenMergeIDs != len(p.MergeWhitelistUserIDs)) ||
|
||||
(lenTeamIDs != len(p.WhitelistTeamIDs) ||
|
||||
lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) ||
|
||||
lenApprovalTeamIDs != len(p.ApprovalsWhitelistTeamIDs) ||
|
||||
lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs)) {
|
||||
if _, err := db.GetEngine(ctx).ID(p.ID).Cols(columnNames...).Update(p); err != nil {
|
||||
return fmt.Errorf("updateProtectedBranches: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserIDFromProtectedBranch removes all user ids from protected branch options
|
||||
func RemoveUserIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID int64) error {
|
||||
columnNames := []string{
|
||||
"whitelist_user_i_ds",
|
||||
"force_push_allowlist_user_i_ds",
|
||||
"merge_whitelist_user_i_ds",
|
||||
"approvals_whitelist_user_i_ds",
|
||||
}
|
||||
return removeIDsFromProtectedBranch(ctx, p, userID, 0, columnNames)
|
||||
}
|
||||
|
||||
// RemoveTeamIDFromProtectedBranch removes all team ids from protected branch options
|
||||
func RemoveTeamIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, teamID int64) error {
|
||||
columnNames := []string{
|
||||
"whitelist_team_i_ds",
|
||||
"force_push_allowlist_team_i_ds",
|
||||
"merge_whitelist_team_i_ds",
|
||||
"approvals_whitelist_team_i_ds",
|
||||
}
|
||||
return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/optional"
|
||||
)
|
||||
|
||||
type ProtectedBranchRules []*ProtectedBranch
|
||||
|
||||
func (rules ProtectedBranchRules) GetFirstMatched(branchName string) *ProtectedBranch {
|
||||
for _, rule := range rules {
|
||||
if rule.Match(branchName) {
|
||||
return rule
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
return rules[i].CreatedUnix < rules[j].CreatedUnix
|
||||
})
|
||||
}
|
||||
|
||||
// FindRepoProtectedBranchRules load all repository's protected rules
|
||||
func FindRepoProtectedBranchRules(ctx context.Context, repoID int64) (ProtectedBranchRules, error) {
|
||||
var rules ProtectedBranchRules
|
||||
err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Asc("created_unix").Find(&rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules.sort() // to make non-glob rules have higher priority, and for same glob/non-glob rules, first created rules have higher priority
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// FindAllMatchedBranches find all matched branches
|
||||
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
|
||||
results := make([]string, 0, 10)
|
||||
for page := 1; ; page++ {
|
||||
brancheNames, err := FindBranchNames(ctx, FindBranchOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: 100,
|
||||
Page: page,
|
||||
},
|
||||
RepoID: repoID,
|
||||
IsDeletedBranch: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule := glob.MustCompile(ruleName)
|
||||
|
||||
for _, branch := range brancheNames {
|
||||
if rule.Match(branch) {
|
||||
results = append(results, branch)
|
||||
}
|
||||
}
|
||||
if len(brancheNames) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetFirstMatchProtectedBranchRule returns the first matched rules
|
||||
func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) {
|
||||
rules, err := FindRepoProtectedBranchRules(ctx, repoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules.GetFirstMatched(branchName), nil
|
||||
}
|
||||
|
||||
// IsBranchProtected checks if branch is protected
|
||||
func IsBranchProtected(ctx context.Context, repoID int64, branchName string) (bool, error) {
|
||||
rule, err := GetFirstMatchProtectedBranchRule(ctx, repoID, branchName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rule != nil, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBranchRuleMatchPriority(t *testing.T) {
|
||||
kases := []struct {
|
||||
Rules []string
|
||||
BranchName string
|
||||
ExpectedMatchIdx int
|
||||
}{
|
||||
{
|
||||
Rules: []string{"release/*", "release/v1.17"},
|
||||
BranchName: "release/v1.17",
|
||||
ExpectedMatchIdx: 1,
|
||||
},
|
||||
{
|
||||
Rules: []string{"release/v1.17", "release/*"},
|
||||
BranchName: "release/v1.17",
|
||||
ExpectedMatchIdx: 0,
|
||||
},
|
||||
{
|
||||
Rules: []string{"release/**/v1.17", "release/test/v1.17"},
|
||||
BranchName: "release/test/v1.17",
|
||||
ExpectedMatchIdx: 1,
|
||||
},
|
||||
{
|
||||
Rules: []string{"release/test/v1.17", "release/**/v1.17"},
|
||||
BranchName: "release/test/v1.17",
|
||||
ExpectedMatchIdx: 0,
|
||||
},
|
||||
{
|
||||
Rules: []string{"release/**", "release/v1.0.0"},
|
||||
BranchName: "release/v1.0.0",
|
||||
ExpectedMatchIdx: 1,
|
||||
},
|
||||
{
|
||||
Rules: []string{"release/v1.0.0", "release/**"},
|
||||
BranchName: "release/v1.0.0",
|
||||
ExpectedMatchIdx: 0,
|
||||
},
|
||||
{
|
||||
Rules: []string{"release/**", "release/v1.0.0"},
|
||||
BranchName: "release/v2.0.0",
|
||||
ExpectedMatchIdx: 0,
|
||||
},
|
||||
{
|
||||
Rules: []string{"release/*", "release/v1.0.0"},
|
||||
BranchName: "release/1/v2.0.0",
|
||||
ExpectedMatchIdx: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, kase := range kases {
|
||||
var pbs ProtectedBranchRules
|
||||
for _, rule := range kase.Rules {
|
||||
pbs = append(pbs, &ProtectedBranch{RuleName: rule})
|
||||
}
|
||||
pbs.sort()
|
||||
matchedPB := pbs.GetFirstMatched(kase.BranchName)
|
||||
if matchedPB == nil {
|
||||
if kase.ExpectedMatchIdx >= 0 {
|
||||
assert.Error(t, fmt.Errorf("no matched rules but expected %s[%d]", kase.Rules[kase.ExpectedMatchIdx], kase.ExpectedMatchIdx))
|
||||
}
|
||||
} else {
|
||||
assert.Equal(t, kase.Rules[kase.ExpectedMatchIdx], matchedPB.RuleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchRuleSortLegacy(t *testing.T) {
|
||||
in := []*ProtectedBranch{{
|
||||
RuleName: "b",
|
||||
CreatedUnix: 1,
|
||||
}, {
|
||||
RuleName: "b/*",
|
||||
CreatedUnix: 3,
|
||||
}, {
|
||||
RuleName: "a/*",
|
||||
CreatedUnix: 2,
|
||||
}, {
|
||||
RuleName: "c",
|
||||
CreatedUnix: 0,
|
||||
}, {
|
||||
RuleName: "a",
|
||||
CreatedUnix: 4,
|
||||
}}
|
||||
expect := []string{"c", "b", "a", "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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBranchRuleMatch(t *testing.T) {
|
||||
kases := []struct {
|
||||
Rule string
|
||||
BranchName string
|
||||
ExpectedMatch bool
|
||||
}{
|
||||
{
|
||||
Rule: "release/*",
|
||||
BranchName: "release/v1.17",
|
||||
ExpectedMatch: true,
|
||||
},
|
||||
{
|
||||
Rule: "release/**/v1.17",
|
||||
BranchName: "release/test/v1.17",
|
||||
ExpectedMatch: true,
|
||||
},
|
||||
{
|
||||
Rule: "release/**/v1.17",
|
||||
BranchName: "release/test/1/v1.17",
|
||||
ExpectedMatch: true,
|
||||
},
|
||||
{
|
||||
Rule: "release/*/v1.17",
|
||||
BranchName: "release/test/1/v1.17",
|
||||
ExpectedMatch: false,
|
||||
},
|
||||
{
|
||||
Rule: "release/v*",
|
||||
BranchName: "release/v1.16",
|
||||
ExpectedMatch: true,
|
||||
},
|
||||
{
|
||||
Rule: "*",
|
||||
BranchName: "release/v1.16",
|
||||
ExpectedMatch: false,
|
||||
},
|
||||
{
|
||||
Rule: "**",
|
||||
BranchName: "release/v1.16",
|
||||
ExpectedMatch: true,
|
||||
},
|
||||
{
|
||||
Rule: "main",
|
||||
BranchName: "main",
|
||||
ExpectedMatch: true,
|
||||
},
|
||||
{
|
||||
Rule: "master",
|
||||
BranchName: "main",
|
||||
ExpectedMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, kase := range kases {
|
||||
pb := ProtectedBranch{RuleName: kase.Rule}
|
||||
var should, infact string
|
||||
if !kase.ExpectedMatch {
|
||||
should = " not"
|
||||
} else {
|
||||
infact = " not"
|
||||
}
|
||||
assert.Equal(t, kase.ExpectedMatch, pb.Match(kase.BranchName),
|
||||
"%s should%s match %s but it is%s", kase.BranchName, should, kase.Rule, infact,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(t.Context()).Insert(pb)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Test updating priorities
|
||||
newPriorities := []int64{protectedBranches[2].ID, protectedBranches[0].ID, protectedBranches[1].ID}
|
||||
err := UpdateProtectBranchPriorities(t.Context(), repo, newPriorities)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify new priorities
|
||||
pbs, err := FindRepoProtectedBranchRules(t.Context(), 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(t.Context(), 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(t.Context(), repo, newPB, WhitelistOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
savedPB2, err := GetFirstMatchProtectedBranchRule(t.Context(), repo.ID, "branch-2")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), savedPB2.Priority)
|
||||
}
|
||||
|
||||
func TestCanBypassBranchProtection(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // not in team 1
|
||||
teamMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
pb := &ProtectedBranch{
|
||||
EnableBypassAllowlist: true,
|
||||
BypassAllowlistUserIDs: []int64{user.ID},
|
||||
}
|
||||
|
||||
testBypass := func(t *testing.T, expected bool, pb *ProtectedBranch, doer *user_model.User, isAdmin bool) {
|
||||
assert.Equal(t, expected, CanBypassBranchProtection(t.Context(), pb, doer, isAdmin))
|
||||
}
|
||||
// User bypasses via explicit allowlist.
|
||||
testBypass(t, true, pb, user, false)
|
||||
|
||||
// Non-admin cannot bypass when allowlist is disabled.
|
||||
pb.EnableBypassAllowlist = false
|
||||
testBypass(t, false, pb, user, false)
|
||||
|
||||
// Repo admin can bypass independently of allowlist when not blocked.
|
||||
testBypass(t, true, pb, user, true)
|
||||
|
||||
// Admin override block still allows bypass for allowlisted users.
|
||||
pb.EnableBypassAllowlist = true
|
||||
pb.BlockAdminMergeOverride = true
|
||||
testBypass(t, true, pb, user, false)
|
||||
|
||||
// admin cannot bypass without allowlist membership.
|
||||
pb.BypassAllowlistUserIDs = nil
|
||||
testBypass(t, false, pb, user, true)
|
||||
|
||||
// admin can bypass when allowlisted.
|
||||
pb.BypassAllowlistUserIDs = []int64{user.ID}
|
||||
testBypass(t, true, pb, user, true)
|
||||
|
||||
// User bypasses via team allowlist membership.
|
||||
pb.EnableBypassAllowlist = true
|
||||
pb.BlockAdminMergeOverride = false
|
||||
pb.BypassAllowlistUserIDs = nil
|
||||
pb.BypassAllowlistTeamIDs = []int64{1} // team 1 contains user 2 in test fixtures
|
||||
testBypass(t, true, pb, teamMember, false)
|
||||
|
||||
// User does not bypass when not in allowlisted teams.
|
||||
testBypass(t, false, pb, user, false)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// ProtectedTag struct
|
||||
type ProtectedTag struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64
|
||||
NamePattern string
|
||||
RegexPattern *regexp.Regexp `xorm:"-"`
|
||||
GlobPattern glob.Glob `xorm:"-"`
|
||||
AllowlistUserIDs []int64 `xorm:"JSON TEXT"`
|
||||
AllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ProtectedTag))
|
||||
}
|
||||
|
||||
// EnsureCompiledPattern ensures the glob pattern is compiled
|
||||
func (pt *ProtectedTag) EnsureCompiledPattern() error {
|
||||
if pt.RegexPattern != nil || pt.GlobPattern != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
if len(pt.NamePattern) >= 2 && strings.HasPrefix(pt.NamePattern, "/") && strings.HasSuffix(pt.NamePattern, "/") {
|
||||
pt.RegexPattern, err = regexp.Compile(pt.NamePattern[1 : len(pt.NamePattern)-1])
|
||||
} else {
|
||||
pt.GlobPattern, err = glob.Compile(pt.NamePattern)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (pt *ProtectedTag) matchString(name string) bool {
|
||||
if pt.RegexPattern != nil {
|
||||
return pt.RegexPattern.MatchString(name)
|
||||
}
|
||||
return pt.GlobPattern.Match(name)
|
||||
}
|
||||
|
||||
// InsertProtectedTag inserts a protected tag to database
|
||||
func InsertProtectedTag(ctx context.Context, pt *ProtectedTag) error {
|
||||
_, err := db.GetEngine(ctx).Insert(pt)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateProtectedTag updates the protected tag
|
||||
func UpdateProtectedTag(ctx context.Context, pt *ProtectedTag) error {
|
||||
_, err := db.GetEngine(ctx).ID(pt.ID).AllCols().Update(pt)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProtectedTag deletes a protected tag by ID
|
||||
func DeleteProtectedTag(ctx context.Context, pt *ProtectedTag) error {
|
||||
_, err := db.GetEngine(ctx).ID(pt.ID).Delete(&ProtectedTag{})
|
||||
return err
|
||||
}
|
||||
|
||||
// IsUserAllowedModifyTag returns true if the user is allowed to modify the tag
|
||||
func IsUserAllowedModifyTag(ctx context.Context, pt *ProtectedTag, userID int64) (bool, error) {
|
||||
if slices.Contains(pt.AllowlistUserIDs, userID) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if len(pt.AllowlistTeamIDs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
in, err := organization.IsUserInTeams(ctx, userID, pt.AllowlistTeamIDs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// GetProtectedTags gets all protected tags of the repository
|
||||
func GetProtectedTags(ctx context.Context, repoID int64) ([]*ProtectedTag, error) {
|
||||
tags := make([]*ProtectedTag, 0)
|
||||
return tags, db.GetEngine(ctx).Find(&tags, &ProtectedTag{RepoID: repoID})
|
||||
}
|
||||
|
||||
// GetProtectedTagByID gets the protected tag with the specific id
|
||||
func GetProtectedTagByID(ctx context.Context, id int64) (*ProtectedTag, error) {
|
||||
tag := new(ProtectedTag)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// GetProtectedTagByNamePattern gets protected tag by name_pattern
|
||||
func GetProtectedTagByNamePattern(ctx context.Context, repoID int64, pattern string) (*ProtectedTag, error) {
|
||||
tag := &ProtectedTag{NamePattern: pattern, RepoID: repoID}
|
||||
has, err := db.GetEngine(ctx).Get(tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// IsUserAllowedToControlTag checks if a user can control the specific tag.
|
||||
// It returns true if the tag name is not protected or the user is allowed to control it.
|
||||
func IsUserAllowedToControlTag(ctx context.Context, tags []*ProtectedTag, tagName string, userID int64) (bool, error) {
|
||||
isAllowed := true
|
||||
for _, tag := range tags {
|
||||
err := tag.EnsureCompiledPattern()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !tag.matchString(tagName) {
|
||||
continue
|
||||
}
|
||||
|
||||
isAllowed, err = IsUserAllowedModifyTag(ctx, tag, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if isAllowed {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return isAllowed, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsUserAllowed(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
pt := &git_model.ProtectedTag{}
|
||||
allowed, err := git_model.IsUserAllowedModifyTag(t.Context(), pt, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
|
||||
pt = &git_model.ProtectedTag{
|
||||
AllowlistUserIDs: []int64{1},
|
||||
}
|
||||
allowed, err = git_model.IsUserAllowedModifyTag(t.Context(), pt, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
|
||||
allowed, err = git_model.IsUserAllowedModifyTag(t.Context(), pt, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
|
||||
pt = &git_model.ProtectedTag{
|
||||
AllowlistTeamIDs: []int64{1},
|
||||
}
|
||||
allowed, err = git_model.IsUserAllowedModifyTag(t.Context(), pt, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
|
||||
allowed, err = git_model.IsUserAllowedModifyTag(t.Context(), pt, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
|
||||
pt = &git_model.ProtectedTag{
|
||||
AllowlistUserIDs: []int64{1},
|
||||
AllowlistTeamIDs: []int64{1},
|
||||
}
|
||||
allowed, err = git_model.IsUserAllowedModifyTag(t.Context(), pt, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
|
||||
allowed, err = git_model.IsUserAllowedModifyTag(t.Context(), pt, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
|
||||
func TestIsUserAllowedToControlTag(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
userid int64
|
||||
allowed bool
|
||||
}{
|
||||
{
|
||||
name: "test",
|
||||
userid: 1,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "test",
|
||||
userid: 3,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "gitea",
|
||||
userid: 1,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "gitea",
|
||||
userid: 3,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "test-gitea",
|
||||
userid: 1,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "test-gitea",
|
||||
userid: 3,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "gitea-test",
|
||||
userid: 1,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "gitea-test",
|
||||
userid: 3,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "v-1",
|
||||
userid: 1,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "v-1",
|
||||
userid: 2,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
userid: 1,
|
||||
allowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Glob", func(t *testing.T) {
|
||||
protectedTags := []*git_model.ProtectedTag{
|
||||
{
|
||||
NamePattern: `*gitea`,
|
||||
AllowlistUserIDs: []int64{1},
|
||||
},
|
||||
{
|
||||
NamePattern: `v-*`,
|
||||
AllowlistUserIDs: []int64{2},
|
||||
},
|
||||
{
|
||||
NamePattern: "release",
|
||||
},
|
||||
}
|
||||
|
||||
for n, c := range cases {
|
||||
isAllowed, err := git_model.IsUserAllowedToControlTag(t.Context(), protectedTags, c.name, c.userid)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c.allowed, isAllowed, "case %d: error should match", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Regex", func(t *testing.T) {
|
||||
protectedTags := []*git_model.ProtectedTag{
|
||||
{
|
||||
NamePattern: `/gitea\z/`,
|
||||
AllowlistUserIDs: []int64{1},
|
||||
},
|
||||
{
|
||||
NamePattern: `/\Av-/`,
|
||||
AllowlistUserIDs: []int64{2},
|
||||
},
|
||||
{
|
||||
NamePattern: "/release/",
|
||||
},
|
||||
}
|
||||
|
||||
for n, c := range cases {
|
||||
isAllowed, err := git_model.IsUserAllowedToControlTag(t.Context(), protectedTags, c.name, c.userid)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c.allowed, isAllowed, "case %d: error should match", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user