初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestArchiveType(t *testing.T) {
|
||||
name, archiveType := SplitArchiveNameType("test.tar.gz")
|
||||
assert.Equal(t, "test", name)
|
||||
assert.Equal(t, "tar.gz", archiveType.String())
|
||||
|
||||
name, archiveType = SplitArchiveNameType("a/b/test.zip")
|
||||
assert.Equal(t, "a/b/test", name)
|
||||
assert.Equal(t, "zip", archiveType.String())
|
||||
|
||||
name, archiveType = SplitArchiveNameType("1234.bundle")
|
||||
assert.Equal(t, "1234", name)
|
||||
assert.Equal(t, "bundle", archiveType.String())
|
||||
|
||||
name, archiveType = SplitArchiveNameType("test")
|
||||
assert.Equal(t, "test", name)
|
||||
assert.Equal(t, "unknown", archiveType.String())
|
||||
|
||||
name, archiveType = SplitArchiveNameType("test.xz")
|
||||
assert.Equal(t, "test.xz", name)
|
||||
assert.Equal(t, "unknown", archiveType.String())
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ArchiverStatus represents repo archive status
|
||||
type ArchiverStatus int
|
||||
|
||||
// enumerate all repo archive statuses
|
||||
const (
|
||||
ArchiverGenerating = iota // the archiver is generating
|
||||
ArchiverReady // it's ready
|
||||
)
|
||||
|
||||
// ArchiveType archive types
|
||||
type ArchiveType int
|
||||
|
||||
const (
|
||||
ArchiveUnknown ArchiveType = iota
|
||||
ArchiveZip // 1
|
||||
ArchiveTarGz // 2
|
||||
ArchiveBundle // 3
|
||||
)
|
||||
|
||||
// String converts an ArchiveType to string: the extension of the archive file without prefix dot
|
||||
func (a ArchiveType) String() string {
|
||||
switch a {
|
||||
case ArchiveZip:
|
||||
return "zip"
|
||||
case ArchiveTarGz:
|
||||
return "tar.gz"
|
||||
case ArchiveBundle:
|
||||
return "bundle"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func SplitArchiveNameType(s string) (string, ArchiveType) {
|
||||
switch {
|
||||
case strings.HasSuffix(s, ".zip"):
|
||||
return strings.TrimSuffix(s, ".zip"), ArchiveZip
|
||||
case strings.HasSuffix(s, ".tar.gz"):
|
||||
return strings.TrimSuffix(s, ".tar.gz"), ArchiveTarGz
|
||||
case strings.HasSuffix(s, ".bundle"):
|
||||
return strings.TrimSuffix(s, ".bundle"), ArchiveBundle
|
||||
}
|
||||
return s, ArchiveUnknown
|
||||
}
|
||||
|
||||
// RepoArchiver represents all archivers
|
||||
type RepoArchiver struct { //revive:disable-line:exported
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"index unique(s)"`
|
||||
Type ArchiveType `xorm:"unique(s)"`
|
||||
Status ArchiverStatus
|
||||
CommitID string `xorm:"VARCHAR(64) unique(s)"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL created"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(RepoArchiver))
|
||||
}
|
||||
|
||||
// RelativePath returns the archive path relative to the archive storage root.
|
||||
func (archiver *RepoArchiver) RelativePath() string {
|
||||
return fmt.Sprintf("%d/%s/%s.%s", archiver.RepoID, archiver.CommitID[:2], archiver.CommitID, archiver.Type.String())
|
||||
}
|
||||
|
||||
// repoArchiverForRelativePath takes a relativePath created from (archiver *RepoArchiver) RelativePath() and creates a shell repoArchiver struct representing it
|
||||
func repoArchiverForRelativePath(relativePath string) (*RepoArchiver, error) {
|
||||
parts := strings.SplitN(relativePath, "/", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil, util.NewInvalidArgumentErrorf("invalid storage path: must have 3 parts")
|
||||
}
|
||||
repoID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return nil, util.NewInvalidArgumentErrorf("invalid storage path: invalid repo id")
|
||||
}
|
||||
commitID, archiveType := SplitArchiveNameType(parts[2])
|
||||
if archiveType == ArchiveUnknown {
|
||||
return nil, util.NewInvalidArgumentErrorf("invalid storage path: invalid archive type")
|
||||
}
|
||||
return &RepoArchiver{RepoID: repoID, CommitID: commitID, Type: archiveType}, nil
|
||||
}
|
||||
|
||||
// GetRepoArchiver get an archiver
|
||||
func GetRepoArchiver(ctx context.Context, repoID int64, tp ArchiveType, commitID string) (*RepoArchiver, error) {
|
||||
var archiver RepoArchiver
|
||||
has, err := db.GetEngine(ctx).Where("repo_id=?", repoID).And("`type`=?", tp).And("commit_id=?", commitID).Get(&archiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if has {
|
||||
return &archiver, nil
|
||||
}
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
||||
}
|
||||
|
||||
// ExistsRepoArchiverWithStoragePath checks if there is a RepoArchiver for a given storage path
|
||||
func ExistsRepoArchiverWithStoragePath(ctx context.Context, storagePath string) (bool, error) {
|
||||
// We need to invert the path provided func (archiver *RepoArchiver) RelativePath() above
|
||||
archiver, err := repoArchiverForRelativePath(storagePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return db.GetEngine(ctx).Exist(archiver)
|
||||
}
|
||||
|
||||
// UpdateRepoArchiverStatus updates archiver's status
|
||||
func UpdateRepoArchiverStatus(ctx context.Context, archiver *RepoArchiver) error {
|
||||
_, err := db.GetEngine(ctx).ID(archiver.ID).Cols("status").Update(archiver)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAllRepoArchives deletes all repo archives records
|
||||
func DeleteAllRepoArchives(ctx context.Context) error {
|
||||
// 1=1 to enforce delete all data, otherwise it will delete nothing
|
||||
_, err := db.GetEngine(ctx).Where("1=1").Delete(new(RepoArchiver))
|
||||
return err
|
||||
}
|
||||
|
||||
// FindRepoArchiversOption represents an archiver options
|
||||
type FindRepoArchiversOption struct {
|
||||
db.ListOptions
|
||||
OlderThan time.Duration
|
||||
}
|
||||
|
||||
func (opts FindRepoArchiversOption) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.OlderThan > 0 {
|
||||
cond = cond.And(builder.Lt{"created_unix": time.Now().Add(-opts.OlderThan).Unix()})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts FindRepoArchiversOption) ToOrders() string {
|
||||
return "created_unix ASC"
|
||||
}
|
||||
|
||||
// SetArchiveRepoState sets if a repo is archived
|
||||
func SetArchiveRepoState(ctx context.Context, repo *Repository, isArchived bool) (err error) {
|
||||
repo.IsArchived = isArchived
|
||||
|
||||
if isArchived {
|
||||
repo.ArchivedUnix = timeutil.TimeStampNow()
|
||||
} else {
|
||||
repo.ArchivedUnix = timeutil.TimeStamp(0)
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(repo.ID).Cols("is_archived", "archived_unix").NoAutoTime().Update(repo)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// Attachment represent a attachment of issue/comment/release.
|
||||
type Attachment struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UUID string `xorm:"uuid UNIQUE"`
|
||||
RepoID int64 `xorm:"INDEX"` // this should not be zero
|
||||
IssueID int64 `xorm:"INDEX"` // maybe zero when creating
|
||||
ReleaseID int64 `xorm:"INDEX"` // maybe zero when creating
|
||||
UploaderID int64 `xorm:"INDEX DEFAULT 0"` // Notice: will be zero before this column added
|
||||
CommentID int64 `xorm:"INDEX"`
|
||||
Name string
|
||||
DownloadCount int64 `xorm:"DEFAULT 0"`
|
||||
Size int64 `xorm:"DEFAULT 0"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
CustomDownloadURL string `xorm:"-"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Attachment))
|
||||
}
|
||||
|
||||
// IncreaseDownloadCount is update download count + 1
|
||||
func (a *Attachment) IncreaseDownloadCount(ctx context.Context) error {
|
||||
// Update download count.
|
||||
if _, err := db.GetEngine(ctx).Exec("UPDATE `attachment` SET download_count=download_count+1 WHERE id=?", a.ID); err != nil {
|
||||
return fmt.Errorf("increase attachment count: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachmentRelativePath returns the relative path
|
||||
func AttachmentRelativePath(uuid string) string {
|
||||
return path.Join(uuid[0:1], uuid[1:2], uuid)
|
||||
}
|
||||
|
||||
// RelativePath returns the relative path of the attachment
|
||||
func (a *Attachment) RelativePath() string {
|
||||
return AttachmentRelativePath(a.UUID)
|
||||
}
|
||||
|
||||
// DownloadURL returns the download url of the attached file
|
||||
func (a *Attachment) DownloadURL() string {
|
||||
if a.CustomDownloadURL != "" {
|
||||
return a.CustomDownloadURL
|
||||
}
|
||||
|
||||
return setting.AppURL + "attachments/" + url.PathEscape(a.UUID)
|
||||
}
|
||||
|
||||
// ErrAttachmentNotExist represents a "AttachmentNotExist" kind of error.
|
||||
type ErrAttachmentNotExist struct {
|
||||
ID int64
|
||||
UUID string
|
||||
}
|
||||
|
||||
// IsErrAttachmentNotExist checks if an error is a ErrAttachmentNotExist.
|
||||
func IsErrAttachmentNotExist(err error) bool {
|
||||
_, ok := err.(ErrAttachmentNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrAttachmentNotExist) Error() string {
|
||||
return fmt.Sprintf("attachment does not exist [id: %d, uuid: %s]", err.ID, err.UUID)
|
||||
}
|
||||
|
||||
func (err ErrAttachmentNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// GetAttachmentByID returns attachment by given id
|
||||
func GetAttachmentByID(ctx context.Context, id int64) (*Attachment, error) {
|
||||
attach := &Attachment{}
|
||||
if has, err := db.GetEngine(ctx).ID(id).Get(attach); err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrAttachmentNotExist{ID: id, UUID: ""}
|
||||
}
|
||||
return attach, nil
|
||||
}
|
||||
|
||||
// GetAttachmentByUUID returns attachment by given UUID.
|
||||
func GetAttachmentByUUID(ctx context.Context, uuid string) (*Attachment, error) {
|
||||
attach := &Attachment{}
|
||||
has, err := db.GetEngine(ctx).Where("uuid=?", uuid).Get(attach)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrAttachmentNotExist{0, uuid}
|
||||
}
|
||||
return attach, nil
|
||||
}
|
||||
|
||||
// GetAttachmentsByUUIDs returns attachment by given UUID list.
|
||||
func GetAttachmentsByUUIDs(ctx context.Context, uuids []string) ([]*Attachment, error) {
|
||||
if len(uuids) == 0 {
|
||||
return []*Attachment{}, nil
|
||||
}
|
||||
|
||||
// Silently drop invalid uuids.
|
||||
attachments := make([]*Attachment, 0, len(uuids))
|
||||
return attachments, db.GetEngine(ctx).In("uuid", uuids).Find(&attachments)
|
||||
}
|
||||
|
||||
// ExistAttachmentsByUUID returns true if attachment exists with the given UUID
|
||||
func ExistAttachmentsByUUID(ctx context.Context, uuid string) (bool, error) {
|
||||
return db.GetEngine(ctx).Where("`uuid`=?", uuid).Exist(new(Attachment))
|
||||
}
|
||||
|
||||
// GetAttachmentsByIssueID returns all attachments of an issue.
|
||||
func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment, error) {
|
||||
attachments := make([]*Attachment, 0, 10)
|
||||
return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments)
|
||||
}
|
||||
|
||||
// GetAttachmentsByIssueIDImagesLatest returns the latest image attachments of an issue.
|
||||
func GetAttachmentsByIssueIDImagesLatest(ctx context.Context, issueID int64) ([]*Attachment, error) {
|
||||
attachments := make([]*Attachment, 0, 5)
|
||||
return attachments, db.GetEngine(ctx).Where(`issue_id = ? AND (name like '%.apng'
|
||||
OR name like '%.avif'
|
||||
OR name like '%.bmp'
|
||||
OR name like '%.gif'
|
||||
OR name like '%.jpg'
|
||||
OR name like '%.jpeg'
|
||||
OR name like '%.jxl'
|
||||
OR name like '%.png'
|
||||
OR name like '%.svg'
|
||||
OR name like '%.webp')`, issueID).Desc("comment_id").Limit(5).Find(&attachments)
|
||||
}
|
||||
|
||||
// GetAttachmentsByCommentID returns all attachments if comment by given ID.
|
||||
func GetAttachmentsByCommentID(ctx context.Context, commentID int64) ([]*Attachment, error) {
|
||||
attachments := make([]*Attachment, 0, 10)
|
||||
return attachments, db.GetEngine(ctx).Where("comment_id=?", commentID).Find(&attachments)
|
||||
}
|
||||
|
||||
// GetAttachmentByReleaseIDFileName returns attachment by given releaseId and fileName.
|
||||
func GetAttachmentByReleaseIDFileName(ctx context.Context, releaseID int64, fileName string) (*Attachment, error) {
|
||||
attach := &Attachment{ReleaseID: releaseID, Name: fileName}
|
||||
has, err := db.GetEngine(ctx).Get(attach)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, err
|
||||
}
|
||||
return attach, nil
|
||||
}
|
||||
|
||||
func GetUnlinkedAttachmentsByUserID(ctx context.Context, userID int64) ([]*Attachment, error) {
|
||||
attachments := make([]*Attachment, 0, 10)
|
||||
return attachments, db.GetEngine(ctx).Where("uploader_id = ? AND issue_id = 0 AND release_id = 0 AND comment_id = 0", userID).Find(&attachments)
|
||||
}
|
||||
|
||||
// DeleteAttachment deletes the given attachment and optionally the associated file.
|
||||
func DeleteAttachment(ctx context.Context, a *Attachment, remove bool) error {
|
||||
_, err := DeleteAttachments(ctx, []*Attachment{a}, remove)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAttachments deletes the given attachments and optionally the associated files.
|
||||
func DeleteAttachments(ctx context.Context, attachments []*Attachment, remove bool) (int, error) {
|
||||
if len(attachments) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(attachments))
|
||||
for _, a := range attachments {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
|
||||
cnt, err := db.GetEngine(ctx).In("id", ids).NoAutoCondition().Delete(attachments[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if remove {
|
||||
for i, a := range attachments {
|
||||
if err := storage.Attachments.Delete(a.RelativePath()); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return i, err
|
||||
}
|
||||
log.Warn("Attachment file not found when deleting: %s", a.RelativePath())
|
||||
}
|
||||
}
|
||||
}
|
||||
return int(cnt), nil
|
||||
}
|
||||
|
||||
// DeleteAttachmentsByIssue deletes all attachments associated with the given issue.
|
||||
func DeleteAttachmentsByIssue(ctx context.Context, issueID int64, remove bool) (int, error) {
|
||||
attachments, err := GetAttachmentsByIssueID(ctx, issueID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return DeleteAttachments(ctx, attachments, remove)
|
||||
}
|
||||
|
||||
// DeleteAttachmentsByComment deletes all attachments associated with the given comment.
|
||||
func DeleteAttachmentsByComment(ctx context.Context, commentID int64, remove bool) (int, error) {
|
||||
attachments, err := GetAttachmentsByCommentID(ctx, commentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return DeleteAttachments(ctx, attachments, remove)
|
||||
}
|
||||
|
||||
// UpdateAttachmentByUUID Updates attachment via uuid
|
||||
func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...string) error {
|
||||
if attach.UUID == "" {
|
||||
return errors.New("attachment uuid should be not blank")
|
||||
}
|
||||
_, err := db.GetEngine(ctx).Where("uuid=?", attach.UUID).Cols(cols...).Update(attach)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAttachment updates the given attachment in database
|
||||
func UpdateAttachment(ctx context.Context, atta *Attachment) error {
|
||||
sess := db.GetEngine(ctx).Cols("name", "issue_id", "release_id", "comment_id", "download_count")
|
||||
if atta.ID != 0 && atta.UUID == "" {
|
||||
sess = sess.ID(atta.ID)
|
||||
} else {
|
||||
// Use uuid only if id is not set and uuid is set
|
||||
sess = sess.Where("uuid = ?", atta.UUID)
|
||||
}
|
||||
_, err := sess.Update(atta)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteAttachmentsByRelease deletes all attachments associated with the given release.
|
||||
func DeleteAttachmentsByRelease(ctx context.Context, releaseID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("release_id = ?", releaseID).Delete(&Attachment{})
|
||||
return err
|
||||
}
|
||||
|
||||
// CountOrphanedAttachments returns the number of bad attachments
|
||||
func CountOrphanedAttachments(ctx context.Context) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("(issue_id > 0 and issue_id not in (select id from issue)) or (release_id > 0 and release_id not in (select id from `release`))").
|
||||
Count(new(Attachment))
|
||||
}
|
||||
|
||||
// DeleteOrphanedAttachments delete all bad attachments
|
||||
func DeleteOrphanedAttachments(ctx context.Context) error {
|
||||
_, err := db.GetEngine(ctx).Where("(issue_id > 0 and issue_id not in (select id from issue)) or (release_id > 0 and release_id not in (select id from `release`))").
|
||||
Delete(new(Attachment))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIncreaseDownloadCount(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
attachment, err := repo_model.GetAttachmentByUUID(t.Context(), "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(0), attachment.DownloadCount)
|
||||
|
||||
// increase download count
|
||||
err = attachment.IncreaseDownloadCount(t.Context())
|
||||
assert.NoError(t, err)
|
||||
|
||||
attachment, err = repo_model.GetAttachmentByUUID(t.Context(), "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), attachment.DownloadCount)
|
||||
}
|
||||
|
||||
func TestGetByCommentOrIssueID(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// count of attachments from issue ID
|
||||
attachments, err := repo_model.GetAttachmentsByIssueID(t.Context(), 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attachments, 1)
|
||||
|
||||
attachments, err = repo_model.GetAttachmentsByCommentID(t.Context(), 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attachments, 2)
|
||||
}
|
||||
|
||||
func TestDeleteAttachments(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
count, err := repo_model.DeleteAttachmentsByIssue(t.Context(), 4, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
count, err = repo_model.DeleteAttachmentsByComment(t.Context(), 2, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
err = repo_model.DeleteAttachment(t.Context(), &repo_model.Attachment{ID: 8}, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
attachment, err := repo_model.GetAttachmentByUUID(t.Context(), "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18")
|
||||
assert.Error(t, err)
|
||||
assert.True(t, repo_model.IsErrAttachmentNotExist(err))
|
||||
assert.Nil(t, attachment)
|
||||
}
|
||||
|
||||
func TestGetAttachmentByID(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
attach, err := repo_model.GetAttachmentByID(t.Context(), 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", attach.UUID)
|
||||
}
|
||||
|
||||
func TestAttachment_DownloadURL(t *testing.T) {
|
||||
attach := &repo_model.Attachment{
|
||||
UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
|
||||
ID: 1,
|
||||
}
|
||||
assert.Equal(t, "https://try.gitea.io/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", attach.DownloadURL())
|
||||
}
|
||||
|
||||
func TestUpdateAttachment(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
attach, err := repo_model.GetAttachmentByID(t.Context(), 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", attach.UUID)
|
||||
|
||||
attach.Name = "new_name"
|
||||
assert.NoError(t, repo_model.UpdateAttachment(t.Context(), attach))
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{Name: "new_name"})
|
||||
}
|
||||
|
||||
func TestGetAttachmentsByUUIDs(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
attachList, err := repo_model.GetAttachmentsByUUIDs(t.Context(), []string{"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a17", "not-existing-uuid"})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attachList, 2)
|
||||
assert.Equal(t, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", attachList[0].UUID)
|
||||
assert.Equal(t, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a17", attachList[1].UUID)
|
||||
assert.Equal(t, int64(1), attachList[0].IssueID)
|
||||
assert.Equal(t, int64(5), attachList[1].IssueID)
|
||||
}
|
||||
|
||||
func TestGetUnlinkedAttachmentsByUserID(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 8)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attachments, 1)
|
||||
assert.Equal(t, int64(10), attachments[0].ID)
|
||||
assert.Zero(t, attachments[0].IssueID)
|
||||
assert.Zero(t, attachments[0].ReleaseID)
|
||||
assert.Zero(t, attachments[0].CommentID)
|
||||
|
||||
attachments, err = repo_model.GetUnlinkedAttachmentsByUserID(t.Context(), 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, attachments)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/avatar"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
)
|
||||
|
||||
// CustomAvatarRelativePath returns repository custom avatar file path.
|
||||
func (repo *Repository) CustomAvatarRelativePath() string {
|
||||
return repo.Avatar
|
||||
}
|
||||
|
||||
// ExistsWithAvatarAtStoragePath returns true if there is a user with this Avatar
|
||||
func ExistsWithAvatarAtStoragePath(ctx context.Context, storagePath string) (bool, error) {
|
||||
// See func (repo *Repository) CustomAvatarRelativePath()
|
||||
// repo.Avatar is used directly as the storage path - therefore we can check for existence directly using the path
|
||||
return db.GetEngine(ctx).Where("`avatar`=?", storagePath).Exist(new(Repository))
|
||||
}
|
||||
|
||||
// RelAvatarLink returns a relative link to the repository's avatar.
|
||||
func (repo *Repository) RelAvatarLink(ctx context.Context) string {
|
||||
return repo.relAvatarLink(ctx)
|
||||
}
|
||||
|
||||
// generateRandomAvatar generates a random avatar for repository.
|
||||
func generateRandomAvatar(ctx context.Context, repo *Repository) error {
|
||||
idToString := strconv.FormatInt(repo.ID, 10)
|
||||
|
||||
seed := idToString
|
||||
img := avatar.RandomImageDefaultSize([]byte(seed))
|
||||
|
||||
repo.Avatar = idToString
|
||||
|
||||
if err := storage.SaveFrom(storage.RepoAvatars, repo.CustomAvatarRelativePath(), func(w io.Writer) error {
|
||||
return png.Encode(w, img)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to create dir %s: %w", repo.CustomAvatarRelativePath(), err)
|
||||
}
|
||||
|
||||
log.Info("New random avatar created for repository: %d", repo.ID)
|
||||
|
||||
if _, err := db.GetEngine(ctx).ID(repo.ID).Cols("avatar").NoAutoTime().Update(repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *Repository) relAvatarLink(ctx context.Context) string {
|
||||
// If no avatar - path is empty
|
||||
avatarPath := repo.CustomAvatarRelativePath()
|
||||
if len(avatarPath) == 0 {
|
||||
switch mode := setting.RepoAvatar.Fallback; mode {
|
||||
case "image":
|
||||
return setting.RepoAvatar.FallbackImage
|
||||
case "random":
|
||||
if err := generateRandomAvatar(ctx, repo); err != nil {
|
||||
log.Error("generateRandomAvatar: %v", err)
|
||||
}
|
||||
default:
|
||||
// default behaviour: do not display avatar
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return setting.AppSubURL + "/repo-avatars/" + url.PathEscape(repo.Avatar)
|
||||
}
|
||||
|
||||
// AvatarLink returns the full avatar url with http host or the empty string if the repo doesn't have an avatar.
|
||||
//
|
||||
// TODO: refactor it to a relative URL, but it is still used in API response at the moment
|
||||
func (repo *Repository) AvatarLink(ctx context.Context) string {
|
||||
relLink := repo.relAvatarLink(ctx)
|
||||
if relLink != "" {
|
||||
return httplib.MakeAbsoluteURL(ctx, relLink)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepoAvatarLink(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppURL, "https://localhost/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "")()
|
||||
|
||||
repo := &Repository{ID: 1, Avatar: "avatar.png"}
|
||||
link := repo.AvatarLink(t.Context())
|
||||
assert.Equal(t, "https://localhost/repo-avatars/avatar.png", link)
|
||||
|
||||
setting.AppURL = "https://localhost/sub-path/"
|
||||
setting.AppSubURL = "/sub-path"
|
||||
link = repo.AvatarLink(t.Context())
|
||||
assert.Equal(t, "https://localhost/sub-path/repo-avatars/avatar.png", link)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// Collaboration represent the relation between an individual and a repository.
|
||||
type Collaboration struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
UserID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
Mode perm.AccessMode `xorm:"DEFAULT 2 NOT NULL"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Collaboration))
|
||||
}
|
||||
|
||||
// Collaborator represents a user with collaboration details.
|
||||
type Collaborator struct {
|
||||
*user_model.User
|
||||
Collaboration *Collaboration
|
||||
}
|
||||
|
||||
type FindCollaborationOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
RepoOwnerID int64
|
||||
CollaboratorID int64
|
||||
}
|
||||
|
||||
func (opts *FindCollaborationOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.RepoID != 0 {
|
||||
cond = cond.And(builder.Eq{"collaboration.repo_id": opts.RepoID})
|
||||
}
|
||||
if opts.RepoOwnerID != 0 {
|
||||
cond = cond.And(builder.Eq{"repository.owner_id": opts.RepoOwnerID})
|
||||
}
|
||||
if opts.CollaboratorID != 0 {
|
||||
cond = cond.And(builder.Eq{"collaboration.user_id": opts.CollaboratorID})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts *FindCollaborationOptions) ToJoins() []db.JoinFunc {
|
||||
if opts.RepoOwnerID != 0 {
|
||||
return []db.JoinFunc{
|
||||
func(e db.Engine) error {
|
||||
e.Join("INNER", "repository", "repository.id = collaboration.repo_id")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCollaborators returns the collaborators for a repository
|
||||
func GetCollaborators(ctx context.Context, opts *FindCollaborationOptions) ([]*Collaborator, int64, error) {
|
||||
collaborations, total, err := db.FindAndCount[Collaboration](ctx, opts)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("db.FindAndCount[Collaboration]: %w", err)
|
||||
}
|
||||
|
||||
collaborators := make([]*Collaborator, 0, len(collaborations))
|
||||
userIDs := make([]int64, 0, len(collaborations))
|
||||
for _, c := range collaborations {
|
||||
userIDs = append(userIDs, c.UserID)
|
||||
}
|
||||
|
||||
usersMap := make(map[int64]*user_model.User)
|
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&usersMap); err != nil {
|
||||
return nil, 0, fmt.Errorf("Find users map by user ids: %w", err)
|
||||
}
|
||||
|
||||
for _, c := range collaborations {
|
||||
u := usersMap[c.UserID]
|
||||
if u == nil {
|
||||
u = user_model.NewGhostUser()
|
||||
}
|
||||
collaborators = append(collaborators, &Collaborator{
|
||||
User: u,
|
||||
Collaboration: c,
|
||||
})
|
||||
}
|
||||
return collaborators, total, nil
|
||||
}
|
||||
|
||||
// GetCollaboration get collaboration for a repository id with a user id
|
||||
func GetCollaboration(ctx context.Context, repoID, uid int64) (*Collaboration, error) {
|
||||
collaboration := &Collaboration{
|
||||
RepoID: repoID,
|
||||
UserID: uid,
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(collaboration)
|
||||
if !has {
|
||||
collaboration = nil
|
||||
}
|
||||
return collaboration, err
|
||||
}
|
||||
|
||||
// IsCollaborator check if a user is a collaborator of a repository
|
||||
func IsCollaborator(ctx context.Context, repoID, userID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).Get(&Collaboration{RepoID: repoID, UserID: userID})
|
||||
}
|
||||
|
||||
// ChangeCollaborationAccessMode sets new access mode for the collaboration.
|
||||
func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid int64, mode perm.AccessMode) error {
|
||||
// Discard invalid input
|
||||
if mode <= perm.AccessModeNone || mode > perm.AccessModeOwner {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
collaboration := &Collaboration{
|
||||
RepoID: repo.ID,
|
||||
UserID: uid,
|
||||
}
|
||||
has, err := e.Get(collaboration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get collaboration: %w", err)
|
||||
} else if !has {
|
||||
return nil
|
||||
}
|
||||
|
||||
if collaboration.Mode == mode {
|
||||
return nil
|
||||
}
|
||||
collaboration.Mode = mode
|
||||
|
||||
if _, err = e.
|
||||
ID(collaboration.ID).
|
||||
Cols("mode").
|
||||
Update(collaboration); err != nil {
|
||||
return fmt.Errorf("update collaboration: %w", err)
|
||||
} else if _, err = e.Exec("UPDATE access SET mode = ? WHERE user_id = ? AND repo_id = ?", mode, uid, repo.ID); err != nil {
|
||||
return fmt.Errorf("update access table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
|
||||
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
|
||||
if repo.OwnerID == userID {
|
||||
return true, nil
|
||||
}
|
||||
teamMember, err := db.GetEngine(ctx).Join("INNER", "team_repo", "team_repo.team_id = team_user.team_id").
|
||||
Join("INNER", "team_unit", "team_unit.team_id = team_user.team_id").
|
||||
Where("team_repo.repo_id = ?", repo.ID).
|
||||
And("team_unit.`type` = ?", unit.TypeCode).
|
||||
And("team_user.uid = ?", userID).Table("team_user").Exist()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if teamMember {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return db.GetEngine(ctx).Get(&Collaboration{RepoID: repo.ID, UserID: userID})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_GetCollaborators(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
test := func(repoID int64) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
collaborators, _, err := repo_model.GetCollaborators(t.Context(), &repo_model.FindCollaborationOptions{RepoID: repo.ID})
|
||||
assert.NoError(t, err)
|
||||
expectedLen, err := db.GetEngine(t.Context()).Count(&repo_model.Collaboration{RepoID: repoID})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collaborators, int(expectedLen))
|
||||
for _, collaborator := range collaborators {
|
||||
assert.Equal(t, collaborator.User.ID, collaborator.Collaboration.UserID)
|
||||
assert.Equal(t, repoID, collaborator.Collaboration.RepoID)
|
||||
}
|
||||
}
|
||||
test(1)
|
||||
test(2)
|
||||
test(3)
|
||||
test(4)
|
||||
|
||||
// Test db.ListOptions
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22})
|
||||
|
||||
collaborators1, _, err := repo_model.GetCollaborators(t.Context(), &repo_model.FindCollaborationOptions{
|
||||
ListOptions: db.ListOptions{PageSize: 1, Page: 1},
|
||||
RepoID: repo.ID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collaborators1, 1)
|
||||
|
||||
collaborators2, _, err := repo_model.GetCollaborators(t.Context(), &repo_model.FindCollaborationOptions{
|
||||
ListOptions: db.ListOptions{PageSize: 1, Page: 2},
|
||||
RepoID: repo.ID,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collaborators2, 1)
|
||||
|
||||
assert.NotEqual(t, collaborators1[0].ID, collaborators2[0].ID)
|
||||
}
|
||||
|
||||
func TestRepository_IsCollaborator(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
test := func(repoID, userID int64, expected bool) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
actual, err := repo_model.IsCollaborator(t.Context(), repo.ID, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
test(3, 2, true)
|
||||
test(3, unittest.NonexistentID, false)
|
||||
test(4, 2, false)
|
||||
test(4, 4, true)
|
||||
}
|
||||
|
||||
func TestRepository_ChangeCollaborationAccessMode(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
|
||||
|
||||
collaboration := unittest.AssertExistsAndLoadBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4})
|
||||
assert.Equal(t, perm.AccessModeAdmin, collaboration.Mode)
|
||||
|
||||
access := unittest.AssertExistsAndLoadBean(t, &access_model.Access{UserID: 4, RepoID: repo.ID})
|
||||
assert.Equal(t, perm.AccessModeAdmin, access.Mode)
|
||||
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
|
||||
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, unittest.NonexistentID, perm.AccessModeAdmin))
|
||||
|
||||
// Discard invalid input.
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessMode(-1)))
|
||||
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID})
|
||||
}
|
||||
|
||||
func TestRepository_IsOwnerMemberCollaborator(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
|
||||
// Organisation owner.
|
||||
actual, err := repo_model.IsOwnerMemberCollaborator(t.Context(), repo1, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, actual)
|
||||
|
||||
// Team member.
|
||||
actual, err = repo_model.IsOwnerMemberCollaborator(t.Context(), repo1, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, actual)
|
||||
|
||||
// Normal user.
|
||||
actual, err = repo_model.IsOwnerMemberCollaborator(t.Context(), repo1, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, actual)
|
||||
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
|
||||
// Collaborator.
|
||||
actual, err = repo_model.IsOwnerMemberCollaborator(t.Context(), repo2, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, actual)
|
||||
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15})
|
||||
|
||||
// Repository owner.
|
||||
actual, err = repo_model.IsOwnerMemberCollaborator(t.Context(), repo3, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, actual)
|
||||
}
|
||||
|
||||
func TestRepo_GetCollaboration(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
|
||||
// Existing collaboration.
|
||||
collab, err := repo_model.GetCollaboration(t.Context(), repo.ID, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, collab)
|
||||
assert.EqualValues(t, 4, collab.UserID)
|
||||
assert.EqualValues(t, 4, collab.RepoID)
|
||||
|
||||
// Non-existing collaboration.
|
||||
collab, err = repo_model.GetCollaboration(t.Context(), repo.ID, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, collab)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// GetRepositoriesByForkID returns all repositories with given fork ID.
|
||||
func GetRepositoriesByForkID(ctx context.Context, forkID int64) ([]*Repository, error) {
|
||||
repos := make([]*Repository, 0, 10)
|
||||
return repos, db.GetEngine(ctx).
|
||||
Where("fork_id=?", forkID).
|
||||
Find(&repos)
|
||||
}
|
||||
|
||||
// GetForkedRepo checks if given user has already forked a repository with given ID.
|
||||
func GetForkedRepo(ctx context.Context, ownerID, repoID int64) *Repository {
|
||||
repo := new(Repository)
|
||||
has, _ := db.GetEngine(ctx).
|
||||
Where("owner_id=? AND fork_id=?", ownerID, repoID).
|
||||
Get(repo)
|
||||
if has {
|
||||
return repo
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasForkedRepo checks if given user has already forked a repository with given ID.
|
||||
func HasForkedRepo(ctx context.Context, ownerID, repoID int64) bool {
|
||||
has, _ := db.GetEngine(ctx).
|
||||
Table("repository").
|
||||
Where("owner_id=? AND fork_id=?", ownerID, repoID).
|
||||
Exist()
|
||||
return has
|
||||
}
|
||||
|
||||
// GetUserFork return user forked repository from this repository, if not forked return nil
|
||||
func GetUserFork(ctx context.Context, repoID, userID int64) (*Repository, error) {
|
||||
var forkedRepo Repository
|
||||
has, err := db.GetEngine(ctx).Where("fork_id = ?", repoID).And("owner_id = ?", userID).Get(&forkedRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
||||
}
|
||||
return &forkedRepo, nil
|
||||
}
|
||||
|
||||
// IncrementRepoForkNum increment repository fork number
|
||||
func IncrementRepoForkNum(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET num_forks=num_forks+1 WHERE id=?", repoID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DecrementRepoForkNum decrement repository fork number
|
||||
func DecrementRepoForkNum(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET num_forks=num_forks-1 WHERE id=?", repoID)
|
||||
return err
|
||||
}
|
||||
|
||||
// FindUserOrgForks returns the forked repositories for one user from a repository
|
||||
func FindUserOrgForks(ctx context.Context, repoID, userID int64) ([]*Repository, error) {
|
||||
cond := builder.And(
|
||||
builder.Eq{"fork_id": repoID},
|
||||
builder.In("owner_id",
|
||||
builder.Select("org_id").
|
||||
From("org_user").
|
||||
Where(builder.Eq{"uid": userID}),
|
||||
),
|
||||
)
|
||||
|
||||
var repos []*Repository
|
||||
return repos, db.GetEngine(ctx).Table("repository").Where(cond).Find(&repos)
|
||||
}
|
||||
|
||||
// GetForksByUserAndOrgs return forked repos of the user and owned orgs
|
||||
func GetForksByUserAndOrgs(ctx context.Context, user *user_model.User, repo *Repository) ([]*Repository, error) {
|
||||
var repoList []*Repository
|
||||
if user == nil {
|
||||
return repoList, nil
|
||||
}
|
||||
forkedRepo, err := GetUserFork(ctx, repo.ID, user.ID)
|
||||
if err != nil {
|
||||
return repoList, err
|
||||
}
|
||||
if forkedRepo != nil {
|
||||
repoList = append(repoList, forkedRepo)
|
||||
}
|
||||
orgForks, err := FindUserOrgForks(ctx, repo.ID, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repoList = append(repoList, orgForks...)
|
||||
return repoList, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetUserFork(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// User13 has repo 11 forked from repo10
|
||||
repo, err := repo_model.GetRepositoryByID(t.Context(), 10)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, repo)
|
||||
repo, err = repo_model.GetUserFork(t.Context(), repo.ID, 13)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, repo)
|
||||
|
||||
repo, err = repo_model.GetRepositoryByID(t.Context(), 9)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, repo)
|
||||
repo, err = repo_model.GetUserFork(t.Context(), repo.ID, 13)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, repo)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
)
|
||||
|
||||
// MergeStyle represents the approach to merge commits into base branch.
|
||||
type MergeStyle string
|
||||
|
||||
const (
|
||||
// MergeStyleMerge create merge commit
|
||||
MergeStyleMerge MergeStyle = "merge"
|
||||
// MergeStyleRebase rebase before merging, and fast-forward
|
||||
MergeStyleRebase MergeStyle = "rebase"
|
||||
// MergeStyleRebaseMerge rebase before merging with merge commit (--no-ff)
|
||||
MergeStyleRebaseMerge MergeStyle = "rebase-merge"
|
||||
// MergeStyleSquash squash commits into single commit before merging
|
||||
MergeStyleSquash MergeStyle = "squash"
|
||||
// MergeStyleFastForwardOnly fast-forward merge if possible, otherwise fail
|
||||
MergeStyleFastForwardOnly MergeStyle = "fast-forward-only"
|
||||
// MergeStyleManuallyMerged pr has been merged manually, just mark it as merged directly
|
||||
MergeStyleManuallyMerged MergeStyle = "manually-merged"
|
||||
// MergeStyleRebaseUpdate not a merge style, used to update pull head by rebase
|
||||
MergeStyleRebaseUpdate MergeStyle = "rebase-update-only"
|
||||
)
|
||||
|
||||
// UpdateStyle is a pull request branch update style
|
||||
type UpdateStyle string
|
||||
|
||||
const (
|
||||
// UpdateStyleMerge merges the base branch into the pull request branch
|
||||
UpdateStyleMerge UpdateStyle = "merge"
|
||||
// UpdateStyleRebase rebases the pull request branch onto the base branch
|
||||
UpdateStyleRebase UpdateStyle = "rebase"
|
||||
)
|
||||
|
||||
// UpdateDefaultBranch updates the default branch
|
||||
func UpdateDefaultBranch(ctx context.Context, repo *Repository) error {
|
||||
_, err := db.GetEngine(ctx).ID(repo.ID).Cols("default_branch").Update(repo)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// ___________.__ ___________ __
|
||||
// \__ ___/|__| _____ ___\__ ___/___________ ____ | | __ ___________
|
||||
// | | | |/ \_/ __ \| | \_ __ \__ \ _/ ___\| |/ // __ \_ __ \
|
||||
// | | | | Y Y \ ___/| | | | \// __ \\ \___| <\ ___/| | \/
|
||||
// |____| |__|__|_| /\___ >____| |__| (____ /\___ >__|_ \\___ >__|
|
||||
// \/ \/ \/ \/ \/ \/
|
||||
|
||||
// CanEnableTimetracker returns true when the server admin enabled time tracking
|
||||
// This overrules IsTimetrackerEnabled
|
||||
func (repo *Repository) CanEnableTimetracker() bool {
|
||||
return setting.Service.EnableTimetracking
|
||||
}
|
||||
|
||||
// IsTimetrackerEnabled returns whether or not the timetracker is enabled. It returns the default value from config if an error occurs.
|
||||
func (repo *Repository) IsTimetrackerEnabled(ctx context.Context) bool {
|
||||
if !setting.Service.EnableTimetracking {
|
||||
return false
|
||||
}
|
||||
|
||||
var u *RepoUnit
|
||||
var err error
|
||||
if u, err = repo.GetUnit(ctx, unit.TypeIssues); err != nil {
|
||||
return setting.Service.DefaultEnableTimetracking
|
||||
}
|
||||
return u.IssuesConfig().EnableTimetracker
|
||||
}
|
||||
|
||||
// AllowOnlyContributorsToTrackTime returns value of IssuesConfig or the default value
|
||||
func (repo *Repository) AllowOnlyContributorsToTrackTime(ctx context.Context) bool {
|
||||
var u *RepoUnit
|
||||
var err error
|
||||
if u, err = repo.GetUnit(ctx, unit.TypeIssues); err != nil {
|
||||
return setting.Service.DefaultAllowOnlyContributorsToTrackTime
|
||||
}
|
||||
return u.IssuesConfig().AllowOnlyContributorsToTrackTime
|
||||
}
|
||||
|
||||
// IsDependenciesEnabled returns if dependencies are enabled and returns the default setting if not set.
|
||||
func (repo *Repository) IsDependenciesEnabled(ctx context.Context) bool {
|
||||
var u *RepoUnit
|
||||
var err error
|
||||
if u, err = repo.GetUnit(ctx, unit.TypeIssues); err != nil {
|
||||
log.Trace("IsDependenciesEnabled: %v", err)
|
||||
return setting.Service.DefaultEnableDependencies
|
||||
}
|
||||
return u.IssuesConfig().EnableDependencies
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/go-enry/go-enry/v2"
|
||||
)
|
||||
|
||||
// LanguageStat describes language statistics of a repository
|
||||
type LanguageStat struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
CommitID string
|
||||
IsPrimary bool
|
||||
Language string `xorm:"VARCHAR(50) UNIQUE(s) INDEX NOT NULL"`
|
||||
Percentage float32 `xorm:"-"`
|
||||
Size int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
Color string `xorm:"-"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(LanguageStat))
|
||||
}
|
||||
|
||||
// LanguageStatList defines a list of language statistics
|
||||
type LanguageStatList []*LanguageStat
|
||||
|
||||
// LoadAttributes loads attributes
|
||||
func (stats LanguageStatList) LoadAttributes() {
|
||||
for i := range stats {
|
||||
stats[i].Color = enry.GetColor(stats[i].Language)
|
||||
}
|
||||
}
|
||||
|
||||
func (stats LanguageStatList) getLanguagePercentages() map[string]float32 {
|
||||
langPerc := make(map[string]float32)
|
||||
var otherPerc float32
|
||||
var total int64
|
||||
|
||||
for _, stat := range stats {
|
||||
total += stat.Size
|
||||
}
|
||||
if total > 0 {
|
||||
for _, stat := range stats {
|
||||
perc := float32(float64(stat.Size) / float64(total) * 100)
|
||||
if perc <= 0.1 {
|
||||
otherPerc += perc
|
||||
continue
|
||||
}
|
||||
langPerc[stat.Language] = perc
|
||||
}
|
||||
}
|
||||
if otherPerc > 0 {
|
||||
langPerc["other"] = otherPerc
|
||||
}
|
||||
roundByLargestRemainder(langPerc, 100)
|
||||
return langPerc
|
||||
}
|
||||
|
||||
// Rounds to 1 decimal point, target should be the expected sum of percs
|
||||
func roundByLargestRemainder(percs map[string]float32, target float32) {
|
||||
leftToDistribute := int(target * 10)
|
||||
|
||||
keys := make([]string, 0, len(percs))
|
||||
|
||||
for k, v := range percs {
|
||||
percs[k] = v * 10
|
||||
floored := math.Floor(float64(percs[k]))
|
||||
leftToDistribute -= int(floored)
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
// Sort the keys by the largest remainder
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
_, remainderI := math.Modf(float64(percs[keys[i]]))
|
||||
_, remainderJ := math.Modf(float64(percs[keys[j]]))
|
||||
return remainderI > remainderJ
|
||||
})
|
||||
|
||||
// Increment the values in order of largest remainder
|
||||
for _, k := range keys {
|
||||
percs[k] = float32(math.Floor(float64(percs[k])))
|
||||
if leftToDistribute > 0 {
|
||||
percs[k]++
|
||||
leftToDistribute--
|
||||
}
|
||||
percs[k] /= 10
|
||||
}
|
||||
}
|
||||
|
||||
// GetLanguageStats returns the language statistics for a repository
|
||||
func GetLanguageStats(ctx context.Context, repo *Repository) (LanguageStatList, error) {
|
||||
stats := make(LanguageStatList, 0, 6)
|
||||
if err := db.GetEngine(ctx).Where("`repo_id` = ?", repo.ID).Desc("`size`").Find(&stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetTopLanguageStats returns the top language statistics for a repository
|
||||
func GetTopLanguageStats(ctx context.Context, repo *Repository, limit int) (LanguageStatList, error) {
|
||||
stats, err := GetLanguageStats(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perc := stats.getLanguagePercentages()
|
||||
topstats := make(LanguageStatList, 0, limit)
|
||||
var other float32
|
||||
for i := range stats {
|
||||
if _, ok := perc[stats[i].Language]; !ok {
|
||||
continue
|
||||
}
|
||||
if stats[i].Language == "other" || len(topstats) >= limit {
|
||||
other += perc[stats[i].Language]
|
||||
continue
|
||||
}
|
||||
stats[i].Percentage = perc[stats[i].Language]
|
||||
topstats = append(topstats, stats[i])
|
||||
}
|
||||
if other > 0 {
|
||||
topstats = append(topstats, &LanguageStat{
|
||||
RepoID: repo.ID,
|
||||
Language: "other",
|
||||
Color: "#cccccc",
|
||||
Percentage: float32(math.Round(float64(other)*10) / 10),
|
||||
})
|
||||
}
|
||||
topstats.LoadAttributes()
|
||||
return topstats, nil
|
||||
}
|
||||
|
||||
// UpdateLanguageStats updates the language statistics for repository
|
||||
func UpdateLanguageStats(ctx context.Context, repo *Repository, commitID string, stats map[string]int64) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
oldstats, err := GetLanguageStats(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var topLang string
|
||||
var s int64
|
||||
for lang, size := range stats {
|
||||
if size > s {
|
||||
s = size
|
||||
topLang = lang
|
||||
}
|
||||
}
|
||||
|
||||
for lang, size := range stats {
|
||||
upd := false
|
||||
for _, s := range oldstats {
|
||||
// Update already existing language
|
||||
if strings.EqualFold(s.Language, lang) {
|
||||
s.CommitID = commitID
|
||||
s.IsPrimary = lang == topLang
|
||||
s.Size = size
|
||||
if _, err := sess.ID(s.ID).Cols("`commit_id`", "`size`", "`is_primary`").Update(s); err != nil {
|
||||
return err
|
||||
}
|
||||
upd = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Insert new language
|
||||
if !upd {
|
||||
if err := db.Insert(ctx, &LanguageStat{
|
||||
RepoID: repo.ID,
|
||||
CommitID: commitID,
|
||||
IsPrimary: lang == topLang,
|
||||
Language: lang,
|
||||
Size: size,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delete old languages
|
||||
statsToDelete := make([]int64, 0, len(oldstats))
|
||||
for _, s := range oldstats {
|
||||
if s.CommitID != commitID {
|
||||
statsToDelete = append(statsToDelete, s.ID)
|
||||
}
|
||||
}
|
||||
if len(statsToDelete) > 0 {
|
||||
if _, err := sess.In("`id`", statsToDelete).Delete(&LanguageStat{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update indexer status
|
||||
return UpdateIndexerStatus(ctx, repo, RepoIndexerTypeStats, commitID)
|
||||
})
|
||||
}
|
||||
|
||||
// CopyLanguageStat Copy originalRepo language stat information to destRepo (use for forked repo)
|
||||
func CopyLanguageStat(ctx context.Context, originalRepo, destRepo *Repository) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
RepoLang := make(LanguageStatList, 0, 6)
|
||||
if err := db.GetEngine(ctx).Where("`repo_id` = ?", originalRepo.ID).Desc("`size`").Find(&RepoLang); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(RepoLang) > 0 {
|
||||
for i := range RepoLang {
|
||||
RepoLang[i].ID = 0
|
||||
RepoLang[i].RepoID = destRepo.ID
|
||||
RepoLang[i].CreatedUnix = timeutil.TimeStampNow()
|
||||
}
|
||||
// update destRepo's indexer status
|
||||
tmpCommitID := RepoLang[0].CommitID
|
||||
if err := UpdateIndexerStatus(ctx, destRepo, RepoIndexerTypeStats, tmpCommitID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Insert(ctx, &RepoLang); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(RepoLicense))
|
||||
}
|
||||
|
||||
type RepoLicense struct { //revive:disable-line:exported
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"UNIQUE(s) NOT NULL"`
|
||||
CommitID string
|
||||
License string `xorm:"VARCHAR(255) UNIQUE(s) NOT NULL"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX UPDATED"`
|
||||
}
|
||||
|
||||
// RepoLicenseList defines a list of repo licenses
|
||||
type RepoLicenseList []*RepoLicense //revive:disable-line:exported
|
||||
|
||||
func (rll RepoLicenseList) StringList() []string {
|
||||
var licenses []string
|
||||
for _, rl := range rll {
|
||||
licenses = append(licenses, rl.License)
|
||||
}
|
||||
return licenses
|
||||
}
|
||||
|
||||
// GetRepoLicenses returns the license statistics for a repository
|
||||
func GetRepoLicenses(ctx context.Context, repo *Repository) (RepoLicenseList, error) {
|
||||
licenses := make(RepoLicenseList, 0)
|
||||
if err := db.GetEngine(ctx).Where("`repo_id` = ?", repo.ID).Asc("`license`").Find(&licenses); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return licenses, nil
|
||||
}
|
||||
|
||||
// UpdateRepoLicenses updates the license statistics for repository
|
||||
func UpdateRepoLicenses(ctx context.Context, repo *Repository, commitID string, licenses []string) error {
|
||||
oldLicenses, err := GetRepoLicenses(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, license := range licenses {
|
||||
upd := false
|
||||
for _, o := range oldLicenses {
|
||||
// Update already existing license
|
||||
if o.License == license {
|
||||
o.CommitID = commitID
|
||||
if _, err := db.GetEngine(ctx).ID(o.ID).Cols("`commit_id`").Update(o); err != nil {
|
||||
return err
|
||||
}
|
||||
upd = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Insert new license
|
||||
if !upd {
|
||||
if err := db.Insert(ctx, &RepoLicense{
|
||||
RepoID: repo.ID,
|
||||
CommitID: commitID,
|
||||
License: license,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delete old licenses
|
||||
licenseToDelete := make([]int64, 0, len(oldLicenses))
|
||||
for _, o := range oldLicenses {
|
||||
if o.CommitID != commitID {
|
||||
licenseToDelete = append(licenseToDelete, o.ID)
|
||||
}
|
||||
}
|
||||
if len(licenseToDelete) > 0 {
|
||||
if _, err := db.GetEngine(ctx).In("`id`", licenseToDelete).Delete(&RepoLicense{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyLicense Copy originalRepo license information to destRepo (use for forked repo)
|
||||
func CopyLicense(ctx context.Context, originalRepo, destRepo *Repository) error {
|
||||
repoLicenses, err := GetRepoLicenses(ctx, originalRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(repoLicenses) > 0 {
|
||||
newRepoLicenses := make(RepoLicenseList, 0, len(repoLicenses))
|
||||
|
||||
for _, rl := range repoLicenses {
|
||||
newRepoLicense := &RepoLicense{
|
||||
RepoID: destRepo.ID,
|
||||
CommitID: rl.CommitID,
|
||||
License: rl.License,
|
||||
}
|
||||
newRepoLicenses = append(newRepoLicenses, newRepoLicense)
|
||||
}
|
||||
if err := db.Insert(ctx, &newRepoLicenses); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanRepoLicenses will remove all license record of the repo
|
||||
func CleanRepoLicenses(ctx context.Context, repo *Repository) error {
|
||||
return db.DeleteBeans(ctx, &RepoLicense{
|
||||
RepoID: repo.ID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
_ "gitea.dev/models" // register table model
|
||||
_ "gitea.dev/models/actions"
|
||||
_ "gitea.dev/models/activities"
|
||||
_ "gitea.dev/models/perm/access" // register table model
|
||||
_ "gitea.dev/models/repo" // register table model
|
||||
_ "gitea.dev/models/user" // register table model
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrMirrorNotExist mirror does not exist error
|
||||
var ErrMirrorNotExist = util.NewNotExistErrorf("Mirror does not exist")
|
||||
|
||||
// Mirror represents mirror information of a repository.
|
||||
type Mirror struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX"`
|
||||
Repo *Repository `xorm:"-"`
|
||||
Interval time.Duration
|
||||
EnablePrune bool `xorm:"NOT NULL DEFAULT true"`
|
||||
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
NextUpdateUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
LastSyncUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
|
||||
LFS bool `xorm:"lfs_enabled NOT NULL DEFAULT false"`
|
||||
LFSEndpoint string `xorm:"lfs_endpoint TEXT"`
|
||||
|
||||
RemoteAddress string `xorm:"VARCHAR(2048)"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Mirror))
|
||||
}
|
||||
|
||||
// BeforeInsert will be invoked by XORM before inserting a record
|
||||
func (m *Mirror) BeforeInsert() {
|
||||
if m != nil {
|
||||
m.UpdatedUnix = timeutil.TimeStampNow()
|
||||
m.NextUpdateUnix = timeutil.TimeStampNow()
|
||||
}
|
||||
}
|
||||
|
||||
// GetRepository returns the repository.
|
||||
func (m *Mirror) GetRepository(ctx context.Context) *Repository {
|
||||
if m.Repo != nil {
|
||||
return m.Repo
|
||||
}
|
||||
var err error
|
||||
m.Repo, err = GetRepositoryByID(ctx, m.RepoID)
|
||||
if err != nil {
|
||||
log.Error("getRepositoryByID[%d]: %v", m.ID, err)
|
||||
}
|
||||
return m.Repo
|
||||
}
|
||||
|
||||
// GetRemoteName returns the name of the remote.
|
||||
func (m *Mirror) GetRemoteName() string {
|
||||
return "origin"
|
||||
}
|
||||
|
||||
// ScheduleNextUpdate calculates and sets next update time.
|
||||
func (m *Mirror) ScheduleNextUpdate() {
|
||||
if m.Interval != 0 {
|
||||
m.NextUpdateUnix = timeutil.TimeStampNow().AddDuration(m.Interval)
|
||||
} else {
|
||||
m.NextUpdateUnix = 0
|
||||
}
|
||||
}
|
||||
|
||||
// GetMirrorByRepoID returns mirror information of a repository.
|
||||
func GetMirrorByRepoID(ctx context.Context, repoID int64) (*Mirror, error) {
|
||||
m := &Mirror{RepoID: repoID}
|
||||
has, err := db.GetEngine(ctx).Get(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrMirrorNotExist
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// UpdateMirror updates the mirror
|
||||
func UpdateMirror(ctx context.Context, m *Mirror) error {
|
||||
_, err := db.GetEngine(ctx).ID(m.ID).AllCols().Update(m)
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchMirror updates the mirror updatedUnix
|
||||
func TouchMirror(ctx context.Context, m *Mirror) error {
|
||||
m.UpdatedUnix = timeutil.TimeStampNow()
|
||||
_, err := db.GetEngine(ctx).ID(m.ID).Cols("updated_unix").Update(m)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMirrorByRepoID deletes a mirror by repoID
|
||||
func DeleteMirrorByRepoID(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Delete(&Mirror{RepoID: repoID})
|
||||
return err
|
||||
}
|
||||
|
||||
// MirrorsIterate iterates all mirror repositories.
|
||||
func MirrorsIterate(ctx context.Context, limit int, f func(idx int, bean any) error) error {
|
||||
sess := db.GetEngine(ctx).
|
||||
Where("next_update_unix<=?", time.Now().Unix()).
|
||||
And("next_update_unix!=0").
|
||||
OrderBy("updated_unix ASC")
|
||||
if limit > 0 {
|
||||
sess = sess.Limit(limit)
|
||||
}
|
||||
return sess.Iterate(new(Mirror), f)
|
||||
}
|
||||
|
||||
// InsertMirror inserts a mirror to database
|
||||
func InsertMirror(ctx context.Context, mirror *Mirror) error {
|
||||
_, err := db.GetEngine(ctx).Insert(mirror)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
org_model "gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// GetOrgRepositories get repos belonging to the given organization
|
||||
func GetOrgRepositories(ctx context.Context, orgID int64) (RepositoryList, error) {
|
||||
var orgRepos []*Repository
|
||||
err := db.GetEngine(ctx).Where("owner_id = ?", orgID).Find(&orgRepos)
|
||||
return orgRepos, err
|
||||
}
|
||||
|
||||
// GetOrgRepositoryIDs get repo IDs belonging to the given organization
|
||||
func GetOrgRepositoryIDs(ctx context.Context, orgID int64) (repoIDs []int64, _ error) {
|
||||
err := db.GetEngine(ctx).Table("repository").Where("owner_id = ?", orgID).Cols("id").Find(&repoIDs)
|
||||
return repoIDs, err
|
||||
}
|
||||
|
||||
type SearchTeamRepoOptions struct {
|
||||
db.ListOptions
|
||||
TeamID int64
|
||||
}
|
||||
|
||||
// GetTeamRepositories returns paginated repositories in team of organization.
|
||||
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
|
||||
sess := db.GetEngine(ctx)
|
||||
if opts.TeamID > 0 {
|
||||
sess = sess.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
)
|
||||
}
|
||||
if opts.PageSize > 0 {
|
||||
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
||||
}
|
||||
var repos []*Repository
|
||||
return repos, sess.OrderBy("repository.name").
|
||||
Find(&repos)
|
||||
}
|
||||
|
||||
// AccessibleReposEnvironment operations involving the repositories that are
|
||||
// accessible to a particular user
|
||||
type AccessibleReposEnvironment interface {
|
||||
CountRepos(ctx context.Context) (int64, error)
|
||||
RepoIDs(ctx context.Context) ([]int64, error)
|
||||
MirrorRepos(ctx context.Context) (RepositoryList, error)
|
||||
AddKeyword(keyword string)
|
||||
SetSort(db.SearchOrderBy)
|
||||
}
|
||||
|
||||
type accessibleReposEnv struct {
|
||||
org *org_model.Organization
|
||||
user *user_model.User
|
||||
team *org_model.Team
|
||||
teamIDs []int64
|
||||
keyword string
|
||||
orderBy db.SearchOrderBy
|
||||
}
|
||||
|
||||
// AccessibleReposEnv builds an AccessibleReposEnvironment for the repositories in `org`
|
||||
// that are accessible to the specified user.
|
||||
func AccessibleReposEnv(ctx context.Context, org *org_model.Organization, userID int64) (AccessibleReposEnvironment, error) {
|
||||
var user *user_model.User
|
||||
|
||||
if userID > 0 {
|
||||
u, err := user_model.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user = u
|
||||
}
|
||||
|
||||
teamIDs, err := org.GetUserTeamIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &accessibleReposEnv{
|
||||
org: org,
|
||||
user: user,
|
||||
teamIDs: teamIDs,
|
||||
orderBy: db.SearchOrderByRecentUpdated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AccessibleTeamReposEnv an AccessibleReposEnvironment for the repositories in `org`
|
||||
// that are accessible to the specified team.
|
||||
func AccessibleTeamReposEnv(org *org_model.Organization, team *org_model.Team) AccessibleReposEnvironment {
|
||||
return &accessibleReposEnv{
|
||||
org: org,
|
||||
team: team,
|
||||
orderBy: db.SearchOrderByRecentUpdated,
|
||||
}
|
||||
}
|
||||
|
||||
func (env *accessibleReposEnv) cond() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if env.team != nil {
|
||||
cond = cond.And(builder.Eq{"team_repo.team_id": env.team.ID})
|
||||
} else {
|
||||
if env.user == nil || !env.user.IsRestricted {
|
||||
cond = cond.Or(builder.Eq{
|
||||
"`repository`.owner_id": env.org.ID,
|
||||
"`repository`.is_private": false,
|
||||
})
|
||||
}
|
||||
if len(env.teamIDs) > 0 {
|
||||
cond = cond.Or(builder.In("team_repo.team_id", env.teamIDs))
|
||||
}
|
||||
}
|
||||
if env.keyword != "" {
|
||||
cond = cond.And(builder.Like{"`repository`.lower_name", strings.ToLower(env.keyword)})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func (env *accessibleReposEnv) CountRepos(ctx context.Context) (int64, error) {
|
||||
repoCount, err := db.GetEngine(ctx).
|
||||
Join("INNER", "team_repo", "`team_repo`.repo_id=`repository`.id").
|
||||
Where(env.cond()).
|
||||
Distinct("`repository`.id").
|
||||
Count(&Repository{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count user repositories in organization: %w", err)
|
||||
}
|
||||
return repoCount, nil
|
||||
}
|
||||
|
||||
func (env *accessibleReposEnv) RepoIDs(ctx context.Context) ([]int64, error) {
|
||||
var repoIDs []int64
|
||||
return repoIDs, db.GetEngine(ctx).
|
||||
Table("repository").
|
||||
Join("INNER", "team_repo", "`team_repo`.repo_id=`repository`.id").
|
||||
Where(env.cond()).
|
||||
GroupBy("`repository`.id,`repository`." + strings.Fields(string(env.orderBy))[0]).
|
||||
OrderBy(string(env.orderBy)).
|
||||
Cols("`repository`.id").
|
||||
Find(&repoIDs)
|
||||
}
|
||||
|
||||
func (env *accessibleReposEnv) MirrorRepoIDs(ctx context.Context) ([]int64, error) {
|
||||
repoIDs := make([]int64, 0, 10)
|
||||
return repoIDs, db.GetEngine(ctx).
|
||||
Table("repository").
|
||||
Join("INNER", "team_repo", "`team_repo`.repo_id=`repository`.id AND `repository`.is_mirror=?", true).
|
||||
Where(env.cond()).
|
||||
GroupBy("`repository`.id, `repository`.updated_unix").
|
||||
OrderBy(string(env.orderBy)).
|
||||
Cols("`repository`.id").
|
||||
Find(&repoIDs)
|
||||
}
|
||||
|
||||
func (env *accessibleReposEnv) MirrorRepos(ctx context.Context) (RepositoryList, error) {
|
||||
repoIDs, err := env.MirrorRepoIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MirrorRepoIDs: %w", err)
|
||||
}
|
||||
|
||||
repos := make([]*Repository, 0, len(repoIDs))
|
||||
if len(repoIDs) == 0 {
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
return repos, db.GetEngine(ctx).
|
||||
In("`repository`.id", repoIDs).
|
||||
Find(&repos)
|
||||
}
|
||||
|
||||
func (env *accessibleReposEnv) AddKeyword(keyword string) {
|
||||
env.keyword = keyword
|
||||
}
|
||||
|
||||
func (env *accessibleReposEnv) SetSort(orderBy db.SearchOrderBy) {
|
||||
env.orderBy = orderBy
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func (repo *Repository) GetPullRequestTargetBranch(ctx context.Context) string {
|
||||
unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
|
||||
return util.IfZero(unitPRConfig.DefaultTargetBranch, repo.DefaultBranch)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDefaultTargetBranchSelection(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ctx := t.Context()
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1})
|
||||
|
||||
assert.Equal(t, repo.DefaultBranch, repo.GetPullRequestTargetBranch(ctx))
|
||||
|
||||
repo.Units = nil
|
||||
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
|
||||
assert.NoError(t, err)
|
||||
prConfig := prUnit.PullRequestsConfig()
|
||||
prConfig.DefaultTargetBranch = "branch2"
|
||||
prUnit.Config = prConfig
|
||||
assert.NoError(t, UpdateRepoUnitConfig(ctx, prUnit))
|
||||
repo.Units = nil
|
||||
assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx))
|
||||
}
|
||||
|
||||
func TestPullRequestConfigFromDB(t *testing.T) {
|
||||
cases := []struct {
|
||||
// name describes the row shape under test; the comments capture why each row matters.
|
||||
name string
|
||||
json string
|
||||
wantMergeUpdate bool
|
||||
wantRebaseUpdate bool
|
||||
wantDefaultStyle UpdateStyle
|
||||
wantValidatesPass bool
|
||||
}{
|
||||
{
|
||||
// Empty object exercises the all-defaults path (e.g. fresh repos created via low-level paths).
|
||||
name: "defaults", json: "{}",
|
||||
wantMergeUpdate: true, wantRebaseUpdate: true,
|
||||
wantDefaultStyle: UpdateStyleMerge, wantValidatesPass: true,
|
||||
},
|
||||
{
|
||||
// Realistic upgrade case: pre-PR JSON lacks the new fields and has AllowRebaseUpdate=false.
|
||||
// Historical setting must be preserved while new fields take safe defaults.
|
||||
name: "legacy without new fields",
|
||||
json: `{"AllowMerge":true,"AllowRebase":true,"AllowRebaseMerge":true,"AllowSquash":true,"AllowRebaseUpdate":false}`,
|
||||
wantMergeUpdate: true, wantRebaseUpdate: false,
|
||||
wantDefaultStyle: UpdateStyleMerge, wantValidatesPass: true,
|
||||
},
|
||||
{
|
||||
// Partially-migrated row with explicit empty string must normalize so ValidateUpdateSettings passes.
|
||||
name: "empty default style", json: `{"DefaultUpdateStyle":""}`,
|
||||
wantMergeUpdate: true, wantRebaseUpdate: true,
|
||||
wantDefaultStyle: UpdateStyleMerge, wantValidatesPass: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := new(PullRequestsConfig)
|
||||
assert.NoError(t, cfg.FromDB([]byte(tc.json)))
|
||||
assert.Equal(t, tc.wantMergeUpdate, cfg.AllowMergeUpdate)
|
||||
assert.Equal(t, tc.wantRebaseUpdate, cfg.AllowRebaseUpdate)
|
||||
assert.Equal(t, tc.wantDefaultStyle, cfg.DefaultUpdateStyle)
|
||||
if tc.wantValidatesPass {
|
||||
assert.NoError(t, cfg.ValidateUpdateSettings())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRequestConfigValidateUpdateSettingsInvalidArgument(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg PullRequestsConfig
|
||||
}{
|
||||
{
|
||||
name: "invalid default style",
|
||||
cfg: PullRequestsConfig{
|
||||
AllowMergeUpdate: true,
|
||||
AllowRebaseUpdate: true,
|
||||
DefaultUpdateStyle: "invalid",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no update style enabled",
|
||||
cfg: PullRequestsConfig{
|
||||
DefaultUpdateStyle: UpdateStyleMerge,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default update style disabled",
|
||||
cfg: PullRequestsConfig{
|
||||
AllowRebaseUpdate: true,
|
||||
DefaultUpdateStyle: UpdateStyleMerge,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.ErrorIs(t, tc.cfg.ValidateUpdateSettings(), util.ErrInvalidArgument)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// PushMirror represents mirror information of a repository.
|
||||
type PushMirror struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX"`
|
||||
Repo *Repository `xorm:"-"`
|
||||
RemoteName string
|
||||
RemoteAddress string `xorm:"VARCHAR(2048)"`
|
||||
|
||||
SyncOnCommit bool `xorm:"NOT NULL DEFAULT true"`
|
||||
Interval time.Duration
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
LastUpdateUnix timeutil.TimeStamp `xorm:"INDEX last_update"`
|
||||
LastError string `xorm:"text"`
|
||||
}
|
||||
|
||||
type PushMirrorOptions struct {
|
||||
db.ListOptions
|
||||
ID int64
|
||||
RepoID int64
|
||||
RemoteName string
|
||||
}
|
||||
|
||||
func (opts PushMirrorOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.RepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
|
||||
}
|
||||
if opts.RemoteName != "" {
|
||||
cond = cond.And(builder.Eq{"remote_name": opts.RemoteName})
|
||||
}
|
||||
if opts.ID > 0 {
|
||||
cond = cond.And(builder.Eq{"id": opts.ID})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(PushMirror))
|
||||
}
|
||||
|
||||
// GetRepository returns the path of the repository.
|
||||
func (m *PushMirror) GetRepository(ctx context.Context) *Repository {
|
||||
if m.Repo != nil {
|
||||
return m.Repo
|
||||
}
|
||||
var err error
|
||||
m.Repo, err = GetRepositoryByID(ctx, m.RepoID)
|
||||
if err != nil {
|
||||
log.Error("getRepositoryByID[%d]: %v", m.ID, err)
|
||||
}
|
||||
return m.Repo
|
||||
}
|
||||
|
||||
// GetRemoteName returns the name of the remote.
|
||||
func (m *PushMirror) GetRemoteName() string {
|
||||
return m.RemoteName
|
||||
}
|
||||
|
||||
// UpdatePushMirror updates the push-mirror
|
||||
func UpdatePushMirror(ctx context.Context, m *PushMirror) error {
|
||||
_, err := db.GetEngine(ctx).ID(m.ID).AllCols().Update(m)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePushMirrorInterval updates the push-mirror
|
||||
func UpdatePushMirrorInterval(ctx context.Context, m *PushMirror) error {
|
||||
_, err := db.GetEngine(ctx).ID(m.ID).Cols("interval").Update(m)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeletePushMirrors(ctx context.Context, opts PushMirrorOptions) error {
|
||||
if opts.RepoID > 0 {
|
||||
_, err := db.Delete[PushMirror](ctx, opts)
|
||||
return err
|
||||
}
|
||||
return util.NewInvalidArgumentErrorf("repoID required and must be set")
|
||||
}
|
||||
|
||||
type findPushMirrorOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
SyncOnCommit optional.Option[bool]
|
||||
}
|
||||
|
||||
func (opts findPushMirrorOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.RepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
|
||||
}
|
||||
if opts.SyncOnCommit.Has() {
|
||||
cond = cond.And(builder.Eq{"sync_on_commit": opts.SyncOnCommit.Value()})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetPushMirrorsByRepoID returns push-mirror information of a repository.
|
||||
func GetPushMirrorsByRepoID(ctx context.Context, repoID int64, listOptions db.ListOptions) ([]*PushMirror, int64, error) {
|
||||
return db.FindAndCount[PushMirror](ctx, findPushMirrorOptions{
|
||||
ListOptions: listOptions,
|
||||
RepoID: repoID,
|
||||
})
|
||||
}
|
||||
|
||||
func GetPushMirrorByIDAndRepoID(ctx context.Context, id, repoID int64) (*PushMirror, bool, error) {
|
||||
var pushMirror PushMirror
|
||||
has, err := db.GetEngine(ctx).Where("id = ?", id).And("repo_id = ?", repoID).Get(&pushMirror)
|
||||
if !has || err != nil {
|
||||
return nil, has, err
|
||||
}
|
||||
return &pushMirror, true, nil
|
||||
}
|
||||
|
||||
// GetPushMirrorsSyncedOnCommit returns push-mirrors for this repo that should be updated by new commits
|
||||
func GetPushMirrorsSyncedOnCommit(ctx context.Context, repoID int64) ([]*PushMirror, error) {
|
||||
return db.Find[PushMirror](ctx, findPushMirrorOptions{
|
||||
RepoID: repoID,
|
||||
SyncOnCommit: optional.Some(true),
|
||||
})
|
||||
}
|
||||
|
||||
// PushMirrorsIterate iterates all push-mirror repositories.
|
||||
func PushMirrorsIterate(ctx context.Context, limit int, f func(idx int, bean any) error) error {
|
||||
sess := db.GetEngine(ctx).
|
||||
Table("push_mirror").
|
||||
Join("INNER", "`repository`", "`repository`.id = `push_mirror`.repo_id").
|
||||
Where("`push_mirror`.last_update + (`push_mirror`.`interval` / ?) <= ?", time.Second, time.Now().Unix()).
|
||||
And("`push_mirror`.`interval` != 0").
|
||||
And("`repository`.is_archived = ?", false).
|
||||
OrderBy("last_update ASC")
|
||||
if limit > 0 {
|
||||
sess = sess.Limit(limit)
|
||||
}
|
||||
return sess.Iterate(new(PushMirror), f)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPushMirrorsIterate(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
|
||||
db.Insert(t.Context(), &repo_model.PushMirror{
|
||||
RemoteName: "test-1",
|
||||
LastUpdateUnix: now,
|
||||
Interval: 1,
|
||||
})
|
||||
|
||||
long, _ := time.ParseDuration("24h")
|
||||
db.Insert(t.Context(), &repo_model.PushMirror{
|
||||
RemoteName: "test-2",
|
||||
LastUpdateUnix: now,
|
||||
Interval: long,
|
||||
})
|
||||
|
||||
db.Insert(t.Context(), &repo_model.PushMirror{
|
||||
RemoteName: "test-3",
|
||||
LastUpdateUnix: now,
|
||||
Interval: 0,
|
||||
})
|
||||
|
||||
repo_model.PushMirrorsIterate(t.Context(), 1, func(idx int, bean any) error {
|
||||
m, ok := bean.(*repo_model.PushMirror)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test-1", m.RemoteName)
|
||||
assert.Equal(t, m.RemoteName, m.GetRemoteName())
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrRedirectNotExist represents a "RedirectNotExist" kind of error.
|
||||
type ErrRedirectNotExist struct {
|
||||
OwnerID int64
|
||||
RepoName string
|
||||
}
|
||||
|
||||
// IsErrRedirectNotExist check if an error is an ErrRepoRedirectNotExist.
|
||||
func IsErrRedirectNotExist(err error) bool {
|
||||
_, ok := err.(ErrRedirectNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRedirectNotExist) Error() string {
|
||||
return fmt.Sprintf("repository redirect does not exist [uid: %d, name: %s]", err.OwnerID, err.RepoName)
|
||||
}
|
||||
|
||||
func (err ErrRedirectNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// Redirect represents that a repo name should be redirected to another
|
||||
type Redirect struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"UNIQUE(s)"`
|
||||
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
RedirectRepoID int64 // repoID to redirect to
|
||||
}
|
||||
|
||||
// TableName represents real table name in database
|
||||
func (Redirect) TableName() string {
|
||||
return "repo_redirect"
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Redirect))
|
||||
}
|
||||
|
||||
// LookupRedirect look up if a repository has a redirect name
|
||||
func LookupRedirect(ctx context.Context, ownerID int64, repoName string) (int64, error) {
|
||||
repoName = strings.ToLower(repoName)
|
||||
redirect := &Redirect{OwnerID: ownerID, LowerName: repoName}
|
||||
if has, err := db.GetEngine(ctx).Get(redirect); err != nil {
|
||||
return 0, err
|
||||
} else if !has {
|
||||
return 0, ErrRedirectNotExist{OwnerID: ownerID, RepoName: repoName}
|
||||
}
|
||||
return redirect.RedirectRepoID, nil
|
||||
}
|
||||
|
||||
// NewRedirect create a new repo redirect
|
||||
func NewRedirect(ctx context.Context, ownerID, repoID int64, oldRepoName, newRepoName string) error {
|
||||
oldRepoName = strings.ToLower(oldRepoName)
|
||||
newRepoName = strings.ToLower(newRepoName)
|
||||
|
||||
if err := DeleteRedirect(ctx, ownerID, newRepoName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Insert(ctx, &Redirect{
|
||||
OwnerID: ownerID,
|
||||
LowerName: oldRepoName,
|
||||
RedirectRepoID: repoID,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRedirect delete any redirect from the specified repo name to
|
||||
// anything else
|
||||
func DeleteRedirect(ctx context.Context, ownerID int64, repoName string) error {
|
||||
repoName = strings.ToLower(repoName)
|
||||
_, err := db.GetEngine(ctx).Delete(&Redirect{OwnerID: ownerID, LowerName: repoName})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLookupRedirect(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repoID, err := repo_model.LookupRedirect(t.Context(), 2, "oldrepo1")
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1, repoID)
|
||||
|
||||
_, err = repo_model.LookupRedirect(t.Context(), unittest.NonexistentID, "doesnotexist")
|
||||
assert.True(t, repo_model.IsErrRedirectNotExist(err))
|
||||
}
|
||||
|
||||
func TestNewRedirect(t *testing.T) {
|
||||
// redirect to a completely new name
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.NoError(t, repo_model.NewRedirect(t.Context(), repo.OwnerID, repo.ID, repo.Name, "newreponame"))
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Redirect{
|
||||
OwnerID: repo.OwnerID,
|
||||
LowerName: repo.LowerName,
|
||||
RedirectRepoID: repo.ID,
|
||||
})
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Redirect{
|
||||
OwnerID: repo.OwnerID,
|
||||
LowerName: "oldrepo1",
|
||||
RedirectRepoID: repo.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRedirect2(t *testing.T) {
|
||||
// redirect to previously used name
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.NoError(t, repo_model.NewRedirect(t.Context(), repo.OwnerID, repo.ID, repo.Name, "oldrepo1"))
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Redirect{
|
||||
OwnerID: repo.OwnerID,
|
||||
LowerName: repo.LowerName,
|
||||
RedirectRepoID: repo.ID,
|
||||
})
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Redirect{
|
||||
OwnerID: repo.OwnerID,
|
||||
LowerName: "oldrepo1",
|
||||
RedirectRepoID: repo.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRedirect3(t *testing.T) {
|
||||
// redirect for a previously-unredirected repo
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
assert.NoError(t, repo_model.NewRedirect(t.Context(), repo.OwnerID, repo.ID, repo.Name, "newreponame"))
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Redirect{
|
||||
OwnerID: repo.OwnerID,
|
||||
LowerName: repo.LowerName,
|
||||
RedirectRepoID: repo.ID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrReleaseAlreadyExist represents a "ReleaseAlreadyExist" kind of error.
|
||||
type ErrReleaseAlreadyExist struct {
|
||||
TagName string
|
||||
}
|
||||
|
||||
// IsErrReleaseAlreadyExist checks if an error is a ErrReleaseAlreadyExist.
|
||||
func IsErrReleaseAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrReleaseAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrReleaseAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("release tag already exist [tag_name: %s]", err.TagName)
|
||||
}
|
||||
|
||||
func (err ErrReleaseAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrReleaseNotExist represents a "ReleaseNotExist" kind of error.
|
||||
type ErrReleaseNotExist struct {
|
||||
ID int64
|
||||
TagName string
|
||||
}
|
||||
|
||||
// IsErrReleaseNotExist checks if an error is a ErrReleaseNotExist.
|
||||
func IsErrReleaseNotExist(err error) bool {
|
||||
_, ok := err.(ErrReleaseNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrReleaseNotExist) Error() string {
|
||||
return fmt.Sprintf("release tag does not exist [id: %d, tag_name: %s]", err.ID, err.TagName)
|
||||
}
|
||||
|
||||
func (err ErrReleaseNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// Release represents a release of repository.
|
||||
type Release struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX UNIQUE(n)"`
|
||||
Repo *Repository `xorm:"-"`
|
||||
PublisherID int64 `xorm:"INDEX"`
|
||||
Publisher *user_model.User `xorm:"-"`
|
||||
TagName string `xorm:"INDEX UNIQUE(n)"`
|
||||
OriginalAuthor string
|
||||
OriginalAuthorID int64 `xorm:"index"`
|
||||
LowerTagName string
|
||||
Target string
|
||||
TargetBehind string `xorm:"-"` // to handle non-existing or empty target
|
||||
Title string
|
||||
Sha1 string `xorm:"INDEX VARCHAR(64)"`
|
||||
NumCommits int64
|
||||
NumCommitsBehind int64 `xorm:"-"`
|
||||
Note string `xorm:"TEXT"`
|
||||
RenderedNote template.HTML `xorm:"-"`
|
||||
IsDraft bool `xorm:"NOT NULL DEFAULT false"`
|
||||
IsPrerelease bool `xorm:"NOT NULL DEFAULT false"`
|
||||
IsTag bool `xorm:"NOT NULL DEFAULT false"` // will be true only if the record is a tag and has no related releases
|
||||
Attachments []*Attachment `xorm:"-"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Release))
|
||||
}
|
||||
|
||||
// LegacyAttachmentMissingRepoIDCutoff marks the date when repo_id started to be written during uploads
|
||||
// (2026-01-16T00:00:00Z). Older rows might have repo_id=0 and should be tolerated once.
|
||||
const LegacyAttachmentMissingRepoIDCutoff timeutil.TimeStamp = 1768521600
|
||||
|
||||
func (r *Release) LoadRepo(ctx context.Context) (err error) {
|
||||
if r.Repo != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.Repo, err = GetRepositoryByID(ctx, r.RepoID)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadAttributes load repo and publisher attributes for a release
|
||||
func (r *Release) LoadAttributes(ctx context.Context) (err error) {
|
||||
if err := r.LoadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.Publisher == nil {
|
||||
r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
r.Publisher = user_model.NewGhostUser()
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return GetReleaseAttachments(ctx, r)
|
||||
}
|
||||
|
||||
// APIURL the api url for a release. release must have attributes loaded
|
||||
func (r *Release) APIURL() string {
|
||||
return r.Repo.APIURL() + "/releases/" + strconv.FormatInt(r.ID, 10)
|
||||
}
|
||||
|
||||
// ZipURL the zip url for a release. release must have attributes loaded
|
||||
func (r *Release) ZipURL() string {
|
||||
return r.Repo.HTMLURL() + "/archive/" + util.PathEscapeSegments(r.TagName) + ".zip"
|
||||
}
|
||||
|
||||
// TarURL the tar.gz url for a release. release must have attributes loaded
|
||||
func (r *Release) TarURL() string {
|
||||
return r.Repo.HTMLURL() + "/archive/" + util.PathEscapeSegments(r.TagName) + ".tar.gz"
|
||||
}
|
||||
|
||||
// HTMLURL the url for a release on the web UI. release must have attributes loaded
|
||||
func (r *Release) HTMLURL() string {
|
||||
return r.Repo.HTMLURL() + "/releases/tag/" + util.PathEscapeSegments(r.TagName)
|
||||
}
|
||||
|
||||
// APIUploadURL the api url to upload assets to a release. release must have attributes loaded
|
||||
func (r *Release) APIUploadURL() string {
|
||||
return r.APIURL() + "/assets"
|
||||
}
|
||||
|
||||
// Link the relative url for a release on the web UI. release must have attributes loaded
|
||||
func (r *Release) Link() string {
|
||||
return r.Repo.Link() + "/releases/tag/" + util.PathEscapeSegments(r.TagName)
|
||||
}
|
||||
|
||||
// IsReleaseExist returns true if release with given tag name already exists.
|
||||
func IsReleaseExist(ctx context.Context, repoID int64, tagName string) (bool, error) {
|
||||
if len(tagName) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return db.GetEngine(ctx).Exist(&Release{RepoID: repoID, LowerTagName: strings.ToLower(tagName)})
|
||||
}
|
||||
|
||||
// UpdateRelease updates all columns of a release
|
||||
func UpdateRelease(ctx context.Context, rel *Release) error {
|
||||
rel.Title = util.EllipsisDisplayString(rel.Title, 255)
|
||||
_, err := db.GetEngine(ctx).ID(rel.ID).AllCols().Update(rel)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateReleaseNumCommits(ctx context.Context, rel *Release) error {
|
||||
_, err := db.GetEngine(ctx).ID(rel.ID).Cols("num_commits").Update(rel)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddReleaseAttachments adds a release attachments
|
||||
func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs []string) (err error) {
|
||||
rel, err := GetReleaseByID(ctx, releaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check attachments
|
||||
attachments, err := GetAttachmentsByUUIDs(ctx, attachmentUUIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", attachmentUUIDs, err)
|
||||
}
|
||||
|
||||
for i := range attachments {
|
||||
if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff {
|
||||
attachments[i].RepoID = rel.RepoID
|
||||
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil {
|
||||
return fmt.Errorf("update attachment repo_id [%d]: %w", attachments[i].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if attachments[i].RepoID != rel.RepoID {
|
||||
return util.NewPermissionDeniedErrorf("attachment belongs to different repository")
|
||||
}
|
||||
|
||||
if attachments[i].ReleaseID != 0 {
|
||||
return util.NewPermissionDeniedErrorf("release permission denied")
|
||||
}
|
||||
attachments[i].ReleaseID = releaseID
|
||||
// No assign value could be 0, so ignore AllCols().
|
||||
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("release_id").Update(attachments[i]); err != nil {
|
||||
return fmt.Errorf("update attachment [%d]: %w", attachments[i].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRelease returns release by given ID.
|
||||
func GetRelease(ctx context.Context, repoID int64, tagName string) (*Release, error) {
|
||||
rel := &Release{RepoID: repoID, LowerTagName: strings.ToLower(tagName)}
|
||||
has, err := db.GetEngine(ctx).Get(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrReleaseNotExist{0, tagName}
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// GetReleaseByID returns release with given ID.
|
||||
func GetReleaseByID(ctx context.Context, id int64) (*Release, error) {
|
||||
rel := new(Release)
|
||||
has, err := db.GetEngine(ctx).
|
||||
ID(id).
|
||||
Get(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrReleaseNotExist{id, ""}
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// GetReleaseForRepoByID returns release with given ID.
|
||||
func GetReleaseForRepoByID(ctx context.Context, repoID, id int64) (*Release, error) {
|
||||
rel := new(Release)
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("id=? AND repo_id=?", id, repoID).
|
||||
Get(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrReleaseNotExist{id, ""}
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// FindReleasesOptions describes the conditions to Find releases
|
||||
type FindReleasesOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
IncludeDrafts bool
|
||||
IncludeTags bool
|
||||
IsPreRelease optional.Option[bool]
|
||||
IsDraft optional.Option[bool]
|
||||
TagNames []string
|
||||
HasSha1 optional.Option[bool] // useful to find draft releases which are created with existing tags
|
||||
NamePattern optional.Option[string]
|
||||
}
|
||||
|
||||
func (opts FindReleasesOptions) ToConds() builder.Cond {
|
||||
var cond builder.Cond = builder.Eq{"repo_id": opts.RepoID}
|
||||
|
||||
if !opts.IncludeDrafts {
|
||||
cond = cond.And(builder.Eq{"is_draft": false})
|
||||
}
|
||||
if !opts.IncludeTags {
|
||||
cond = cond.And(builder.Eq{"is_tag": false})
|
||||
}
|
||||
if len(opts.TagNames) > 0 {
|
||||
cond = cond.And(builder.In("tag_name", opts.TagNames))
|
||||
}
|
||||
if opts.IsPreRelease.Has() {
|
||||
cond = cond.And(builder.Eq{"is_prerelease": opts.IsPreRelease.Value()})
|
||||
}
|
||||
if opts.IsDraft.Has() {
|
||||
cond = cond.And(builder.Eq{"is_draft": opts.IsDraft.Value()})
|
||||
}
|
||||
if opts.HasSha1.Has() {
|
||||
if opts.HasSha1.Value() {
|
||||
cond = cond.And(builder.Neq{"sha1": ""})
|
||||
} else {
|
||||
cond = cond.And(builder.Eq{"sha1": ""})
|
||||
}
|
||||
}
|
||||
|
||||
if opts.NamePattern.Has() && opts.NamePattern.Value() != "" {
|
||||
cond = cond.And(builder.Like{"lower_tag_name", strings.ToLower(opts.NamePattern.Value())})
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts FindReleasesOptions) ToOrders() string {
|
||||
return "created_unix DESC, id DESC"
|
||||
}
|
||||
|
||||
// GetTagNamesByRepoID returns a list of release tag names of repository.
|
||||
func GetTagNamesByRepoID(ctx context.Context, repoID int64) ([]string, error) {
|
||||
opts := FindReleasesOptions{
|
||||
ListOptions: db.ListOptionsAll,
|
||||
IncludeDrafts: true,
|
||||
IncludeTags: true,
|
||||
HasSha1: optional.Some(true),
|
||||
RepoID: repoID,
|
||||
}
|
||||
|
||||
tags := make([]string, 0)
|
||||
sess := db.GetEngine(ctx).
|
||||
Table("release").
|
||||
Desc("created_unix", "id").
|
||||
Where(opts.ToConds()).
|
||||
Cols("tag_name")
|
||||
|
||||
return tags, sess.Find(&tags)
|
||||
}
|
||||
|
||||
// GetLatestReleaseByRepoID returns the latest release for a repository
|
||||
func GetLatestReleaseByRepoID(ctx context.Context, repoID int64) (*Release, error) {
|
||||
cond := builder.NewCond().
|
||||
And(builder.Eq{"repo_id": repoID}).
|
||||
And(builder.Eq{"is_draft": false}).
|
||||
And(builder.Eq{"is_prerelease": false}).
|
||||
And(builder.Eq{"is_tag": false})
|
||||
|
||||
rel := new(Release)
|
||||
has, err := db.GetEngine(ctx).
|
||||
Desc("created_unix", "id").
|
||||
Where(cond).
|
||||
Get(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrReleaseNotExist{0, "latest"}
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
type releaseMetaSearch struct {
|
||||
ID []int64
|
||||
Rel []*Release
|
||||
}
|
||||
|
||||
func (s releaseMetaSearch) Len() int {
|
||||
return len(s.ID)
|
||||
}
|
||||
|
||||
func (s releaseMetaSearch) Swap(i, j int) {
|
||||
s.ID[i], s.ID[j] = s.ID[j], s.ID[i]
|
||||
s.Rel[i], s.Rel[j] = s.Rel[j], s.Rel[i]
|
||||
}
|
||||
|
||||
func (s releaseMetaSearch) Less(i, j int) bool {
|
||||
return s.ID[i] < s.ID[j]
|
||||
}
|
||||
|
||||
func hasDuplicateName(attaches []*Attachment) bool {
|
||||
attachSet := container.Set[string]{}
|
||||
for _, attachment := range attaches {
|
||||
if attachSet.Contains(attachment.Name) {
|
||||
return true
|
||||
}
|
||||
attachSet.Add(attachment.Name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetReleaseAttachments retrieves the attachments for releases
|
||||
func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
|
||||
if len(rels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// To keep this efficient as possible sort all releases by id,
|
||||
// select attachments by release id,
|
||||
// then merge join them
|
||||
|
||||
// Sort
|
||||
sortedRels := releaseMetaSearch{ID: make([]int64, len(rels)), Rel: make([]*Release, len(rels))}
|
||||
var attachments []*Attachment
|
||||
for index, element := range rels {
|
||||
element.Attachments = []*Attachment{}
|
||||
sortedRels.ID[index] = element.ID
|
||||
sortedRels.Rel[index] = element
|
||||
}
|
||||
sort.Sort(sortedRels)
|
||||
|
||||
// Select attachments
|
||||
err = db.GetEngine(ctx).
|
||||
Asc("release_id", "name").
|
||||
In("release_id", sortedRels.ID).
|
||||
Find(&attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// merge join
|
||||
currentIndex := 0
|
||||
for _, attachment := range attachments {
|
||||
for sortedRels.ID[currentIndex] < attachment.ReleaseID {
|
||||
currentIndex++
|
||||
}
|
||||
sortedRels.Rel[currentIndex].Attachments = append(sortedRels.Rel[currentIndex].Attachments, attachment)
|
||||
}
|
||||
|
||||
// Makes URL's predictable
|
||||
for _, release := range rels {
|
||||
// If we have no Repo, we don't need to execute this loop
|
||||
if release.Repo == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the names unique, use the URL with the Name instead of the UUID
|
||||
if !hasDuplicateName(release.Attachments) {
|
||||
for _, attachment := range release.Attachments {
|
||||
attachment.CustomDownloadURL = release.Repo.HTMLURL() + "/releases/download/" + url.PathEscape(release.TagName) + "/" + url.PathEscape(attachment.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateReleasesMigrationsByType updates all migrated repositories' releases from gitServiceType to replace originalAuthorID to posterID
|
||||
func UpdateReleasesMigrationsByType(ctx context.Context, gitServiceType structs.GitServiceType, originalAuthorID string, posterID int64) error {
|
||||
_, err := db.GetEngine(ctx).Table("release").
|
||||
Where("repo_id IN (SELECT id FROM repository WHERE original_service_type = ?)", gitServiceType).
|
||||
And("original_author_id = ?", originalAuthorID).
|
||||
Update(map[string]any{
|
||||
"publisher_id": posterID,
|
||||
"original_author": "",
|
||||
"original_author_id": 0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// PushUpdateDeleteTags updates a number of delete tags with context
|
||||
func PushUpdateDeleteTags(ctx context.Context, repo *Repository, tags []string) error {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
lowerTags := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
lowerTags = append(lowerTags, strings.ToLower(tag))
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND is_tag = ?", repo.ID, true).
|
||||
In("lower_tag_name", lowerTags).
|
||||
Delete(new(Release)); err != nil {
|
||||
return fmt.Errorf("Delete: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND is_tag = ?", repo.ID, false).
|
||||
In("lower_tag_name", lowerTags).
|
||||
Cols("is_draft", "num_commits", "sha1").
|
||||
Update(&Release{
|
||||
IsDraft: true,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("Update: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemapExternalUser ExternalUserRemappable interface
|
||||
func (r *Release) RemapExternalUser(externalName string, externalID, userID int64) error {
|
||||
r.OriginalAuthor = externalName
|
||||
r.OriginalAuthorID = externalID
|
||||
r.PublisherID = userID
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserID ExternalUserRemappable interface
|
||||
func (r *Release) GetUserID() int64 { return r.PublisherID }
|
||||
|
||||
// ExternalName ExternalUserRemappable interface
|
||||
func (r *Release) GetExternalName() string { return r.OriginalAuthor }
|
||||
|
||||
// ExternalID ExternalUserRemappable interface
|
||||
func (r *Release) GetExternalID() int64 { return r.OriginalAuthorID }
|
||||
|
||||
// InsertReleases migrates release
|
||||
func InsertReleases(ctx context.Context, rels ...*Release) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
for _, rel := range rels {
|
||||
if _, err := db.GetEngine(ctx).NoAutoTime().Insert(rel); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(rel.Attachments) > 0 {
|
||||
for i := range rel.Attachments {
|
||||
rel.Attachments[i].ReleaseID = rel.ID
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).NoAutoTime().Insert(rel.Attachments); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func FindTagsByCommitIDs(ctx context.Context, repoID int64, commitIDs ...string) (map[string][]*Release, error) {
|
||||
releases := make([]*Release, 0, len(commitIDs))
|
||||
if err := db.GetEngine(ctx).Where("repo_id=?", repoID).
|
||||
In("sha1", commitIDs).
|
||||
Find(&releases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make(map[string][]*Release, len(releases))
|
||||
for _, r := range releases {
|
||||
res[r.Sha1] = append(res[r.Sha1], r)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func DeleteRepoReleases(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(Release))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMigrate_InsertReleases(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
a := &Attachment{
|
||||
UUID: "a0eebc91-9c0c-4ef7-bb6e-6bb9bd380a12",
|
||||
}
|
||||
r := &Release{
|
||||
Attachments: []*Attachment{a},
|
||||
}
|
||||
|
||||
err := InsertReleases(t.Context(), r)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_FindTagsByCommitIDs(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
sha1Rels, err := FindTagsByCommitIDs(t.Context(), 1, "65f1bf27bc3bf70f64657658635e66094edbcb4d")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, sha1Rels, 1)
|
||||
rels := sha1Rels["65f1bf27bc3bf70f64657658635e66094edbcb4d"]
|
||||
assert.Len(t, rels, 3)
|
||||
assert.Equal(t, "v1.1", rels[0].TagName)
|
||||
assert.Equal(t, "delete-tag", rels[1].TagName)
|
||||
assert.Equal(t, "v1.0", rels[2].TagName)
|
||||
}
|
||||
|
||||
func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
uuid := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12" // attachment 2 belongs to repo 2
|
||||
err := AddReleaseAttachments(t.Context(), 1, []string{uuid})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, util.ErrPermissionDenied)
|
||||
|
||||
attach, err := GetAttachmentByUUID(t.Context(), uuid)
|
||||
assert.NoError(t, err)
|
||||
assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure")
|
||||
}
|
||||
|
||||
func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
legacyUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20" // attachment 10 has repo_id 0
|
||||
err := AddReleaseAttachments(t.Context(), 1, []string{legacyUUID})
|
||||
assert.NoError(t, err)
|
||||
|
||||
attach, err := GetAttachmentByUUID(t.Context(), legacyUUID)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1, attach.RepoID)
|
||||
assert.EqualValues(t, 1, attach.ReleaseID)
|
||||
}
|
||||
|
||||
func TestAddReleaseAttachmentsRejectsRecentZeroRepoID(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
recentUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd3800aa"
|
||||
attachment := &Attachment{
|
||||
UUID: recentUUID,
|
||||
RepoID: 0,
|
||||
IssueID: 0,
|
||||
ReleaseID: 0,
|
||||
CommentID: 0,
|
||||
Name: "recent-zero",
|
||||
CreatedUnix: LegacyAttachmentMissingRepoIDCutoff + 1,
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), attachment))
|
||||
|
||||
err := AddReleaseAttachments(t.Context(), 1, []string{recentUUID})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, util.ErrPermissionDenied)
|
||||
|
||||
attach, err := GetAttachmentByUUID(t.Context(), recentUUID)
|
||||
assert.NoError(t, err)
|
||||
assert.Zero(t, attach.ReleaseID)
|
||||
assert.Zero(t, attach.RepoID)
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/git"
|
||||
giturl "gitea.dev/modules/git/url"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrUserDoesNotHaveAccessToRepo represents an error where the user doesn't has access to a given repo.
|
||||
type ErrUserDoesNotHaveAccessToRepo struct {
|
||||
UserID int64
|
||||
RepoName string
|
||||
}
|
||||
|
||||
// IsErrUserDoesNotHaveAccessToRepo checks if an error is a ErrUserDoesNotHaveAccessToRepo.
|
||||
func IsErrUserDoesNotHaveAccessToRepo(err error) bool {
|
||||
_, ok := err.(ErrUserDoesNotHaveAccessToRepo)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUserDoesNotHaveAccessToRepo) Error() string {
|
||||
return fmt.Sprintf("user doesn't have access to repo [user_id: %d, repo_name: %s]", err.UserID, err.RepoName)
|
||||
}
|
||||
|
||||
func (err ErrUserDoesNotHaveAccessToRepo) Unwrap() error {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
type ErrRepoIsArchived struct {
|
||||
Repo *Repository
|
||||
}
|
||||
|
||||
func (err ErrRepoIsArchived) Error() string {
|
||||
return err.Repo.LogString() + " is archived"
|
||||
}
|
||||
|
||||
type globalVarsStruct struct {
|
||||
validRepoNamePattern *regexp.Regexp
|
||||
invalidRepoNamePattern *regexp.Regexp
|
||||
reservedRepoNames []string
|
||||
reservedRepoNamePatterns []string
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() *globalVarsStruct {
|
||||
return &globalVarsStruct{
|
||||
validRepoNamePattern: regexp.MustCompile(`^[-.\w]+$`),
|
||||
invalidRepoNamePattern: regexp.MustCompile(`[.]{2,}`),
|
||||
reservedRepoNames: []string{".", "..", "-"},
|
||||
reservedRepoNamePatterns: []string{"*.wiki", "*.git", "*.rss", "*.atom"},
|
||||
}
|
||||
})
|
||||
|
||||
// IsUsableRepoName returns true when name is usable
|
||||
func IsUsableRepoName(name string) error {
|
||||
vars := globalVars()
|
||||
if !vars.validRepoNamePattern.MatchString(name) || vars.invalidRepoNamePattern.MatchString(name) {
|
||||
// Note: usually this error is normally caught up earlier in the UI
|
||||
return db.ErrNameCharsNotAllowed{Name: name}
|
||||
}
|
||||
return db.IsUsableName(vars.reservedRepoNames, vars.reservedRepoNamePatterns, name)
|
||||
}
|
||||
|
||||
// IsValidSSHAccessRepoName is like IsUsableRepoName, but it allows "*.wiki" because wiki repo needs to be accessed in SSH code
|
||||
func IsValidSSHAccessRepoName(name string) bool {
|
||||
vars := globalVars()
|
||||
if !vars.validRepoNamePattern.MatchString(name) || vars.invalidRepoNamePattern.MatchString(name) {
|
||||
return false
|
||||
}
|
||||
return db.IsUsableName(vars.reservedRepoNames, vars.reservedRepoNamePatterns[1:], name) == nil
|
||||
}
|
||||
|
||||
// TrustModelType defines the types of trust model for this repository
|
||||
type TrustModelType int
|
||||
|
||||
// kinds of TrustModel
|
||||
const (
|
||||
DefaultTrustModel TrustModelType = iota // default trust model
|
||||
CommitterTrustModel
|
||||
CollaboratorTrustModel
|
||||
CollaboratorCommitterTrustModel
|
||||
)
|
||||
|
||||
// String converts a TrustModelType to a string
|
||||
func (t TrustModelType) String() string {
|
||||
switch t {
|
||||
case DefaultTrustModel:
|
||||
return "default"
|
||||
case CommitterTrustModel:
|
||||
return "committer"
|
||||
case CollaboratorTrustModel:
|
||||
return "collaborator"
|
||||
case CollaboratorCommitterTrustModel:
|
||||
return "collaboratorcommitter"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// ToTrustModel converts a string to a TrustModelType
|
||||
func ToTrustModel(model string) TrustModelType {
|
||||
switch strings.ToLower(strings.TrimSpace(model)) {
|
||||
case "default":
|
||||
return DefaultTrustModel
|
||||
case "collaborator":
|
||||
return CollaboratorTrustModel
|
||||
case "committer":
|
||||
return CommitterTrustModel
|
||||
case "collaboratorcommitter":
|
||||
return CollaboratorCommitterTrustModel
|
||||
}
|
||||
return DefaultTrustModel
|
||||
}
|
||||
|
||||
// RepositoryStatus defines the status of repository
|
||||
type RepositoryStatus int
|
||||
|
||||
// all kinds of RepositoryStatus
|
||||
const (
|
||||
RepositoryReady RepositoryStatus = iota // a normal repository
|
||||
RepositoryBeingMigrated // repository is migrating
|
||||
RepositoryPendingTransfer // repository pending in ownership transfer state
|
||||
RepositoryBroken // repository is in a permanently broken state
|
||||
)
|
||||
|
||||
// Repository represents a git repository.
|
||||
type Repository struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"UNIQUE(s) index"`
|
||||
OwnerName string
|
||||
Owner *user_model.User `xorm:"-"`
|
||||
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
Name string `xorm:"INDEX NOT NULL"`
|
||||
Description string `xorm:"TEXT"`
|
||||
Website string `xorm:"VARCHAR(2048)"`
|
||||
OriginalServiceType api.GitServiceType `xorm:"index"`
|
||||
OriginalURL string `xorm:"VARCHAR(2048)"`
|
||||
DefaultBranch string
|
||||
DefaultWikiBranch string
|
||||
|
||||
NumWatches int
|
||||
NumStars int
|
||||
NumForks int
|
||||
NumIssues int
|
||||
NumClosedIssues int
|
||||
NumOpenIssues int `xorm:"-"`
|
||||
NumPulls int
|
||||
NumClosedPulls int
|
||||
NumOpenPulls int `xorm:"-"`
|
||||
NumMilestones int `xorm:"NOT NULL DEFAULT 0"`
|
||||
NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"`
|
||||
NumOpenMilestones int `xorm:"-"`
|
||||
NumProjects int `xorm:"NOT NULL DEFAULT 0"`
|
||||
NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"`
|
||||
NumOpenProjects int `xorm:"-"`
|
||||
NumActionRuns int `xorm:"NOT NULL DEFAULT 0"`
|
||||
NumClosedActionRuns int `xorm:"NOT NULL DEFAULT 0"`
|
||||
NumOpenActionRuns int `xorm:"-"`
|
||||
|
||||
IsPrivate bool `xorm:"INDEX"`
|
||||
IsEmpty bool `xorm:"INDEX"`
|
||||
IsArchived bool `xorm:"INDEX"`
|
||||
IsMirror bool `xorm:"INDEX"`
|
||||
|
||||
Status RepositoryStatus `xorm:"NOT NULL DEFAULT 0"`
|
||||
|
||||
commonRenderingMetas map[string]string `xorm:"-"`
|
||||
|
||||
Units []*RepoUnit `xorm:"-"`
|
||||
PrimaryLanguage *LanguageStat `xorm:"-"`
|
||||
|
||||
IsFork bool `xorm:"INDEX NOT NULL DEFAULT false"`
|
||||
ForkID int64 `xorm:"INDEX"`
|
||||
BaseRepo *Repository `xorm:"-"`
|
||||
IsTemplate bool `xorm:"INDEX NOT NULL DEFAULT false"`
|
||||
TemplateID int64 `xorm:"INDEX"`
|
||||
Size int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
GitSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
LFSSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
CodeIndexerStatus *RepoIndexerStatus `xorm:"-"`
|
||||
StatsIndexerStatus *RepoIndexerStatus `xorm:"-"`
|
||||
IsFsckEnabled bool `xorm:"NOT NULL DEFAULT true"`
|
||||
CloseIssuesViaCommitInAnyBranch bool `xorm:"NOT NULL DEFAULT false"`
|
||||
Topics []string `xorm:"TEXT JSON"`
|
||||
ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"`
|
||||
|
||||
TrustModel TrustModelType
|
||||
|
||||
// Avatar: ID(10-20)-md5(32) - must fit into 64 symbols
|
||||
Avatar string `xorm:"VARCHAR(64)"`
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
ArchivedUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Repository))
|
||||
}
|
||||
|
||||
func RelativePath(ownerName, repoName string) string {
|
||||
return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".git"
|
||||
}
|
||||
|
||||
// RelativePath should be an unix style path like username/reponame.git
|
||||
func (repo *Repository) RelativePath() string {
|
||||
return RelativePath(repo.OwnerName, repo.Name)
|
||||
}
|
||||
|
||||
type StorageRepo string
|
||||
|
||||
// RelativePath should be an unix style path like username/reponame.git
|
||||
func (sr StorageRepo) RelativePath() string {
|
||||
return string(sr)
|
||||
}
|
||||
|
||||
// SanitizedOriginalURL returns a sanitized OriginalURL
|
||||
func (repo *Repository) SanitizedOriginalURL() string {
|
||||
if repo.OriginalURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, _ := util.SanitizeURL(repo.OriginalURL)
|
||||
return u
|
||||
}
|
||||
|
||||
// text representations to be returned in SizeDetail.Name
|
||||
const (
|
||||
SizeDetailNameGit = "git"
|
||||
SizeDetailNameLFS = "lfs"
|
||||
)
|
||||
|
||||
type SizeDetail struct {
|
||||
Name string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// SizeDetails forms a struct with various size details about repository
|
||||
func (repo *Repository) SizeDetails() []SizeDetail {
|
||||
sizeDetails := []SizeDetail{
|
||||
{
|
||||
Name: SizeDetailNameGit,
|
||||
Size: repo.GitSize,
|
||||
},
|
||||
{
|
||||
Name: SizeDetailNameLFS,
|
||||
Size: repo.LFSSize,
|
||||
},
|
||||
}
|
||||
return sizeDetails
|
||||
}
|
||||
|
||||
// SizeDetailsString returns a concatenation of all repository size details as a string
|
||||
func (repo *Repository) SizeDetailsString() string {
|
||||
var str strings.Builder
|
||||
sizeDetails := repo.SizeDetails()
|
||||
for _, detail := range sizeDetails {
|
||||
fmt.Fprintf(&str, "%s: %s, ", detail.Name, base.FileSize(detail.Size))
|
||||
}
|
||||
return strings.TrimSuffix(str.String(), ", ")
|
||||
}
|
||||
|
||||
func (repo *Repository) LogString() string {
|
||||
if repo == nil {
|
||||
return "<Repository nil>"
|
||||
}
|
||||
return fmt.Sprintf("<Repository %d:%s/%s>", repo.ID, repo.OwnerName, repo.Name)
|
||||
}
|
||||
|
||||
// IsBeingMigrated indicates that repository is being migrated
|
||||
func (repo *Repository) IsBeingMigrated() bool {
|
||||
return repo.Status == RepositoryBeingMigrated
|
||||
}
|
||||
|
||||
// IsBeingCreated indicates that repository is being migrated or forked
|
||||
func (repo *Repository) IsBeingCreated() bool {
|
||||
return repo.IsBeingMigrated()
|
||||
}
|
||||
|
||||
// IsBroken indicates that repository is broken
|
||||
func (repo *Repository) IsBroken() bool {
|
||||
return repo.Status == RepositoryBroken
|
||||
}
|
||||
|
||||
// MarkAsBrokenEmpty marks the repo as broken and empty
|
||||
// FIXME: the status "broken" and "is_empty" were abused,
|
||||
// The code always set them together, no way to distinguish whether a repo is really "empty" or "broken"
|
||||
func (repo *Repository) MarkAsBrokenEmpty() {
|
||||
repo.Status = RepositoryBroken
|
||||
repo.IsEmpty = true
|
||||
}
|
||||
|
||||
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
|
||||
func (repo *Repository) AfterLoad() {
|
||||
repo.NumOpenIssues = repo.NumIssues - repo.NumClosedIssues
|
||||
repo.NumOpenPulls = repo.NumPulls - repo.NumClosedPulls
|
||||
repo.NumOpenMilestones = repo.NumMilestones - repo.NumClosedMilestones
|
||||
repo.NumOpenProjects = repo.NumProjects - repo.NumClosedProjects
|
||||
repo.NumOpenActionRuns = repo.NumActionRuns - repo.NumClosedActionRuns
|
||||
if repo.DefaultWikiBranch == "" {
|
||||
repo.DefaultWikiBranch = setting.Repository.DefaultBranch
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAttributes loads attributes of the repository.
|
||||
func (repo *Repository) LoadAttributes(ctx context.Context) error {
|
||||
// Load owner
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return fmt.Errorf("load owner: %w", err)
|
||||
}
|
||||
|
||||
// Load primary language
|
||||
stats := make(LanguageStatList, 0, 1)
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("`repo_id` = ? AND `is_primary` = ? AND `language` != ?", repo.ID, true, "other").
|
||||
Find(&stats); err != nil {
|
||||
return fmt.Errorf("find primary languages: %w", err)
|
||||
}
|
||||
stats.LoadAttributes()
|
||||
for _, st := range stats {
|
||||
if st.RepoID == repo.ID {
|
||||
repo.PrimaryLanguage = st
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FullName returns the repository full name
|
||||
func (repo *Repository) FullName() string {
|
||||
return repo.OwnerName + "/" + repo.Name
|
||||
}
|
||||
|
||||
// HTMLURL returns the repository HTML URL
|
||||
func (repo *Repository) HTMLURL(ctxs ...context.Context) string {
|
||||
// FIXME: this HTMLURL is still used in mail templates, so the "ctx" is not provided.
|
||||
ctx := util.OptionalArg(ctxs, context.TODO())
|
||||
return httplib.MakeAbsoluteURL(ctx, repo.Link())
|
||||
}
|
||||
|
||||
// CommitLink make link to by commit full ID
|
||||
// note: won't check whether it's an right id
|
||||
func (repo *Repository) CommitLink(commitID string) (result string) {
|
||||
if git.IsEmptyCommitID(commitID) {
|
||||
result = ""
|
||||
} else {
|
||||
result = repo.Link() + "/commit/" + url.PathEscape(commitID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// APIURL returns the repository API URL
|
||||
func (repo *Repository) APIURL(ctxOpt ...context.Context) string {
|
||||
ctx := util.OptionalArg(ctxOpt, context.TODO())
|
||||
return httplib.MakeAbsoluteURL(ctx, setting.AppSubURL+"/api/v1/repos/"+url.PathEscape(repo.OwnerName)+"/"+url.PathEscape(repo.Name))
|
||||
}
|
||||
|
||||
// GetCommitsCountCacheKey returns cache key used for commits count caching.
|
||||
func (repo *Repository) GetCommitsCountCacheKey(contextName string, isRef bool) string {
|
||||
var prefix string
|
||||
if isRef {
|
||||
prefix = "ref"
|
||||
} else {
|
||||
prefix = "commit"
|
||||
}
|
||||
return fmt.Sprintf("commits-count-%d-%s-%s", repo.ID, prefix, contextName)
|
||||
}
|
||||
|
||||
// LoadUnits loads repo units into repo.Units
|
||||
func (repo *Repository) LoadUnits(ctx context.Context) (err error) {
|
||||
if repo.Units != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
repo.Units, err = getUnitsByRepoID(ctx, repo.ID)
|
||||
if log.IsTrace() {
|
||||
unitTypeStrings := make([]string, len(repo.Units))
|
||||
for i, unit := range repo.Units {
|
||||
unitTypeStrings[i] = unit.Type.LogString()
|
||||
}
|
||||
log.Trace("repo.Units, ID=%d, Types: [%s]", repo.ID, strings.Join(unitTypeStrings, ", "))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UnitEnabled if this repository has the given unit enabled
|
||||
func (repo *Repository) UnitEnabled(ctx context.Context, tp unit.Type) bool {
|
||||
if err := repo.LoadUnits(ctx); err != nil {
|
||||
log.Warn("Error loading repository (ID: %d) units: %s", repo.ID, err.Error())
|
||||
}
|
||||
for _, unit := range repo.Units {
|
||||
if unit.Type == tp {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MustGetUnit always returns a RepoUnit object even if the unit doesn't exist (not enabled)
|
||||
func (repo *Repository) MustGetUnit(ctx context.Context, tp unit.Type) *RepoUnit {
|
||||
ru, err := repo.GetUnit(ctx, tp)
|
||||
if err == nil {
|
||||
return ru
|
||||
}
|
||||
if !errors.Is(err, util.ErrNotExist) {
|
||||
setting.PanicInDevOrTesting("Failed to get unit %v for repository %d: %v", tp, repo.ID, err)
|
||||
}
|
||||
ru = &RepoUnit{RepoID: repo.ID, Type: tp}
|
||||
switch tp {
|
||||
case unit.TypeExternalWiki:
|
||||
ru.Config = new(ExternalWikiConfig)
|
||||
case unit.TypeExternalTracker:
|
||||
ru.Config = new(ExternalTrackerConfig)
|
||||
case unit.TypePullRequests:
|
||||
ru.Config = new(PullRequestsConfig)
|
||||
case unit.TypeIssues:
|
||||
ru.Config = new(IssuesConfig)
|
||||
case unit.TypeActions:
|
||||
ru.Config = new(ActionsConfig)
|
||||
case unit.TypeProjects:
|
||||
ru.Config = new(ProjectsConfig)
|
||||
default: // other units don't have config
|
||||
}
|
||||
if ru.Config != nil {
|
||||
if err = ru.Config.FromDB(nil); err != nil {
|
||||
setting.PanicInDevOrTesting("Failed to load default config for unit %v of repository %d: %v", tp, repo.ID, err)
|
||||
}
|
||||
}
|
||||
return ru
|
||||
}
|
||||
|
||||
// GetUnit returns a RepoUnit object
|
||||
func (repo *Repository) GetUnit(ctx context.Context, tp unit.Type) (*RepoUnit, error) {
|
||||
if err := repo.LoadUnits(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, unit := range repo.Units {
|
||||
if unit.Type == tp {
|
||||
return unit, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrUnitTypeNotExist{tp}
|
||||
}
|
||||
|
||||
// LoadOwner loads owner user
|
||||
func (repo *Repository) LoadOwner(ctx context.Context) (err error) {
|
||||
if repo.Owner != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
repo.Owner, err = user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MustOwner always returns a valid *user_model.User object to avoid
|
||||
// conceptually impossible error handling.
|
||||
// It creates a fake object that contains error details
|
||||
// when error occurs.
|
||||
func (repo *Repository) MustOwner(ctx context.Context) *user_model.User {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return &user_model.User{
|
||||
Name: "error",
|
||||
FullName: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return repo.Owner
|
||||
}
|
||||
|
||||
func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]string {
|
||||
if len(repo.commonRenderingMetas) == 0 {
|
||||
metas := map[string]string{
|
||||
"user": repo.OwnerName,
|
||||
"repo": repo.Name,
|
||||
}
|
||||
|
||||
unitExternalTracker, err := repo.GetUnit(ctx, unit.TypeExternalTracker)
|
||||
if err == nil {
|
||||
metas["format"] = unitExternalTracker.ExternalTrackerConfig().ExternalTrackerFormat
|
||||
switch unitExternalTracker.ExternalTrackerConfig().ExternalTrackerStyle {
|
||||
case markup.IssueNameStyleAlphanumeric:
|
||||
metas["style"] = markup.IssueNameStyleAlphanumeric
|
||||
case markup.IssueNameStyleRegexp:
|
||||
metas["style"] = markup.IssueNameStyleRegexp
|
||||
metas["regexp"] = unitExternalTracker.ExternalTrackerConfig().ExternalTrackerRegexpPattern
|
||||
default:
|
||||
metas["style"] = markup.IssueNameStyleNumeric
|
||||
}
|
||||
}
|
||||
|
||||
repo.MustOwner(ctx)
|
||||
if repo.Owner.IsOrganization() {
|
||||
teams := make([]string, 0, 5)
|
||||
_ = db.GetEngine(ctx).Table("team_repo").
|
||||
Join("INNER", "team", "team.id = team_repo.team_id").
|
||||
Where("team_repo.repo_id = ?", repo.ID).
|
||||
Select("team.lower_name").
|
||||
OrderBy("team.lower_name").
|
||||
Find(&teams)
|
||||
metas["teams"] = "," + strings.Join(teams, ",") + ","
|
||||
metas["org"] = strings.ToLower(repo.OwnerName)
|
||||
}
|
||||
|
||||
repo.commonRenderingMetas = metas
|
||||
}
|
||||
return repo.commonRenderingMetas
|
||||
}
|
||||
|
||||
// ComposeCommentMetas composes a map of metas for properly rendering comments or comment-like contents (commit message)
|
||||
func (repo *Repository) ComposeCommentMetas(ctx context.Context) map[string]string {
|
||||
metas := maps.Clone(repo.composeCommonMetas(ctx))
|
||||
metas["markdownNewLineHardBreak"] = strconv.FormatBool(setting.Markdown.RenderOptionsComment.NewLineHardBreak)
|
||||
metas["markupAllowShortIssuePattern"] = strconv.FormatBool(setting.Markdown.RenderOptionsComment.ShortIssuePattern)
|
||||
return metas
|
||||
}
|
||||
|
||||
// ComposeWikiMetas composes a map of metas for properly rendering wikis
|
||||
func (repo *Repository) ComposeWikiMetas(ctx context.Context) map[string]string {
|
||||
// does wiki need the "teams" and "org" from common metas?
|
||||
metas := maps.Clone(repo.composeCommonMetas(ctx))
|
||||
metas["markdownNewLineHardBreak"] = strconv.FormatBool(setting.Markdown.RenderOptionsWiki.NewLineHardBreak)
|
||||
metas["markupAllowShortIssuePattern"] = strconv.FormatBool(setting.Markdown.RenderOptionsWiki.ShortIssuePattern)
|
||||
return metas
|
||||
}
|
||||
|
||||
// ComposeRepoFileMetas composes a map of metas for properly rendering documents (repo files)
|
||||
func (repo *Repository) ComposeRepoFileMetas(ctx context.Context) map[string]string {
|
||||
// does document(file) need the "teams" and "org" from common metas?
|
||||
metas := maps.Clone(repo.composeCommonMetas(ctx))
|
||||
metas["markdownNewLineHardBreak"] = strconv.FormatBool(setting.Markdown.RenderOptionsRepoFile.NewLineHardBreak)
|
||||
metas["markupAllowShortIssuePattern"] = strconv.FormatBool(setting.Markdown.RenderOptionsRepoFile.ShortIssuePattern)
|
||||
return metas
|
||||
}
|
||||
|
||||
// GetBaseRepo populates repo.BaseRepo for a fork repository and
|
||||
// returns an error on failure (NOTE: no error is returned for
|
||||
// non-fork repositories, and BaseRepo will be left untouched)
|
||||
func (repo *Repository) GetBaseRepo(ctx context.Context) (err error) {
|
||||
if !repo.IsFork {
|
||||
return nil
|
||||
}
|
||||
|
||||
if repo.BaseRepo != nil {
|
||||
return nil
|
||||
}
|
||||
repo.BaseRepo, err = GetRepositoryByID(ctx, repo.ForkID)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsGenerated returns whether _this_ repository was generated from a template
|
||||
func (repo *Repository) IsGenerated() bool {
|
||||
return repo.TemplateID != 0
|
||||
}
|
||||
|
||||
// RepoPath returns repository path by given user and repository name.
|
||||
func RepoPath(userName, repoName string) string { //revive:disable-line:exported
|
||||
return filepath.Join(setting.RepoRootPath, filepath.Clean(strings.ToLower(userName)), filepath.Clean(strings.ToLower(repoName)+".git"))
|
||||
}
|
||||
|
||||
// RepoPath returns the repository path
|
||||
func (repo *Repository) RepoPath() string {
|
||||
return RepoPath(repo.OwnerName, repo.Name)
|
||||
}
|
||||
|
||||
// Link returns the repository relative url
|
||||
func (repo *Repository) Link() string {
|
||||
return setting.AppSubURL + "/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name)
|
||||
}
|
||||
|
||||
// ComposeCompareURL returns the repository comparison URL
|
||||
func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) string {
|
||||
return fmt.Sprintf("%s/%s/compare/%s...%s", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), util.PathEscapeSegments(oldCommitID), util.PathEscapeSegments(newCommitID))
|
||||
}
|
||||
|
||||
func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, baseBranch, branchName string) string {
|
||||
var cmpBranchEscaped string
|
||||
if repo.ID != baseRepo.ID {
|
||||
cmpBranchEscaped = fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name))
|
||||
}
|
||||
cmpBranchEscaped = fmt.Sprintf("%s%s", cmpBranchEscaped, util.PathEscapeSegments(branchName))
|
||||
return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseBranch), cmpBranchEscaped)
|
||||
}
|
||||
|
||||
// IsOwnedBy returns true when user owns this repository
|
||||
func (repo *Repository) IsOwnedBy(userID int64) bool {
|
||||
return repo.OwnerID == userID
|
||||
}
|
||||
|
||||
// CanCreateBranch returns true if repository meets the requirements for creating new branches.
|
||||
func (repo *Repository) CanCreateBranch() bool {
|
||||
return !repo.IsMirror
|
||||
}
|
||||
|
||||
// CanEnablePulls returns true if repository meets the requirements of accepting pulls.
|
||||
func (repo *Repository) CanEnablePulls() bool {
|
||||
return !repo.IsMirror && !repo.IsEmpty
|
||||
}
|
||||
|
||||
// AllowsPulls returns true if repository meets the requirements of accepting pulls and has them enabled.
|
||||
func (repo *Repository) AllowsPulls(ctx context.Context) bool {
|
||||
return repo.CanEnablePulls() && repo.UnitEnabled(ctx, unit.TypePullRequests)
|
||||
}
|
||||
|
||||
// CanEnableEditor returns true if repository meets the requirements of web editor.
|
||||
// FIXME: most CanEnableEditor calls should be replaced with CanContentChange
|
||||
// And all other like CanCreateBranch / CanEnablePulls should also be updated
|
||||
func (repo *Repository) CanEnableEditor() bool {
|
||||
return repo.CanContentChange()
|
||||
}
|
||||
|
||||
func (repo *Repository) CanContentChange() bool {
|
||||
return !repo.IsMirror && !repo.IsArchived
|
||||
}
|
||||
|
||||
// DescriptionHTML does special handles to description and return HTML string.
|
||||
func (repo *Repository) DescriptionHTML(ctx context.Context) template.HTML {
|
||||
desc, err := markup.PostProcessDescriptionHTML(markup.NewRenderContext(ctx), repo.Description)
|
||||
if err != nil {
|
||||
log.Error("Failed to render description for %s (ID: %d): %v", repo.Name, repo.ID, err)
|
||||
return template.HTML(markup.SanitizeDescription(repo.Description))
|
||||
}
|
||||
return template.HTML(markup.SanitizeDescription(desc))
|
||||
}
|
||||
|
||||
// CloneLink represents different types of clone URLs of repository.
|
||||
type CloneLink struct {
|
||||
SSH string
|
||||
HTTPS string
|
||||
Tea string
|
||||
}
|
||||
|
||||
// ComposeHTTPSCloneURL returns HTTPS clone URL based on the given owner and repository name.
|
||||
func ComposeHTTPSCloneURL(ctx context.Context, owner, repo string) string {
|
||||
return fmt.Sprintf("%s%s/%s.git", httplib.GuessCurrentAppURL(ctx), url.PathEscape(owner), url.PathEscape(repo))
|
||||
}
|
||||
|
||||
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
|
||||
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
|
||||
sshUser := setting.SSH.User
|
||||
sshDomain := setting.SSH.Domain
|
||||
|
||||
if sshUser == "(DOER_USERNAME)" {
|
||||
// Some users use SSH reverse-proxy and need to use the current signed-in username as the SSH user
|
||||
// to make the SSH reverse-proxy could prepare the user's public keys ahead.
|
||||
// For most cases we have the correct "doer", then use it as the SSH user.
|
||||
// If we can't get the doer, then use the built-in SSH user.
|
||||
if doer != nil {
|
||||
sshUser = doer.Name
|
||||
} else {
|
||||
sshUser = setting.SSH.BuiltinServerUser
|
||||
}
|
||||
}
|
||||
|
||||
// non-standard port, it must use full URI
|
||||
if setting.SSH.Port != 22 {
|
||||
sshHost := net.JoinHostPort(sshDomain, strconv.Itoa(setting.SSH.Port))
|
||||
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
||||
}
|
||||
|
||||
// for standard port, it can use a shorter URI (without the port)
|
||||
sshHost := sshDomain
|
||||
if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil {
|
||||
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
|
||||
}
|
||||
if setting.Repository.UseCompatSSHURI {
|
||||
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
||||
}
|
||||
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
||||
}
|
||||
|
||||
// ComposeTeaCloneCommand returns Tea CLI clone command based on the given owner and repository name.
|
||||
func ComposeTeaCloneCommand(ctx context.Context, owner, repo string) string {
|
||||
return fmt.Sprintf("tea clone %s/%s", url.PathEscape(owner), url.PathEscape(repo))
|
||||
}
|
||||
|
||||
func (repo *Repository) cloneLink(ctx context.Context, doer *user_model.User, repoPathName string) *CloneLink {
|
||||
return &CloneLink{
|
||||
SSH: ComposeSSHCloneURL(doer, repo.OwnerName, repoPathName),
|
||||
HTTPS: ComposeHTTPSCloneURL(ctx, repo.OwnerName, repoPathName),
|
||||
Tea: ComposeTeaCloneCommand(ctx, repo.OwnerName, repoPathName),
|
||||
}
|
||||
}
|
||||
|
||||
// CloneLink returns clone URLs of repository.
|
||||
func (repo *Repository) CloneLink(ctx context.Context, doer *user_model.User) (cl *CloneLink) {
|
||||
return repo.cloneLink(ctx, doer, repo.Name)
|
||||
}
|
||||
|
||||
func (repo *Repository) CloneLinkGeneral(ctx context.Context) (cl *CloneLink) {
|
||||
return repo.cloneLink(ctx, nil /* no doer, use a general git user */, repo.Name)
|
||||
}
|
||||
|
||||
// GetOriginalURLHostname returns the hostname of a URL or the URL
|
||||
func (repo *Repository) GetOriginalURLHostname() string {
|
||||
u, err := url.Parse(repo.OriginalURL)
|
||||
if err != nil {
|
||||
return repo.OriginalURL
|
||||
}
|
||||
|
||||
return u.Host
|
||||
}
|
||||
|
||||
// GetTrustModel will get the TrustModel for the repo or the default trust model
|
||||
func (repo *Repository) GetTrustModel() TrustModelType {
|
||||
trustModel := repo.TrustModel
|
||||
if trustModel == DefaultTrustModel {
|
||||
trustModel = ToTrustModel(setting.Repository.Signing.DefaultTrustModel)
|
||||
if trustModel == DefaultTrustModel {
|
||||
return CollaboratorTrustModel
|
||||
}
|
||||
}
|
||||
return trustModel
|
||||
}
|
||||
|
||||
// MustNotBeArchived returns ErrRepoIsArchived if the repo is archived
|
||||
func (repo *Repository) MustNotBeArchived() error {
|
||||
if repo.IsArchived {
|
||||
return ErrRepoIsArchived{Repo: repo}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// __________ .__ __
|
||||
// \______ \ ____ ______ ____ _____|__|/ |_ ___________ ___.__.
|
||||
// | _// __ \\____ \ / _ \/ ___/ \ __\/ _ \_ __ < | |
|
||||
// | | \ ___/| |_> > <_> )___ \| || | ( <_> ) | \/\___ |
|
||||
// |____|_ /\___ > __/ \____/____ >__||__| \____/|__| / ____|
|
||||
// \/ \/|__| \/ \/
|
||||
|
||||
// ErrRepoNotExist represents a "RepoNotExist" kind of error.
|
||||
type ErrRepoNotExist struct {
|
||||
ID int64
|
||||
UID int64
|
||||
OwnerName string
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsErrRepoNotExist checks if an error is a ErrRepoNotExist.
|
||||
func IsErrRepoNotExist(err error) bool {
|
||||
_, ok := err.(ErrRepoNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRepoNotExist) Error() string {
|
||||
return fmt.Sprintf("repository does not exist [id: %d, uid: %d, owner_name: %s, name: %s]",
|
||||
err.ID, err.UID, err.OwnerName, err.Name)
|
||||
}
|
||||
|
||||
// Unwrap unwraps this error as a ErrNotExist error
|
||||
func (err ErrRepoNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// GetRepositoryByOwnerAndName returns the repository by given owner name and repo name
|
||||
func GetRepositoryByOwnerAndName(ctx context.Context, ownerName, repoName string) (*Repository, error) {
|
||||
var repo Repository
|
||||
has, err := db.GetEngine(ctx).Table("repository").Select("repository.*").
|
||||
Join("INNER", "`user`", "`user`.id = repository.owner_id").
|
||||
Where("repository.lower_name = ?", strings.ToLower(repoName)).
|
||||
And("`user`.lower_name = ?", strings.ToLower(ownerName)).
|
||||
Get(&repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrRepoNotExist{0, 0, ownerName, repoName}
|
||||
}
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
// GetRepositoryByName returns the repository by given name under user if exists.
|
||||
func GetRepositoryByName(ctx context.Context, ownerID int64, name string) (*Repository, error) {
|
||||
var repo Repository
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("`owner_id`=?", ownerID).
|
||||
And("`lower_name`=?", strings.ToLower(name)).
|
||||
NoAutoCondition().
|
||||
Get(&repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrRepoNotExist{0, ownerID, "", name}
|
||||
}
|
||||
return &repo, err
|
||||
}
|
||||
|
||||
// GetRepositoryByURL returns the repository by given url
|
||||
func GetRepositoryByURL(ctx context.Context, repoURL string) (*Repository, error) {
|
||||
ret, err := giturl.ParseRepositoryURL(ctx, repoURL)
|
||||
if err != nil || ret.OwnerName == "" {
|
||||
return nil, errors.New("unknown or malformed repository URL")
|
||||
}
|
||||
return GetRepositoryByOwnerAndName(ctx, ret.OwnerName, ret.RepoName)
|
||||
}
|
||||
|
||||
// GetRepositoryByURLRelax also accepts an SSH clone URL without user part
|
||||
func GetRepositoryByURLRelax(ctx context.Context, repoURL string) (*Repository, error) {
|
||||
if !strings.Contains(repoURL, "://") && !strings.Contains(repoURL, "@") {
|
||||
// convert "example.com:owner/repo" to "@example.com:owner/repo"
|
||||
p1, p2, p3 := strings.Index(repoURL, "."), strings.Index(repoURL, ":"), strings.Index(repoURL, "/")
|
||||
if 0 < p1 && p1 < p2 && p2 < p3 {
|
||||
repoURL = "@" + repoURL
|
||||
}
|
||||
}
|
||||
return GetRepositoryByURL(ctx, repoURL)
|
||||
}
|
||||
|
||||
// GetRepositoryByID returns the repository by given id if exists.
|
||||
func GetRepositoryByID(ctx context.Context, id int64) (*Repository, error) {
|
||||
repo := new(Repository)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrRepoNotExist{id, 0, "", ""}
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// GetRepositoriesMapByIDs returns the repositories by given id slice.
|
||||
func GetRepositoriesMapByIDs(ctx context.Context, ids []int64) (map[int64]*Repository, error) {
|
||||
repos := make(map[int64]*Repository, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return repos, nil
|
||||
}
|
||||
return repos, db.GetEngine(ctx).In("id", ids).Find(&repos)
|
||||
}
|
||||
|
||||
func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) {
|
||||
return db.GetEngine(ctx).Get(&Repository{
|
||||
OwnerID: u.ID,
|
||||
LowerName: strings.ToLower(repoName),
|
||||
})
|
||||
}
|
||||
|
||||
// GetTemplateRepo populates repo.TemplateRepo for a generated repository and
|
||||
// returns an error on failure (NOTE: no error is returned for
|
||||
// non-generated repositories, and TemplateRepo will be left untouched)
|
||||
func GetTemplateRepo(ctx context.Context, repo *Repository) (*Repository, error) {
|
||||
if !repo.IsGenerated() {
|
||||
return nil, nil //nolint:nilnil // return nil for non-generated repositories
|
||||
}
|
||||
|
||||
return GetRepositoryByID(ctx, repo.TemplateID)
|
||||
}
|
||||
|
||||
// TemplateRepo returns the repository, which is template of this repository
|
||||
func (repo *Repository) TemplateRepo(ctx context.Context) *Repository {
|
||||
repo, err := GetTemplateRepo(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("TemplateRepo: %v", err)
|
||||
return nil
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// ErrUserOwnRepos represents a "UserOwnRepos" kind of error.
|
||||
type ErrUserOwnRepos struct {
|
||||
UID int64
|
||||
}
|
||||
|
||||
// IsErrUserOwnRepos checks if an error is a ErrUserOwnRepos.
|
||||
func IsErrUserOwnRepos(err error) bool {
|
||||
_, ok := err.(ErrUserOwnRepos)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUserOwnRepos) Error() string {
|
||||
return fmt.Sprintf("user still has ownership of repositories [uid: %d]", err.UID)
|
||||
}
|
||||
|
||||
type CountRepositoryOptions struct {
|
||||
OwnerID int64
|
||||
Private optional.Option[bool]
|
||||
}
|
||||
|
||||
// CountRepositories returns number of repositories.
|
||||
// Argument private only takes effect when it is false,
|
||||
// set it true to count all repositories.
|
||||
func CountRepositories(ctx context.Context, opts CountRepositoryOptions) (int64, error) {
|
||||
sess := db.GetEngine(ctx).Where("id > 0")
|
||||
|
||||
if opts.OwnerID > 0 {
|
||||
sess.And("owner_id = ?", opts.OwnerID)
|
||||
}
|
||||
if opts.Private.Has() {
|
||||
sess.And("is_private=?", opts.Private.Value())
|
||||
}
|
||||
|
||||
count, err := sess.Count(new(Repository))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("countRepositories: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// UpdateRepoIssueNumbers updates one of a repositories amount of (open|closed) (issues|PRs) with the current count
|
||||
func UpdateRepoIssueNumbers(ctx context.Context, repoID int64, isPull, isClosed bool) error {
|
||||
field := "num_"
|
||||
if isClosed {
|
||||
field += "closed_"
|
||||
}
|
||||
if isPull {
|
||||
field += "pulls"
|
||||
} else {
|
||||
field += "issues"
|
||||
}
|
||||
|
||||
subQuery := builder.Select("count(*)").
|
||||
From("issue").Where(builder.Eq{
|
||||
"repo_id": repoID,
|
||||
"is_pull": isPull,
|
||||
}.And(builder.If(isClosed, builder.Eq{"is_closed": isClosed})))
|
||||
|
||||
// builder.Update(cond) will generate SQL like UPDATE ... SET cond
|
||||
query := builder.Update(builder.Eq{field: subQuery}).
|
||||
From("repository").
|
||||
Where(builder.Eq{"id": repoID})
|
||||
_, err := db.Exec(ctx, query)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountNullArchivedRepository counts the number of repositories with is_archived is null
|
||||
func CountNullArchivedRepository(ctx context.Context) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(builder.IsNull{"is_archived"}).Count(new(Repository))
|
||||
}
|
||||
|
||||
// FixNullArchivedRepository sets is_archived to false where it is null
|
||||
func FixNullArchivedRepository(ctx context.Context) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(builder.IsNull{"is_archived"}).Cols("is_archived").NoAutoTime().Update(&Repository{
|
||||
IsArchived: false,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRepositoryOwnerName updates the owner name of all repositories owned by the user
|
||||
func UpdateRepositoryOwnerName(ctx context.Context, oldUserName, newUserName string) error {
|
||||
if _, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET owner_name=? WHERE owner_name=?", newUserName, oldUserName); err != nil {
|
||||
return fmt.Errorf("change repo owner name: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// RepoIndexerType specifies the repository indexer type
|
||||
type RepoIndexerType int //revive:disable-line:exported
|
||||
|
||||
const (
|
||||
// RepoIndexerTypeCode code indexer
|
||||
RepoIndexerTypeCode RepoIndexerType = iota // 0
|
||||
// RepoIndexerTypeStats repository stats indexer
|
||||
RepoIndexerTypeStats // 1
|
||||
)
|
||||
|
||||
// RepoIndexerStatus status of a repo's entry in the repo indexer
|
||||
// For now, implicitly refers to default branch
|
||||
type RepoIndexerStatus struct { //revive:disable-line:exported
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX(s)"`
|
||||
CommitSha string `xorm:"VARCHAR(64)"`
|
||||
IndexerType RepoIndexerType `xorm:"INDEX(s) NOT NULL DEFAULT 0"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(RepoIndexerStatus))
|
||||
}
|
||||
|
||||
// GetUnindexedRepos returns repos which do not have an indexer status
|
||||
func GetUnindexedRepos(ctx context.Context, indexerType RepoIndexerType, maxRepoID int64, page, pageSize int) ([]int64, error) {
|
||||
ids := make([]int64, 0, 50)
|
||||
cond := builder.Cond(builder.IsNull{
|
||||
"repo_indexer_status.id",
|
||||
}).And(builder.Eq{
|
||||
"repository.is_empty": false,
|
||||
})
|
||||
sess := db.GetEngine(ctx).Table("repository").Join("LEFT OUTER", "repo_indexer_status", "repository.id = repo_indexer_status.repo_id AND repo_indexer_status.indexer_type = ?", indexerType)
|
||||
if maxRepoID > 0 {
|
||||
cond = builder.And(cond, builder.Lte{
|
||||
"repository.id": maxRepoID,
|
||||
})
|
||||
}
|
||||
if page >= 0 && pageSize > 0 {
|
||||
start := 0
|
||||
if page > 0 {
|
||||
start = (page - 1) * pageSize
|
||||
}
|
||||
sess.Limit(pageSize, start)
|
||||
}
|
||||
|
||||
sess.Where(cond).Cols("repository.id").Desc("repository.id")
|
||||
err := sess.Find(&ids)
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// GetIndexerStatus loads repo codes indxer status
|
||||
func GetIndexerStatus(ctx context.Context, repo *Repository, indexerType RepoIndexerType) (*RepoIndexerStatus, error) {
|
||||
switch indexerType {
|
||||
case RepoIndexerTypeCode:
|
||||
if repo.CodeIndexerStatus != nil {
|
||||
return repo.CodeIndexerStatus, nil
|
||||
}
|
||||
case RepoIndexerTypeStats:
|
||||
if repo.StatsIndexerStatus != nil {
|
||||
return repo.StatsIndexerStatus, nil
|
||||
}
|
||||
}
|
||||
status := &RepoIndexerStatus{RepoID: repo.ID}
|
||||
if has, err := db.GetEngine(ctx).Where("`indexer_type` = ?", indexerType).Get(status); err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
status.IndexerType = indexerType
|
||||
status.CommitSha = ""
|
||||
}
|
||||
switch indexerType {
|
||||
case RepoIndexerTypeCode:
|
||||
repo.CodeIndexerStatus = status
|
||||
case RepoIndexerTypeStats:
|
||||
repo.StatsIndexerStatus = status
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// UpdateIndexerStatus updates indexer status
|
||||
func UpdateIndexerStatus(ctx context.Context, repo *Repository, indexerType RepoIndexerType, sha string) error {
|
||||
status, err := GetIndexerStatus(ctx, repo, indexerType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UpdateIndexerStatus: Unable to getIndexerStatus for repo: %s Error: %w", repo.FullName(), err)
|
||||
}
|
||||
|
||||
if len(status.CommitSha) == 0 {
|
||||
status.CommitSha = sha
|
||||
if err := db.Insert(ctx, status); err != nil {
|
||||
return fmt.Errorf("UpdateIndexerStatus: Unable to insert repoIndexerStatus for repo: %s Sha: %s Error: %w", repo.FullName(), sha, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
status.CommitSha = sha
|
||||
_, err = db.GetEngine(ctx).ID(status.ID).Cols("commit_sha").
|
||||
Update(status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UpdateIndexerStatus: Unable to update repoIndexerStatus for repo: %s Sha: %s Error: %w", repo.FullName(), sha, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// RepositoryListDefaultPageSize is the default number of repositories
|
||||
// to load in memory when running administrative tasks on all (or almost
|
||||
// all) of them.
|
||||
// The number should be low enough to avoid filling up all RAM with
|
||||
// repository data...
|
||||
const RepositoryListDefaultPageSize = 64
|
||||
|
||||
// RepositoryList contains a list of repositories
|
||||
type RepositoryList []*Repository
|
||||
|
||||
func (repos RepositoryList) Len() int {
|
||||
return len(repos)
|
||||
}
|
||||
|
||||
func (repos RepositoryList) Less(i, j int) bool {
|
||||
return repos[i].FullName() < repos[j].FullName()
|
||||
}
|
||||
|
||||
func (repos RepositoryList) Swap(i, j int) {
|
||||
repos[i], repos[j] = repos[j], repos[i]
|
||||
}
|
||||
|
||||
// ValuesRepository converts a repository map to a list
|
||||
// FIXME: Remove in favor of maps.values when MIN_GO_VERSION >= 1.18
|
||||
func ValuesRepository(m map[int64]*Repository) []*Repository {
|
||||
values := make([]*Repository, 0, len(m))
|
||||
for _, v := range m {
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// RepositoryListOfMap make list from values of map
|
||||
func RepositoryListOfMap(repoMap map[int64]*Repository) RepositoryList {
|
||||
return RepositoryList(ValuesRepository(repoMap))
|
||||
}
|
||||
|
||||
func (repos RepositoryList) LoadUnits(ctx context.Context) error {
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load units.
|
||||
units := make([]*RepoUnit, 0, len(repos)*6)
|
||||
if err := db.GetEngine(ctx).
|
||||
In("repo_id", repos.IDs()).
|
||||
Find(&units); err != nil {
|
||||
return fmt.Errorf("find units: %w", err)
|
||||
}
|
||||
|
||||
unitsMap := make(map[int64][]*RepoUnit, len(repos))
|
||||
for _, unit := range units {
|
||||
if !unit.Type.UnitGlobalDisabled() {
|
||||
unitsMap[unit.RepoID] = append(unitsMap[unit.RepoID], unit)
|
||||
}
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
repo.Units = unitsMap[repo.ID]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repos RepositoryList) IDs() []int64 {
|
||||
repoIDs := make([]int64, len(repos))
|
||||
for i := range repos {
|
||||
repoIDs[i] = repos[i].ID
|
||||
}
|
||||
return repoIDs
|
||||
}
|
||||
|
||||
func (repos RepositoryList) LoadOwners(ctx context.Context) error {
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
userIDs := container.FilterSlice(repos, func(repo *Repository) (int64, bool) {
|
||||
return repo.OwnerID, true
|
||||
})
|
||||
|
||||
// Load owners.
|
||||
users := make(map[int64]*user_model.User, len(userIDs))
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("id > 0").
|
||||
In("id", userIDs).
|
||||
Find(&users); err != nil {
|
||||
return fmt.Errorf("find users: %w", err)
|
||||
}
|
||||
for i := range repos {
|
||||
repos[i].Owner = users[repos[i].OwnerID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repos RepositoryList) LoadLanguageStats(ctx context.Context) error {
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load primary language.
|
||||
stats := make(LanguageStatList, 0, len(repos))
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("`is_primary` = ? AND `language` != ?", true, "other").
|
||||
In("`repo_id`", repos.IDs()).
|
||||
Find(&stats); err != nil {
|
||||
return fmt.Errorf("find primary languages: %w", err)
|
||||
}
|
||||
stats.LoadAttributes()
|
||||
for i := range repos {
|
||||
for _, st := range stats {
|
||||
if st.RepoID == repos[i].ID {
|
||||
repos[i].PrimaryLanguage = st
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAttributes loads the attributes for the given RepositoryList
|
||||
func (repos RepositoryList) LoadAttributes(ctx context.Context) error {
|
||||
if err := repos.LoadOwners(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repos.LoadLanguageStats(ctx)
|
||||
}
|
||||
|
||||
// SearchRepoOptions holds the search options
|
||||
type SearchRepoOptions struct {
|
||||
db.ListOptions
|
||||
Actor *user_model.User
|
||||
Keyword string
|
||||
OwnerID int64
|
||||
PriorityOwnerID int64
|
||||
TeamID int64
|
||||
OrderBy db.SearchOrderBy
|
||||
Private bool // Include private repositories in results
|
||||
StarredByID int64
|
||||
WatchedByID int64
|
||||
AllPublic bool // Include also all public repositories of users and public organisations
|
||||
AllLimited bool // Include also all public repositories of limited organisations
|
||||
// None -> include public and private
|
||||
// True -> include just private
|
||||
// False -> include just public
|
||||
IsPrivate optional.Option[bool]
|
||||
// None -> include collaborative AND non-collaborative
|
||||
// True -> include just collaborative
|
||||
// False -> include just non-collaborative
|
||||
Collaborate optional.Option[bool]
|
||||
// What type of unit the user can be collaborative in,
|
||||
// it is ignored if Collaborate is False.
|
||||
// TypeInvalid means any unit type.
|
||||
UnitType unit.Type
|
||||
// None -> include forks AND non-forks
|
||||
// True -> include just forks
|
||||
// False -> include just non-forks
|
||||
Fork optional.Option[bool]
|
||||
// If Fork option is True, you can use this option to limit the forks of a special repo by repo id.
|
||||
ForkFrom int64
|
||||
// None -> include templates AND non-templates
|
||||
// True -> include just templates
|
||||
// False -> include just non-templates
|
||||
Template optional.Option[bool]
|
||||
// None -> include mirrors AND non-mirrors
|
||||
// True -> include just mirrors
|
||||
// False -> include just non-mirrors
|
||||
Mirror optional.Option[bool]
|
||||
// None -> include archived AND non-archived
|
||||
// True -> include just archived
|
||||
// False -> include just non-archived
|
||||
Archived optional.Option[bool]
|
||||
// only search topic name
|
||||
TopicOnly bool
|
||||
// only search repositories with specified primary language
|
||||
Language string
|
||||
// include description in keyword search
|
||||
IncludeDescription bool
|
||||
// None -> include has milestones AND has no milestone
|
||||
// True -> include just has milestones
|
||||
// False -> include just has no milestone
|
||||
HasMilestones optional.Option[bool]
|
||||
// LowerNames represents valid lower names to restrict to
|
||||
LowerNames []string
|
||||
// When specified true, apply some filters over the conditions:
|
||||
// - Don't show forks, when opts.Fork is OptionalBoolNone.
|
||||
// - Do not display repositories that don't have a description, an icon and topics.
|
||||
OnlyShowRelevant bool
|
||||
}
|
||||
|
||||
func (opts *SearchRepoOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
if publicOnly {
|
||||
opts.Private = false
|
||||
opts.AllLimited = false
|
||||
}
|
||||
}
|
||||
|
||||
// UserOwnedRepoCond returns user ownered repositories
|
||||
func UserOwnedRepoCond(userID int64) builder.Cond {
|
||||
return builder.Eq{
|
||||
"repository.owner_id": userID,
|
||||
}
|
||||
}
|
||||
|
||||
// UserAssignedRepoCond return user as assignee repositories list
|
||||
func UserAssignedRepoCond(id string, userID int64) builder.Cond {
|
||||
return builder.And(
|
||||
builder.Eq{
|
||||
"repository.is_private": false,
|
||||
},
|
||||
builder.In(id,
|
||||
builder.Select("issue.repo_id").From("issue_assignees").
|
||||
InnerJoin("issue", "issue.id = issue_assignees.issue_id").
|
||||
Where(builder.Eq{
|
||||
"issue_assignees.assignee_id": userID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// UserCreateIssueRepoCond return user created issues repositories list
|
||||
func UserCreateIssueRepoCond(id string, userID int64, isPull bool) builder.Cond {
|
||||
return builder.And(
|
||||
builder.Eq{
|
||||
"repository.is_private": false,
|
||||
},
|
||||
builder.In(id,
|
||||
builder.Select("issue.repo_id").From("issue").
|
||||
Where(builder.Eq{
|
||||
"issue.poster_id": userID,
|
||||
"issue.is_pull": isPull,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// UserMentionedRepoCond return user metinoed repositories list
|
||||
func UserMentionedRepoCond(id string, userID int64) builder.Cond {
|
||||
return builder.And(
|
||||
builder.Eq{
|
||||
"repository.is_private": false,
|
||||
},
|
||||
builder.In(id,
|
||||
builder.Select("issue.repo_id").From("issue_user").
|
||||
InnerJoin("issue", "issue.id = issue_user.issue_id").
|
||||
Where(builder.Eq{
|
||||
"issue_user.is_mentioned": true,
|
||||
"issue_user.uid": userID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// UserAccessRepoCond returns a condition for selecting all repositories a user has unit independent access to
|
||||
func UserAccessRepoCond(idStr string, userID int64) builder.Cond {
|
||||
return builder.In(idStr, builder.Select("repo_id").
|
||||
From("`access`").
|
||||
Where(builder.And(
|
||||
builder.Eq{"`access`.user_id": userID},
|
||||
builder.Gt{"`access`.mode": int(perm.AccessModeNone)},
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
// userCollaborationRepoCond returns a condition for selecting all repositories a user is collaborator in
|
||||
func UserCollaborationRepoCond(idStr string, userID int64) builder.Cond {
|
||||
return builder.In(idStr, builder.Select("repo_id").
|
||||
From("`collaboration`").
|
||||
Where(builder.And(
|
||||
builder.Eq{"`collaboration`.user_id": userID},
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
// UserOrgTeamRepoCond selects repos that the given user has access to through team membership
|
||||
func UserOrgTeamRepoCond(idStr string, userID int64) builder.Cond {
|
||||
return builder.In(idStr, userOrgTeamRepoBuilder(userID))
|
||||
}
|
||||
|
||||
// userOrgTeamRepoBuilder returns repo ids where user's teams can access.
|
||||
func userOrgTeamRepoBuilder(userID int64) *builder.Builder {
|
||||
return builder.Select("`team_repo`.repo_id").
|
||||
From("team_repo").
|
||||
Join("INNER", "team_user", "`team_user`.team_id = `team_repo`.team_id").
|
||||
Where(builder.Eq{"`team_user`.uid": userID})
|
||||
}
|
||||
|
||||
// userOrgTeamUnitRepoBuilder returns repo ids where user's teams can access the special unit.
|
||||
func userOrgTeamUnitRepoBuilder(userID int64, unitType unit.Type) *builder.Builder {
|
||||
return userOrgTeamRepoBuilder(userID).
|
||||
Join("INNER", "team_unit", "`team_unit`.team_id = `team_repo`.team_id").
|
||||
Where(builder.Eq{"`team_unit`.`type`": unitType}).
|
||||
And(builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)})
|
||||
}
|
||||
|
||||
// userOrgTeamUnitRepoCond returns a condition to select repo ids where user's teams can access the special unit.
|
||||
func userOrgTeamUnitRepoCond(idStr string, userID int64, unitType unit.Type) builder.Cond {
|
||||
return builder.In(idStr, userOrgTeamUnitRepoBuilder(userID, unitType))
|
||||
}
|
||||
|
||||
// UserOrgUnitRepoCond selects repos that the given user has access to through org and the special unit
|
||||
func UserOrgUnitRepoCond(idStr string, userID, orgID int64, unitType unit.Type) builder.Cond {
|
||||
return builder.In(idStr,
|
||||
userOrgTeamUnitRepoBuilder(userID, unitType).
|
||||
And(builder.Eq{"`team_unit`.org_id": orgID}),
|
||||
)
|
||||
}
|
||||
|
||||
// userOrgPublicRepoCond returns the condition that one user could access all public repositories in organizations
|
||||
func userOrgPublicRepoCond(userID int64) builder.Cond {
|
||||
return builder.And(
|
||||
builder.Eq{"`repository`.is_private": false},
|
||||
builder.In("`repository`.owner_id",
|
||||
builder.Select("`org_user`.org_id").
|
||||
From("org_user").
|
||||
Where(builder.Eq{"`org_user`.uid": userID}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// userOrgPublicRepoCondPrivate returns the condition that one user could access all public repositories in private organizations
|
||||
func userOrgPublicRepoCondPrivate(userID int64) builder.Cond {
|
||||
return builder.And(
|
||||
builder.Eq{"`repository`.is_private": false},
|
||||
builder.In("`repository`.owner_id",
|
||||
builder.Select("`org_user`.org_id").
|
||||
From("org_user").
|
||||
Join("INNER", "`user`", "`user`.id = `org_user`.org_id").
|
||||
Where(builder.Eq{
|
||||
"`org_user`.uid": userID,
|
||||
"`user`.`type`": user_model.UserTypeOrganization,
|
||||
"`user`.visibility": structs.VisibleTypePrivate,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// UserOrgPublicUnitRepoCond returns the condition that one user could access all public repositories in the special organization
|
||||
func UserOrgPublicUnitRepoCond(userID, orgID int64) builder.Cond {
|
||||
return userOrgPublicRepoCond(userID).
|
||||
And(builder.Eq{"`repository`.owner_id": orgID})
|
||||
}
|
||||
|
||||
// SearchRepositoryCondition creates a query condition according search repository options
|
||||
func SearchRepositoryCondition(opts SearchRepoOptions) builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
|
||||
if opts.Private {
|
||||
if opts.Actor != nil && !opts.Actor.IsAdmin && opts.Actor.ID != opts.OwnerID {
|
||||
// OK we're in the context of a User
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
}
|
||||
} else {
|
||||
// Not looking at private organisations and users
|
||||
// We should be able to see all non-private repositories that
|
||||
// isn't in a private or limited organisation.
|
||||
cond = cond.And(
|
||||
builder.Eq{"is_private": false},
|
||||
builder.NotIn("owner_id", builder.Select("id").From("`user`").Where(
|
||||
builder.Or(builder.Eq{"visibility": structs.VisibleTypeLimited}, builder.Eq{"visibility": structs.VisibleTypePrivate}),
|
||||
)))
|
||||
}
|
||||
|
||||
if opts.IsPrivate.Has() {
|
||||
cond = cond.And(builder.Eq{"is_private": opts.IsPrivate.Value()})
|
||||
}
|
||||
|
||||
if opts.Template.Has() {
|
||||
cond = cond.And(builder.Eq{"is_template": opts.Template.Value()})
|
||||
}
|
||||
|
||||
// Restrict to starred repositories
|
||||
if opts.StarredByID > 0 {
|
||||
cond = cond.And(builder.In("id", builder.Select("repo_id").From("star").Where(builder.Eq{"uid": opts.StarredByID})))
|
||||
}
|
||||
|
||||
// Restrict to watched repositories
|
||||
if opts.WatchedByID > 0 {
|
||||
cond = cond.And(builder.In("id", builder.Select("repo_id").From("watch").Where(builder.Eq{"user_id": opts.WatchedByID})))
|
||||
}
|
||||
|
||||
// Restrict repositories to those the OwnerID owns or contributes to as per opts.Collaborate
|
||||
if opts.OwnerID > 0 {
|
||||
accessCond := builder.NewCond()
|
||||
if !opts.Collaborate.Value() {
|
||||
accessCond = builder.Eq{"owner_id": opts.OwnerID}
|
||||
}
|
||||
|
||||
if opts.Collaborate.ValueOrDefault(true) {
|
||||
// A Collaboration is:
|
||||
|
||||
collaborateCond := builder.NewCond()
|
||||
// 1. Repository we don't own
|
||||
collaborateCond = collaborateCond.And(builder.Neq{"owner_id": opts.OwnerID})
|
||||
// 2. But we can see because of:
|
||||
{
|
||||
userAccessCond := builder.NewCond()
|
||||
// A. We have unit independent access
|
||||
userAccessCond = userAccessCond.Or(UserAccessRepoCond("`repository`.id", opts.OwnerID))
|
||||
// B. We are in a team for
|
||||
if opts.UnitType == unit.TypeInvalid {
|
||||
userAccessCond = userAccessCond.Or(UserOrgTeamRepoCond("`repository`.id", opts.OwnerID))
|
||||
} else {
|
||||
userAccessCond = userAccessCond.Or(userOrgTeamUnitRepoCond("`repository`.id", opts.OwnerID, opts.UnitType))
|
||||
}
|
||||
// C. Public repositories in organizations that we are member of
|
||||
userAccessCond = userAccessCond.Or(userOrgPublicRepoCondPrivate(opts.OwnerID))
|
||||
collaborateCond = collaborateCond.And(userAccessCond)
|
||||
}
|
||||
if !opts.Private {
|
||||
collaborateCond = collaborateCond.And(builder.Expr("owner_id NOT IN (SELECT org_id FROM org_user WHERE org_user.uid = ? AND org_user.is_public = ?)", opts.OwnerID, false))
|
||||
}
|
||||
|
||||
accessCond = accessCond.Or(collaborateCond)
|
||||
}
|
||||
|
||||
if opts.AllPublic {
|
||||
accessCond = accessCond.Or(builder.Eq{"is_private": false}.And(builder.In("owner_id", builder.Select("`user`.id").From("`user`").Where(builder.Eq{"`user`.visibility": structs.VisibleTypePublic}))))
|
||||
}
|
||||
|
||||
if opts.AllLimited {
|
||||
accessCond = accessCond.Or(builder.Eq{"is_private": false}.And(builder.In("owner_id", builder.Select("`user`.id").From("`user`").Where(builder.Eq{"`user`.visibility": structs.VisibleTypeLimited}))))
|
||||
}
|
||||
|
||||
cond = cond.And(accessCond)
|
||||
}
|
||||
|
||||
if opts.TeamID > 0 {
|
||||
cond = cond.And(builder.In("`repository`.id", builder.Select("`team_repo`.repo_id").From("team_repo").Where(builder.Eq{"`team_repo`.team_id": opts.TeamID})))
|
||||
}
|
||||
|
||||
if opts.Keyword != "" {
|
||||
// separate keyword
|
||||
subQueryCond := builder.NewCond()
|
||||
for v := range strings.SplitSeq(opts.Keyword, ",") {
|
||||
if opts.TopicOnly {
|
||||
subQueryCond = subQueryCond.Or(builder.Eq{"topic.name": strings.ToLower(v)})
|
||||
} else {
|
||||
subQueryCond = subQueryCond.Or(builder.Like{"topic.name", strings.ToLower(v)})
|
||||
}
|
||||
}
|
||||
subQuery := builder.Select("repo_topic.repo_id").From("repo_topic").
|
||||
Join("INNER", "topic", "topic.id = repo_topic.topic_id").
|
||||
Where(subQueryCond).
|
||||
GroupBy("repo_topic.repo_id")
|
||||
|
||||
keywordCond := builder.In("id", subQuery)
|
||||
if !opts.TopicOnly {
|
||||
likes := builder.NewCond()
|
||||
for v := range strings.SplitSeq(opts.Keyword, ",") {
|
||||
likes = likes.Or(builder.Like{"lower_name", strings.ToLower(v)})
|
||||
|
||||
// If the string looks like "org/repo", match against that pattern too
|
||||
if opts.TeamID == 0 && strings.Count(opts.Keyword, "/") == 1 {
|
||||
pieces := strings.Split(opts.Keyword, "/")
|
||||
ownerName := pieces[0]
|
||||
repoName := pieces[1]
|
||||
likes = likes.Or(builder.And(builder.Like{"owner_name", strings.ToLower(ownerName)}, builder.Like{"lower_name", strings.ToLower(repoName)}))
|
||||
}
|
||||
|
||||
if opts.IncludeDescription {
|
||||
likes = likes.Or(builder.Like{"LOWER(description)", strings.ToLower(v)})
|
||||
}
|
||||
}
|
||||
keywordCond = keywordCond.Or(likes)
|
||||
}
|
||||
cond = cond.And(keywordCond)
|
||||
}
|
||||
|
||||
if opts.Language != "" {
|
||||
cond = cond.And(builder.In("id", builder.
|
||||
Select("repo_id").
|
||||
From("language_stat").
|
||||
Where(builder.Eq{"language": opts.Language}).And(builder.Eq{"is_primary": true})))
|
||||
}
|
||||
|
||||
if opts.Fork.Has() || opts.OnlyShowRelevant {
|
||||
if opts.OnlyShowRelevant && !opts.Fork.Has() {
|
||||
cond = cond.And(builder.Eq{"is_fork": false})
|
||||
} else {
|
||||
cond = cond.And(builder.Eq{"is_fork": opts.Fork.Value()})
|
||||
|
||||
if opts.ForkFrom > 0 && opts.Fork.Value() {
|
||||
cond = cond.And(builder.Eq{"fork_id": opts.ForkFrom})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Mirror.Has() {
|
||||
cond = cond.And(builder.Eq{"is_mirror": opts.Mirror.Value()})
|
||||
}
|
||||
|
||||
if opts.Actor != nil && opts.Actor.IsRestricted {
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
}
|
||||
|
||||
if opts.Archived.Has() {
|
||||
cond = cond.And(builder.Eq{"is_archived": opts.Archived.Value()})
|
||||
}
|
||||
|
||||
if opts.HasMilestones.Has() {
|
||||
if opts.HasMilestones.Value() {
|
||||
cond = cond.And(builder.Gt{"num_milestones": 0})
|
||||
} else {
|
||||
cond = cond.And(builder.Eq{"num_milestones": 0}.Or(builder.IsNull{"num_milestones"}))
|
||||
}
|
||||
}
|
||||
|
||||
if opts.OnlyShowRelevant {
|
||||
// Only show a repo that has at least a topic, an icon, or a description
|
||||
subQueryCond := builder.NewCond()
|
||||
|
||||
// Topic checking. Topics are present.
|
||||
if setting.Database.Type.IsPostgreSQL() { // postgres stores the topics as json and not as text
|
||||
subQueryCond = subQueryCond.Or(builder.And(builder.NotNull{"topics"}, builder.Neq{"(topics)::text": "[]"}))
|
||||
} else {
|
||||
subQueryCond = subQueryCond.Or(builder.And(builder.Neq{"topics": "null"}, builder.Neq{"topics": "[]"}))
|
||||
}
|
||||
|
||||
// Description checking. Description not empty
|
||||
subQueryCond = subQueryCond.Or(builder.Neq{"description": ""})
|
||||
|
||||
// Repo has a avatar
|
||||
subQueryCond = subQueryCond.Or(builder.Neq{"avatar": ""})
|
||||
|
||||
// Always hide repo's that are empty
|
||||
subQueryCond = subQueryCond.And(builder.Eq{"is_empty": false})
|
||||
|
||||
cond = cond.And(subQueryCond)
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
// SearchRepository returns repositories based on search options,
|
||||
// it returns results in given range and number of total results.
|
||||
func SearchRepository(ctx context.Context, opts SearchRepoOptions) (RepositoryList, int64, error) {
|
||||
cond := SearchRepositoryCondition(opts)
|
||||
return SearchRepositoryByCondition(ctx, opts, cond, true)
|
||||
}
|
||||
|
||||
// CountRepository counts repositories based on search options,
|
||||
func CountRepository(ctx context.Context, opts SearchRepoOptions) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(SearchRepositoryCondition(opts)).Count(new(Repository))
|
||||
}
|
||||
|
||||
// SearchRepositoryByCondition search repositories by condition
|
||||
func SearchRepositoryByCondition(ctx context.Context, opts SearchRepoOptions, cond builder.Cond, loadAttributes bool) (RepositoryList, int64, error) {
|
||||
sess, count, err := searchRepositoryByCondition(ctx, opts, cond)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
defaultSize := 50
|
||||
if opts.PageSize > 0 {
|
||||
defaultSize = opts.PageSize
|
||||
}
|
||||
repos := make(RepositoryList, 0, defaultSize)
|
||||
if err := sess.Find(&repos); err != nil {
|
||||
return nil, 0, fmt.Errorf("Repo: %w", err)
|
||||
}
|
||||
|
||||
if opts.PageSize <= 0 {
|
||||
count = int64(len(repos))
|
||||
}
|
||||
|
||||
if loadAttributes {
|
||||
if err := repos.LoadAttributes(ctx); err != nil {
|
||||
return nil, 0, fmt.Errorf("LoadAttributes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return repos, count, nil
|
||||
}
|
||||
|
||||
func searchRepositoryByCondition(ctx context.Context, opts SearchRepoOptions, cond builder.Cond) (db.Engine, int64, error) {
|
||||
page := opts.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
orderBy := opts.OrderBy
|
||||
if len(orderBy) == 0 {
|
||||
orderBy = db.SearchOrderByAlphabetically
|
||||
}
|
||||
|
||||
args := make([]any, 0)
|
||||
if opts.PriorityOwnerID > 0 {
|
||||
orderBy = db.SearchOrderBy(fmt.Sprintf("CASE WHEN owner_id = ? THEN 0 ELSE owner_id END, %s", orderBy))
|
||||
args = append(args, opts.PriorityOwnerID)
|
||||
} else if strings.Count(opts.Keyword, "/") == 1 {
|
||||
// With "owner/repo" search times, prioritise results which match the owner field
|
||||
orgName := strings.Split(opts.Keyword, "/")[0]
|
||||
orderBy = db.SearchOrderBy(fmt.Sprintf("CASE WHEN owner_name LIKE ? THEN 0 ELSE 1 END, %s", orderBy))
|
||||
args = append(args, orgName)
|
||||
}
|
||||
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
var count int64
|
||||
if opts.PageSize > 0 {
|
||||
var err error
|
||||
count, err = sess.
|
||||
Where(cond).
|
||||
Count(new(Repository))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("Count: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sess = sess.Where(cond).OrderBy(orderBy.String(), args...)
|
||||
if opts.PageSize > 0 {
|
||||
sess = sess.Limit(opts.PageSize, (page-1)*opts.PageSize)
|
||||
}
|
||||
return sess, count, nil
|
||||
}
|
||||
|
||||
// SearchRepositoryIDsByCondition search repository IDs by given condition.
|
||||
func SearchRepositoryIDsByCondition(ctx context.Context, cond builder.Cond) ([]int64, error) {
|
||||
repoIDs := make([]int64, 0, 10)
|
||||
return repoIDs, db.GetEngine(ctx).
|
||||
Table("repository").
|
||||
Cols("id").
|
||||
Where(cond).
|
||||
Find(&repoIDs)
|
||||
}
|
||||
|
||||
func userAllPublicRepoCond(cond builder.Cond, orgVisibilityLimit []structs.VisibleType) builder.Cond {
|
||||
return cond.Or(builder.And(
|
||||
builder.Eq{"`repository`.is_private": false},
|
||||
// Aren't in a private organisation or limited organisation if we're not logged in
|
||||
builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(
|
||||
builder.And(
|
||||
builder.Eq{"type": user_model.UserTypeOrganization},
|
||||
builder.In("visibility", orgVisibilityLimit)),
|
||||
))))
|
||||
}
|
||||
|
||||
// AccessibleRepositoryCondition takes a user a returns a condition for checking if a repository is accessible
|
||||
func AccessibleRepositoryCondition(user *user_model.User, unitType unit.Type) builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
|
||||
if user == nil || !user.IsRestricted || user.ID <= 0 {
|
||||
orgVisibilityLimit := []structs.VisibleType{structs.VisibleTypePrivate}
|
||||
if user == nil || user.ID <= 0 {
|
||||
orgVisibilityLimit = append(orgVisibilityLimit, structs.VisibleTypeLimited)
|
||||
}
|
||||
// 1. Be able to see all non-private repositories
|
||||
cond = userAllPublicRepoCond(cond, orgVisibilityLimit)
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
// 2. Be able to see all repositories that we have unit independent access to
|
||||
// 3. Be able to see all repositories through team membership(s)
|
||||
if unitType == unit.TypeInvalid {
|
||||
// Regardless of UnitType
|
||||
cond = cond.Or(
|
||||
UserAccessRepoCond("`repository`.id", user.ID),
|
||||
UserOrgTeamRepoCond("`repository`.id", user.ID),
|
||||
)
|
||||
} else {
|
||||
// For a specific UnitType
|
||||
cond = cond.Or(
|
||||
UserCollaborationRepoCond("`repository`.id", user.ID),
|
||||
userOrgTeamUnitRepoCond("`repository`.id", user.ID, unitType),
|
||||
)
|
||||
}
|
||||
// 4. Repositories that we directly own
|
||||
cond = cond.Or(builder.Eq{"`repository`.owner_id": user.ID})
|
||||
if !user.IsRestricted {
|
||||
// 5. Be able to see all public repos in private organizations that we are an org_user of
|
||||
cond = cond.Or(userOrgPublicRepoCond(user.ID))
|
||||
} else if !setting.Service.RequireSignInViewStrict {
|
||||
orgVisibilityLimit := []structs.VisibleType{structs.VisibleTypePrivate, structs.VisibleTypeLimited}
|
||||
cond = userAllPublicRepoCond(cond, orgVisibilityLimit)
|
||||
}
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
// SearchRepositoryByName takes keyword and part of repository name to search,
|
||||
// it returns results in given range and number of total results.
|
||||
func SearchRepositoryByName(ctx context.Context, opts SearchRepoOptions) (RepositoryList, int64, error) {
|
||||
opts.IncludeDescription = false
|
||||
return SearchRepository(ctx, opts)
|
||||
}
|
||||
|
||||
// SearchRepositoryIDs takes keyword and part of repository name to search,
|
||||
// it returns results in given range and number of total results.
|
||||
func SearchRepositoryIDs(ctx context.Context, opts SearchRepoOptions) ([]int64, int64, error) {
|
||||
opts.IncludeDescription = false
|
||||
|
||||
cond := SearchRepositoryCondition(opts)
|
||||
|
||||
sess, count, err := searchRepositoryByCondition(ctx, opts, cond)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
defaultSize := 50
|
||||
if opts.PageSize > 0 {
|
||||
defaultSize = opts.PageSize
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, defaultSize)
|
||||
err = sess.Select("id").Table("repository").Find(&ids)
|
||||
if opts.PageSize <= 0 {
|
||||
count = int64(len(ids))
|
||||
}
|
||||
|
||||
return ids, count, err
|
||||
}
|
||||
|
||||
// AccessibleRepoIDsQuery queries accessible repository ids. Usable as a subquery wherever repo ids need to be filtered.
|
||||
func AccessibleRepoIDsQuery(user *user_model.User) *builder.Builder {
|
||||
// NB: Please note this code needs to still work if user is nil
|
||||
return builder.Select("id").From("repository").Where(AccessibleRepositoryCondition(user, unit.TypeInvalid))
|
||||
}
|
||||
|
||||
// FindUserCodeAccessibleRepoIDs finds all at Code level accessible repositories' ID by the user's id
|
||||
func FindUserCodeAccessibleRepoIDs(ctx context.Context, user *user_model.User) ([]int64, error) {
|
||||
return SearchRepositoryIDsByCondition(ctx, AccessibleRepositoryCondition(user, unit.TypeCode))
|
||||
}
|
||||
|
||||
// FindUserCodeAccessibleOwnerRepoIDs finds all repository IDs for the given owner whose code the user can see.
|
||||
func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user *user_model.User) ([]int64, error) {
|
||||
return SearchRepositoryIDsByCondition(ctx, builder.NewCond().And(
|
||||
builder.Eq{"owner_id": ownerID},
|
||||
AccessibleRepositoryCondition(user, unit.TypeCode),
|
||||
))
|
||||
}
|
||||
|
||||
// GetUserRepositories returns a list of repositories of given user.
|
||||
func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (RepositoryList, int64, error) {
|
||||
if len(opts.OrderBy) == 0 {
|
||||
opts.OrderBy = "updated_unix DESC"
|
||||
}
|
||||
|
||||
cond := builder.NewCond()
|
||||
if opts.Actor == nil {
|
||||
return nil, 0, util.NewInvalidArgumentErrorf("GetUserRepositories: Actor is needed but not given")
|
||||
}
|
||||
cond = cond.And(builder.Eq{"owner_id": opts.Actor.ID})
|
||||
if !opts.Private {
|
||||
cond = cond.And(builder.Eq{"is_private": false})
|
||||
}
|
||||
|
||||
if len(opts.LowerNames) > 0 {
|
||||
cond = cond.And(builder.In("lower_name", opts.LowerNames))
|
||||
}
|
||||
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
count, err := sess.Where(cond).Count(new(Repository))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("Count: %w", err)
|
||||
}
|
||||
|
||||
sess = sess.Where(cond).OrderBy(opts.OrderBy.String())
|
||||
repos := make(RepositoryList, 0, opts.PageSize)
|
||||
return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos)
|
||||
}
|
||||
|
||||
func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) {
|
||||
if len(repoIDs) == 0 {
|
||||
return RepositoryList{}, nil
|
||||
}
|
||||
repos := make(RepositoryList, 0, len(repoIDs))
|
||||
return repos, db.GetEngine(ctx).Where(builder.Eq{"owner_id": ownerID}).In("id", repoIDs).Find(&repos)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func getTestCases() []struct {
|
||||
name string
|
||||
opts repo_model.SearchRepoOptions
|
||||
count int
|
||||
} {
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts repo_model.SearchRepoOptions
|
||||
count int
|
||||
}{
|
||||
{
|
||||
name: "PublicRepositoriesByName",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{PageSize: 10}, Collaborate: optional.Some(false)},
|
||||
count: 7,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesByName",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 14,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesByNameWithPagesizeLimitFirstPage",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 1, PageSize: 5}, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 14,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesByNameWithPagesizeLimitSecondPage",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 2, PageSize: 5}, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 14,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesByNameWithPagesizeLimitThirdPage",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 3, PageSize: 5}, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 14,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesByNameWithPagesizeLimitFourthPage",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 3, PageSize: 5}, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 14,
|
||||
},
|
||||
{
|
||||
name: "PublicRepositoriesOfUser",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Collaborate: optional.Some(false)},
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
name: "PublicRepositoriesOfUser2",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Collaborate: optional.Some(false)},
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
name: "PublicRepositoriesOfOrg3",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 20, Collaborate: optional.Some(false)},
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesOfUser",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesOfUser2",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesOfOrg3",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 20, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
name: "PublicRepositoriesOfUserIncludingCollaborative",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15},
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
name: "PublicRepositoriesOfUser2IncludingCollaborative",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18},
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
name: "PublicRepositoriesOfOrg3IncludingCollaborative",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 20},
|
||||
count: 3,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesOfUserIncludingCollaborative",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true},
|
||||
count: 9,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesOfUser2IncludingCollaborative",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true},
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesOfOrg3IncludingCollaborative",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 20, Private: true},
|
||||
count: 7,
|
||||
},
|
||||
{
|
||||
name: "PublicRepositoriesOfOrganization",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, Collaborate: optional.Some(false)},
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
name: "PublicAndPrivateRepositoriesOfOrganization",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, Private: true, Collaborate: optional.Some(false)},
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicRepositoriesByName",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{PageSize: 10}, AllPublic: true, Collaborate: optional.Some(false)},
|
||||
count: 7,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicAndPrivateRepositoriesByName",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "big_test_", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, AllPublic: true, Collaborate: optional.Some(false)},
|
||||
count: 14,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicRepositoriesOfUserIncludingCollaborative",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, AllPublic: true, Template: optional.Some(false)},
|
||||
count: 34,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborative",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true, AllLimited: true, Template: optional.Some(false)},
|
||||
count: 39,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicAndPrivateRepositoriesOfUserIncludingCollaborativeByName",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 15, Private: true, AllPublic: true},
|
||||
count: 15,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicAndPrivateRepositoriesOfUser2IncludingCollaborativeByName",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "test", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 18, Private: true, AllPublic: true},
|
||||
count: 13,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/PublicRepositoriesOfOrganization",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerID: 17, AllPublic: true, Collaborate: optional.Some(false), Template: optional.Some(false)},
|
||||
count: 34,
|
||||
},
|
||||
{
|
||||
name: "AllTemplates",
|
||||
opts: repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Template: optional.Some(true)},
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
name: "OwnerSlashRepoSearch",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "user/repo2", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, OwnerID: 0},
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
name: "OwnerSlashSearch",
|
||||
opts: repo_model.SearchRepoOptions{Keyword: "user20/", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, OwnerID: 0},
|
||||
count: 4,
|
||||
},
|
||||
}
|
||||
|
||||
return testCases
|
||||
}
|
||||
|
||||
func TestSearchRepository(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
t.Run("SearchRepositoryPublic", testSearchRepositoryPublic)
|
||||
t.Run("SearchRepositoryPublicRestricted", testSearchRepositoryRestricted)
|
||||
t.Run("SearchRepositoryPrivate", testSearchRepositoryPrivate)
|
||||
t.Run("SearchRepositoryNonExistingOwner", testSearchRepositoryNonExistingOwner)
|
||||
t.Run("SearchRepositoryWithInDescription", testSearchRepositoryWithInDescription)
|
||||
t.Run("SearchRepositoryNotInDescription", testSearchRepositoryNotInDescription)
|
||||
t.Run("SearchRepositoryCases", testSearchRepositoryCases)
|
||||
}
|
||||
|
||||
func testSearchRepositoryPublic(t *testing.T) {
|
||||
// test search public repository on explore page
|
||||
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
},
|
||||
Keyword: "repo_12",
|
||||
Collaborate: optional.Some(false),
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, repos, 1) {
|
||||
assert.Equal(t, "test_repo_12", repos[0].Name)
|
||||
}
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
repos, count, err = repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
},
|
||||
Keyword: "test_repo",
|
||||
Collaborate: optional.Some(false),
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), count)
|
||||
assert.Len(t, repos, 2)
|
||||
}
|
||||
|
||||
func testSearchRepositoryRestricted(t *testing.T) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29, IsRestricted: true})
|
||||
|
||||
performSearch := func(t *testing.T, user *user_model.User) (publicRepoIDs []int64) {
|
||||
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: 10000},
|
||||
Actor: user,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, repos, int(count))
|
||||
for _, repo := range repos {
|
||||
require.NoError(t, repo.LoadOwner(t.Context()))
|
||||
if repo.Owner.Visibility == structs.VisibleTypePublic && !repo.IsPrivate {
|
||||
publicRepoIDs = append(publicRepoIDs, repo.ID)
|
||||
}
|
||||
}
|
||||
return publicRepoIDs
|
||||
}
|
||||
|
||||
normalPublicRepoIDs := performSearch(t, user2)
|
||||
require.Greater(t, len(normalPublicRepoIDs), 10) // quite a lot
|
||||
|
||||
t.Run("RestrictedUser-NoSignInRequirement", func(t *testing.T) {
|
||||
// restricted user can also see public repositories if no "required sign-in"
|
||||
repoIDs := performSearch(t, restrictedUser)
|
||||
assert.ElementsMatch(t, normalPublicRepoIDs, repoIDs)
|
||||
})
|
||||
|
||||
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
|
||||
|
||||
t.Run("NormalUser-RequiredSignIn", func(t *testing.T) {
|
||||
// normal user can still see all public repos, not affected by "required sign-in"
|
||||
repoIDs := performSearch(t, user2)
|
||||
assert.ElementsMatch(t, normalPublicRepoIDs, repoIDs)
|
||||
})
|
||||
t.Run("RestrictedUser-RequiredSignIn", func(t *testing.T) {
|
||||
// restricted user can see only their own repo
|
||||
repoIDs := performSearch(t, restrictedUser)
|
||||
assert.Equal(t, []int64{4}, repoIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func testSearchRepositoryPrivate(t *testing.T) {
|
||||
// test search private repository on explore page
|
||||
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
},
|
||||
Keyword: "repo_13",
|
||||
Private: true,
|
||||
Collaborate: optional.Some(false),
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, repos, 1) {
|
||||
assert.Equal(t, "test_repo_13", repos[0].Name)
|
||||
}
|
||||
assert.Equal(t, int64(1), count)
|
||||
|
||||
repos, count, err = repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
},
|
||||
Keyword: "test_repo",
|
||||
Private: true,
|
||||
Collaborate: optional.Some(false),
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(3), count)
|
||||
assert.Len(t, repos, 3)
|
||||
}
|
||||
|
||||
func testSearchRepositoryNonExistingOwner(t *testing.T) {
|
||||
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{OwnerID: unittest.NonexistentID})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, repos)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func testSearchRepositoryWithInDescription(t *testing.T) {
|
||||
repos, count, err := repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
},
|
||||
Keyword: "description_14",
|
||||
Collaborate: optional.Some(false),
|
||||
IncludeDescription: true,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, repos, 1) {
|
||||
assert.Equal(t, "test_repo_14", repos[0].Name)
|
||||
}
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func testSearchRepositoryNotInDescription(t *testing.T) {
|
||||
repos, count, err := repo_model.SearchRepository(t.Context(), repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
},
|
||||
Keyword: "description_14",
|
||||
Collaborate: optional.Some(false),
|
||||
IncludeDescription: false,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, repos)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func testSearchRepositoryCases(t *testing.T) {
|
||||
testCases := getTestCases()
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), testCase.opts)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(testCase.count), count)
|
||||
|
||||
page := testCase.opts.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
expectedLen := testCase.opts.PageSize
|
||||
if testCase.opts.PageSize*page > testCase.count+testCase.opts.PageSize {
|
||||
expectedLen = 0
|
||||
} else if testCase.opts.PageSize*page > testCase.count {
|
||||
expectedLen = testCase.count % testCase.opts.PageSize
|
||||
}
|
||||
if assert.Len(t, repos, expectedLen) {
|
||||
for _, repo := range repos {
|
||||
assert.NotEmpty(t, repo.Name)
|
||||
|
||||
if len(testCase.opts.Keyword) > 0 {
|
||||
// Keyword match condition is different for search terms of form "owner/repo"
|
||||
if strings.Count(testCase.opts.Keyword, "/") == 1 {
|
||||
// May still match as a whole...
|
||||
wholeMatch := strings.Contains(repo.Name, testCase.opts.Keyword)
|
||||
|
||||
pieces := strings.Split(testCase.opts.Keyword, "/")
|
||||
ownerName := pieces[0]
|
||||
repoName := pieces[1]
|
||||
// ... or match in parts
|
||||
splitMatch := strings.Contains(repo.OwnerName, ownerName) && strings.Contains(repo.Name, repoName)
|
||||
|
||||
assert.True(t, wholeMatch || splitMatch, "Keyword '%s' does not match repo '%s/%s'", testCase.opts.Keyword, repo.Owner.Name, repo.Name)
|
||||
} else {
|
||||
assert.Contains(t, repo.Name, testCase.opts.Keyword)
|
||||
}
|
||||
}
|
||||
|
||||
if !testCase.opts.Private {
|
||||
assert.False(t, repo.IsPrivate)
|
||||
}
|
||||
|
||||
if testCase.opts.Fork.Value() && testCase.opts.Mirror.Value() {
|
||||
assert.True(t, repo.IsFork && repo.IsMirror)
|
||||
} else {
|
||||
if testCase.opts.Fork.Has() {
|
||||
assert.Equal(t, testCase.opts.Fork.Value(), repo.IsFork)
|
||||
}
|
||||
|
||||
if testCase.opts.Mirror.Has() {
|
||||
assert.Equal(t, testCase.opts.Mirror.Value(), repo.IsMirror)
|
||||
}
|
||||
}
|
||||
|
||||
if testCase.opts.OwnerID > 0 && !testCase.opts.AllPublic {
|
||||
if testCase.opts.Collaborate.Has() {
|
||||
if testCase.opts.Collaborate.Value() {
|
||||
assert.NotEqual(t, testCase.opts.OwnerID, repo.Owner.ID)
|
||||
} else {
|
||||
assert.Equal(t, testCase.opts.OwnerID, repo.Owner.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountRepository(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testCases := getTestCases()
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
count, err := repo_model.CountRepository(t.Context(), testCase.opts)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(testCase.count), count)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchRepositoryByTopicName(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
opts repo_model.SearchRepoOptions
|
||||
count int
|
||||
}{
|
||||
{
|
||||
name: "AllPublic/SearchPublicRepositoriesFromTopicAndName",
|
||||
opts: repo_model.SearchRepoOptions{OwnerID: 21, AllPublic: true, Keyword: "graphql"},
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/OnlySearchPublicRepositoriesFromTopic",
|
||||
opts: repo_model.SearchRepoOptions{OwnerID: 21, AllPublic: true, Keyword: "graphql", TopicOnly: true},
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
name: "AllPublic/OnlySearchMultipleKeywordPublicRepositoriesFromTopic",
|
||||
opts: repo_model.SearchRepoOptions{OwnerID: 21, AllPublic: true, Keyword: "graphql,golang", TopicOnly: true},
|
||||
count: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, count, err := repo_model.SearchRepositoryByName(t.Context(), testCase.opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(testCase.count), count)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
countRepospts = CountRepositoryOptions{OwnerID: 10}
|
||||
countReposptsPublic = CountRepositoryOptions{OwnerID: 10, Private: optional.Some(false)}
|
||||
countReposptsPrivate = CountRepositoryOptions{OwnerID: 10, Private: optional.Some(true)}
|
||||
)
|
||||
|
||||
func TestGetRepositoryCount(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
ctx := t.Context()
|
||||
count, err1 := CountRepositories(ctx, countRepospts)
|
||||
privateCount, err2 := CountRepositories(ctx, countReposptsPrivate)
|
||||
publicCount, err3 := CountRepositories(ctx, countReposptsPublic)
|
||||
assert.NoError(t, err1)
|
||||
assert.NoError(t, err2)
|
||||
assert.NoError(t, err3)
|
||||
assert.Equal(t, int64(3), count)
|
||||
assert.Equal(t, privateCount+publicCount, count)
|
||||
}
|
||||
|
||||
func TestGetPublicRepositoryCount(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
count, err := CountRepositories(t.Context(), countReposptsPublic)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestGetPrivateRepositoryCount(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
count, err := CountRepositories(t.Context(), countReposptsPrivate)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), count)
|
||||
}
|
||||
|
||||
func TestRepoAPIURL(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 10})
|
||||
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user12/repo10", repo.APIURL())
|
||||
}
|
||||
|
||||
func TestWatchRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 3})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
assert.NoError(t, WatchRepo(t.Context(), user, repo, true))
|
||||
unittest.AssertExistsAndLoadBean(t, &Watch{RepoID: repo.ID, UserID: user.ID})
|
||||
unittest.CheckConsistencyFor(t, &Repository{ID: repo.ID})
|
||||
|
||||
assert.NoError(t, WatchRepo(t.Context(), user, repo, false))
|
||||
unittest.AssertNotExistsBean(t, &Watch{RepoID: repo.ID, UserID: user.ID})
|
||||
unittest.CheckConsistencyFor(t, &Repository{ID: repo.ID})
|
||||
}
|
||||
|
||||
func TestMetas(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := &Repository{Name: "testRepo"}
|
||||
repo.Owner = &user_model.User{Name: "testOwner"}
|
||||
repo.OwnerName = repo.Owner.Name
|
||||
|
||||
repo.Units = nil
|
||||
|
||||
metas := repo.ComposeCommentMetas(t.Context())
|
||||
assert.Equal(t, "testRepo", metas["repo"])
|
||||
assert.Equal(t, "testOwner", metas["user"])
|
||||
|
||||
externalTracker := RepoUnit{
|
||||
Type: unit.TypeExternalTracker,
|
||||
Config: &ExternalTrackerConfig{
|
||||
ExternalTrackerFormat: "https://someurl.com/{user}/{repo}/{issue}",
|
||||
},
|
||||
}
|
||||
|
||||
testSuccess := func(expectedStyle string) {
|
||||
repo.Units = []*RepoUnit{&externalTracker}
|
||||
repo.commonRenderingMetas = nil
|
||||
metas := repo.ComposeCommentMetas(t.Context())
|
||||
assert.Equal(t, expectedStyle, metas["style"])
|
||||
assert.Equal(t, "testRepo", metas["repo"])
|
||||
assert.Equal(t, "testOwner", metas["user"])
|
||||
assert.Equal(t, "https://someurl.com/{user}/{repo}/{issue}", metas["format"])
|
||||
}
|
||||
|
||||
testSuccess(markup.IssueNameStyleNumeric)
|
||||
|
||||
externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleAlphanumeric
|
||||
testSuccess(markup.IssueNameStyleAlphanumeric)
|
||||
|
||||
externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleNumeric
|
||||
testSuccess(markup.IssueNameStyleNumeric)
|
||||
|
||||
externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleRegexp
|
||||
testSuccess(markup.IssueNameStyleRegexp)
|
||||
|
||||
repo, err := GetRepositoryByID(t.Context(), 3)
|
||||
assert.NoError(t, err)
|
||||
|
||||
metas = repo.ComposeCommentMetas(t.Context())
|
||||
assert.Contains(t, metas, "org")
|
||||
assert.Contains(t, metas, "teams")
|
||||
assert.Equal(t, "org3", metas["org"])
|
||||
assert.Equal(t, ",owners,team1,", metas["teams"])
|
||||
}
|
||||
|
||||
func TestGetRepositoryByURL(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("InvalidPath", func(t *testing.T) {
|
||||
repo, err := GetRepositoryByURL(t.Context(), "something")
|
||||
assert.Nil(t, repo)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
testRepo2 := func(t *testing.T, url string) {
|
||||
repo, err := GetRepositoryByURL(t.Context(), url)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, repo.ID)
|
||||
assert.EqualValues(t, 2, repo.OwnerID)
|
||||
}
|
||||
|
||||
t.Run("ValidHttpURL", func(t *testing.T) {
|
||||
testRepo2(t, "https://try.gitea.io/user2/repo2")
|
||||
testRepo2(t, "https://try.gitea.io/user2/repo2.git")
|
||||
})
|
||||
|
||||
t.Run("ValidGitSshURL", func(t *testing.T) {
|
||||
testRepo2(t, "git+ssh://sshuser@try.gitea.io/user2/repo2")
|
||||
testRepo2(t, "git+ssh://sshuser@try.gitea.io/user2/repo2.git")
|
||||
|
||||
testRepo2(t, "git+ssh://try.gitea.io/user2/repo2")
|
||||
testRepo2(t, "git+ssh://try.gitea.io/user2/repo2.git")
|
||||
})
|
||||
|
||||
t.Run("ValidImplicitSshURL", func(t *testing.T) {
|
||||
testRepo2(t, "sshuser@try.gitea.io:user2/repo2")
|
||||
testRepo2(t, "sshuser@try.gitea.io:user2/repo2.git")
|
||||
|
||||
testRelax := func(t *testing.T, url string) {
|
||||
repo, err := GetRepositoryByURLRelax(t.Context(), url)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), repo.ID)
|
||||
assert.Equal(t, int64(2), repo.OwnerID)
|
||||
}
|
||||
// TODO: it doesn't seem to be common git ssh URL, should we really support this?
|
||||
testRelax(t, "try.gitea.io:user2/repo2")
|
||||
testRelax(t, "try.gitea.io:user2/repo2.git")
|
||||
})
|
||||
}
|
||||
|
||||
func TestComposeSSHCloneURL(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.SSH, setting.SSH)()
|
||||
defer test.MockVariableValue(&setting.Repository, setting.Repository)()
|
||||
|
||||
setting.SSH.User = "git"
|
||||
|
||||
// test SSH_DOMAIN
|
||||
setting.SSH.Domain = "domain"
|
||||
setting.SSH.Port = 22
|
||||
setting.Repository.UseCompatSSHURI = false
|
||||
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
||||
setting.Repository.UseCompatSSHURI = true
|
||||
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
||||
// test SSH_DOMAIN while use non-standard SSH port
|
||||
setting.SSH.Port = 123
|
||||
setting.Repository.UseCompatSSHURI = false
|
||||
assert.Equal(t, "ssh://git@domain:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo"))
|
||||
setting.Repository.UseCompatSSHURI = true
|
||||
assert.Equal(t, "ssh://git@domain:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo"))
|
||||
|
||||
// test IPv6 SSH_DOMAIN
|
||||
setting.Repository.UseCompatSSHURI = false
|
||||
setting.SSH.Domain = "::1"
|
||||
setting.SSH.Port = 22
|
||||
assert.Equal(t, "git@[::1]:user/repo.git", ComposeSSHCloneURL(nil, "user", "repo"))
|
||||
setting.SSH.Port = 123
|
||||
assert.Equal(t, "ssh://git@[::1]:123/user/repo.git", ComposeSSHCloneURL(nil, "user", "repo"))
|
||||
|
||||
setting.SSH.User = "(DOER_USERNAME)"
|
||||
setting.SSH.Domain = "domain"
|
||||
setting.SSH.Port = 22
|
||||
assert.Equal(t, "doer@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
||||
setting.SSH.Port = 123
|
||||
assert.Equal(t, "ssh://doer@domain:123/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
||||
}
|
||||
|
||||
func TestIsUsableRepoName(t *testing.T) {
|
||||
assert.NoError(t, IsUsableRepoName("a"))
|
||||
assert.NoError(t, IsUsableRepoName("-1_."))
|
||||
assert.NoError(t, IsUsableRepoName(".profile"))
|
||||
|
||||
assert.Error(t, IsUsableRepoName("-"))
|
||||
assert.Error(t, IsUsableRepoName("🌞"))
|
||||
assert.Error(t, IsUsableRepoName("the/repo"))
|
||||
assert.Error(t, IsUsableRepoName("the..repo"))
|
||||
assert.Error(t, IsUsableRepoName("foo.wiki"))
|
||||
assert.Error(t, IsUsableRepoName("foo.git"))
|
||||
assert.Error(t, IsUsableRepoName("foo.RSS"))
|
||||
}
|
||||
|
||||
func TestIsValidSSHAccessRepoName(t *testing.T) {
|
||||
assert.True(t, IsValidSSHAccessRepoName("a"))
|
||||
assert.True(t, IsValidSSHAccessRepoName("-1_."))
|
||||
assert.True(t, IsValidSSHAccessRepoName(".profile"))
|
||||
assert.True(t, IsValidSSHAccessRepoName("foo.wiki"))
|
||||
|
||||
assert.False(t, IsValidSSHAccessRepoName("-"))
|
||||
assert.False(t, IsValidSSHAccessRepoName("🌞"))
|
||||
assert.False(t, IsValidSSHAccessRepoName("the/repo"))
|
||||
assert.False(t, IsValidSSHAccessRepoName("the..repo"))
|
||||
assert.False(t, IsValidSSHAccessRepoName("foo.git"))
|
||||
assert.False(t, IsValidSSHAccessRepoName("foo.RSS"))
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/convert"
|
||||
)
|
||||
|
||||
// ErrUnitTypeNotExist represents a "UnitTypeNotExist" kind of error.
|
||||
type ErrUnitTypeNotExist struct {
|
||||
UT unit.Type
|
||||
}
|
||||
|
||||
// IsErrUnitTypeNotExist checks if an error is a ErrUnitNotExist.
|
||||
func IsErrUnitTypeNotExist(err error) bool {
|
||||
_, ok := err.(ErrUnitTypeNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUnitTypeNotExist) Error() string {
|
||||
return "Unit type does not exist: " + err.UT.LogString()
|
||||
}
|
||||
|
||||
func (err ErrUnitTypeNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// RepoUnit describes all units of a repository
|
||||
type RepoUnit struct { //revive:disable-line:exported
|
||||
ID int64
|
||||
RepoID int64 `xorm:"INDEX(s)"`
|
||||
Type unit.Type `xorm:"INDEX(s)"`
|
||||
Config convert.Conversion `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
|
||||
AnonymousAccessMode perm.AccessMode `xorm:"NOT NULL DEFAULT 0"`
|
||||
EveryoneAccessMode perm.AccessMode `xorm:"NOT NULL DEFAULT 0"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(RepoUnit))
|
||||
}
|
||||
|
||||
// UnitConfig describes common unit config
|
||||
type UnitConfig struct{}
|
||||
|
||||
// FromDB fills up a UnitConfig from serialized format.
|
||||
func (cfg *UnitConfig) FromDB(bs []byte) error {
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
||||
}
|
||||
|
||||
// ToDB exports a UnitConfig to a serialized format.
|
||||
func (cfg *UnitConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
// ExternalWikiConfig describes external wiki config
|
||||
type ExternalWikiConfig struct {
|
||||
ExternalWikiURL string
|
||||
}
|
||||
|
||||
// FromDB fills up a ExternalWikiConfig from serialized format.
|
||||
func (cfg *ExternalWikiConfig) FromDB(bs []byte) error {
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
||||
}
|
||||
|
||||
// ToDB exports a ExternalWikiConfig to a serialized format.
|
||||
func (cfg *ExternalWikiConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
// ExternalTrackerConfig describes external tracker config
|
||||
type ExternalTrackerConfig struct {
|
||||
ExternalTrackerURL string
|
||||
ExternalTrackerFormat string
|
||||
ExternalTrackerStyle string
|
||||
ExternalTrackerRegexpPattern string
|
||||
}
|
||||
|
||||
// FromDB fills up a ExternalTrackerConfig from serialized format.
|
||||
func (cfg *ExternalTrackerConfig) FromDB(bs []byte) error {
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
||||
}
|
||||
|
||||
// ToDB exports a ExternalTrackerConfig to a serialized format.
|
||||
func (cfg *ExternalTrackerConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
// IssuesConfig describes issues config
|
||||
type IssuesConfig struct {
|
||||
EnableTimetracker bool
|
||||
AllowOnlyContributorsToTrackTime bool
|
||||
EnableDependencies bool
|
||||
}
|
||||
|
||||
// FromDB fills up a IssuesConfig from serialized format.
|
||||
func (cfg *IssuesConfig) FromDB(bs []byte) error {
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
||||
}
|
||||
|
||||
// ToDB exports a IssuesConfig to a serialized format.
|
||||
func (cfg *IssuesConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
// PullRequestsConfig describes pull requests config
|
||||
type PullRequestsConfig struct {
|
||||
IgnoreWhitespaceConflicts bool
|
||||
AllowMerge bool
|
||||
AllowRebase bool
|
||||
AllowRebaseMerge bool
|
||||
AllowSquash bool
|
||||
AllowFastForwardOnly bool
|
||||
AllowManualMerge bool
|
||||
AutodetectManualMerge bool
|
||||
AllowMergeUpdate bool
|
||||
AllowRebaseUpdate bool
|
||||
DefaultUpdateStyle UpdateStyle
|
||||
DefaultDeleteBranchAfterMerge bool
|
||||
DefaultMergeStyle MergeStyle
|
||||
DefaultAllowMaintainerEdit bool
|
||||
DefaultTargetBranch string
|
||||
}
|
||||
|
||||
func DefaultPullRequestsConfig() *PullRequestsConfig {
|
||||
cfg := &PullRequestsConfig{
|
||||
AllowMerge: true,
|
||||
AllowRebase: true,
|
||||
AllowRebaseMerge: true,
|
||||
AllowSquash: true,
|
||||
AllowFastForwardOnly: true,
|
||||
AllowMergeUpdate: true,
|
||||
AllowRebaseUpdate: true,
|
||||
DefaultUpdateStyle: UpdateStyleMerge,
|
||||
DefaultAllowMaintainerEdit: true,
|
||||
}
|
||||
cfg.DefaultDeleteBranchAfterMerge = setting.Repository.PullRequest.DefaultDeleteBranchAfterMerge
|
||||
cfg.DefaultMergeStyle = MergeStyle(setting.Repository.PullRequest.DefaultMergeStyle)
|
||||
cfg.DefaultMergeStyle = util.IfZero(cfg.DefaultMergeStyle, MergeStyleMerge)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// FromDB fills up a PullRequestsConfig from serialized format.
|
||||
func (cfg *PullRequestsConfig) FromDB(bs []byte) error {
|
||||
// set default values for existing PullRequestConfig in DB
|
||||
*cfg = *DefaultPullRequestsConfig()
|
||||
_ = json.UnmarshalHandleDoubleEncode(bs, &cfg) // don't let corrupted database value cause unnecessary 500 error
|
||||
cfg.DefaultUpdateStyle = util.IfZero(cfg.DefaultUpdateStyle, UpdateStyleMerge)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDB exports a PullRequestsConfig to a serialized format.
|
||||
func (cfg *PullRequestsConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
// IsMergeStyleAllowed returns if merge style is allowed
|
||||
func (cfg *PullRequestsConfig) IsMergeStyleAllowed(mergeStyle MergeStyle) bool {
|
||||
return mergeStyle == MergeStyleMerge && cfg.AllowMerge ||
|
||||
mergeStyle == MergeStyleRebase && cfg.AllowRebase ||
|
||||
mergeStyle == MergeStyleRebaseMerge && cfg.AllowRebaseMerge ||
|
||||
mergeStyle == MergeStyleSquash && cfg.AllowSquash ||
|
||||
mergeStyle == MergeStyleFastForwardOnly && cfg.AllowFastForwardOnly ||
|
||||
mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge
|
||||
}
|
||||
|
||||
// IsUpdateStyleAllowed returns if a pull request branch update style is allowed
|
||||
func (cfg *PullRequestsConfig) IsUpdateStyleAllowed(updateStyle UpdateStyle) bool {
|
||||
switch updateStyle {
|
||||
case UpdateStyleMerge:
|
||||
return cfg.AllowMergeUpdate
|
||||
case UpdateStyleRebase:
|
||||
return cfg.AllowRebaseUpdate
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUpdateSettings checks that the AllowMerge/RebaseUpdate flags and DefaultUpdateStyle are mutually consistent.
|
||||
func (cfg *PullRequestsConfig) ValidateUpdateSettings() error {
|
||||
if cfg.DefaultUpdateStyle != UpdateStyleMerge && cfg.DefaultUpdateStyle != UpdateStyleRebase {
|
||||
return util.NewInvalidArgumentErrorf("default update style must be merge or rebase")
|
||||
}
|
||||
if !cfg.AllowMergeUpdate && !cfg.AllowRebaseUpdate {
|
||||
return util.NewInvalidArgumentErrorf("at least one pull request branch update style must be enabled")
|
||||
}
|
||||
if !cfg.IsUpdateStyleAllowed(cfg.DefaultUpdateStyle) {
|
||||
return util.NewInvalidArgumentErrorf("default update style must be enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultPullRequestsUnit(repoID int64) RepoUnit {
|
||||
return RepoUnit{RepoID: repoID, Type: unit.TypePullRequests, Config: DefaultPullRequestsConfig()}
|
||||
}
|
||||
|
||||
// ProjectsMode represents the projects enabled for a repository
|
||||
type ProjectsMode string
|
||||
|
||||
const (
|
||||
// ProjectsModeRepo allows only repo-level projects
|
||||
ProjectsModeRepo ProjectsMode = "repo"
|
||||
// ProjectsModeOwner allows only owner-level projects
|
||||
ProjectsModeOwner ProjectsMode = "owner"
|
||||
// ProjectsModeAll allows both kinds of projects
|
||||
ProjectsModeAll ProjectsMode = "all"
|
||||
// ProjectsModeNone doesn't allow projects
|
||||
ProjectsModeNone ProjectsMode = "none"
|
||||
)
|
||||
|
||||
// ProjectsConfig describes projects config
|
||||
type ProjectsConfig struct {
|
||||
ProjectsMode ProjectsMode
|
||||
}
|
||||
|
||||
// FromDB fills up a ProjectsConfig from serialized format.
|
||||
func (cfg *ProjectsConfig) FromDB(bs []byte) error {
|
||||
// TODO: remove GetProjectsMode, only use ProjectsMode
|
||||
cfg.ProjectsMode = ProjectsModeAll
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
||||
}
|
||||
|
||||
// ToDB exports a ProjectsConfig to a serialized format.
|
||||
func (cfg *ProjectsConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
func (cfg *ProjectsConfig) GetProjectsMode() ProjectsMode {
|
||||
if cfg.ProjectsMode != "" {
|
||||
return cfg.ProjectsMode
|
||||
}
|
||||
|
||||
return ProjectsModeAll
|
||||
}
|
||||
|
||||
func (cfg *ProjectsConfig) IsProjectsAllowed(m ProjectsMode) bool {
|
||||
projectsMode := cfg.GetProjectsMode()
|
||||
|
||||
if m == ProjectsModeNone {
|
||||
return true
|
||||
}
|
||||
|
||||
return projectsMode == m || projectsMode == ProjectsModeAll
|
||||
}
|
||||
|
||||
// BeforeSet is invoked from XORM before setting the value of a field of this object.
|
||||
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
|
||||
switch colName {
|
||||
case "type":
|
||||
var err error
|
||||
r.Type, _, err = db.CellToInt(val, unit.TypeInvalid)
|
||||
if err != nil {
|
||||
setting.PanicInDevOrTesting("Unable to convert repo unit (id=%d) type: %v", r.ID, err)
|
||||
}
|
||||
switch r.Type {
|
||||
case unit.TypeExternalWiki:
|
||||
r.Config = new(ExternalWikiConfig)
|
||||
case unit.TypeExternalTracker:
|
||||
r.Config = new(ExternalTrackerConfig)
|
||||
case unit.TypePullRequests:
|
||||
r.Config = new(PullRequestsConfig)
|
||||
case unit.TypeIssues:
|
||||
r.Config = new(IssuesConfig)
|
||||
case unit.TypeActions:
|
||||
r.Config = new(ActionsConfig)
|
||||
case unit.TypeProjects:
|
||||
r.Config = new(ProjectsConfig)
|
||||
case unit.TypeCode, unit.TypeReleases, unit.TypeWiki, unit.TypePackages:
|
||||
fallthrough
|
||||
default:
|
||||
r.Config = new(UnitConfig)
|
||||
}
|
||||
case "config":
|
||||
if *val == nil {
|
||||
// XROM doesn't call FromDB if the value is nil, but we need to set default values for the config fields
|
||||
_ = r.Config.FromDB(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unit returns Unit
|
||||
func (r *RepoUnit) Unit() unit.Unit {
|
||||
return unit.Units[r.Type]
|
||||
}
|
||||
|
||||
// CodeConfig returns config for unit.TypeCode
|
||||
func (r *RepoUnit) CodeConfig() *UnitConfig {
|
||||
return r.Config.(*UnitConfig)
|
||||
}
|
||||
|
||||
// PullRequestsConfig returns config for unit.TypePullRequests
|
||||
func (r *RepoUnit) PullRequestsConfig() *PullRequestsConfig {
|
||||
return r.Config.(*PullRequestsConfig)
|
||||
}
|
||||
|
||||
// ReleasesConfig returns config for unit.TypeReleases
|
||||
func (r *RepoUnit) ReleasesConfig() *UnitConfig {
|
||||
return r.Config.(*UnitConfig)
|
||||
}
|
||||
|
||||
// ExternalWikiConfig returns config for unit.TypeExternalWiki
|
||||
func (r *RepoUnit) ExternalWikiConfig() *ExternalWikiConfig {
|
||||
return r.Config.(*ExternalWikiConfig)
|
||||
}
|
||||
|
||||
// IssuesConfig returns config for unit.TypeIssues
|
||||
func (r *RepoUnit) IssuesConfig() *IssuesConfig {
|
||||
return r.Config.(*IssuesConfig)
|
||||
}
|
||||
|
||||
// ExternalTrackerConfig returns config for unit.TypeExternalTracker
|
||||
func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig {
|
||||
return r.Config.(*ExternalTrackerConfig)
|
||||
}
|
||||
|
||||
// ActionsConfig returns config for unit.ActionsConfig
|
||||
func (r *RepoUnit) ActionsConfig() *ActionsConfig {
|
||||
return r.Config.(*ActionsConfig)
|
||||
}
|
||||
|
||||
// ProjectsConfig returns config for unit.ProjectsConfig
|
||||
func (r *RepoUnit) ProjectsConfig() *ProjectsConfig {
|
||||
return r.Config.(*ProjectsConfig)
|
||||
}
|
||||
|
||||
func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err error) {
|
||||
var tmpUnits []*RepoUnit
|
||||
if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&tmpUnits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range tmpUnits {
|
||||
if !u.Type.UnitGlobalDisabled() {
|
||||
units = append(units, u)
|
||||
}
|
||||
}
|
||||
|
||||
return units, nil
|
||||
}
|
||||
|
||||
// UpdateRepoUnitConfig updates the config of the provided repo unit
|
||||
func UpdateRepoUnitConfig(ctx context.Context, unit *RepoUnit) error {
|
||||
_, err := db.GetEngine(ctx).ID(unit.ID).Cols("config").Update(unit)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateRepoUnitPublicAccess(ctx context.Context, unit *RepoUnit) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id=? AND `type`=?", unit.RepoID, unit.Type).
|
||||
Cols("anonymous_access_mode", "everyone_access_mode").Update(unit)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ActionsTokenPermissionMode defines the default permission mode for Actions tokens
|
||||
type ActionsTokenPermissionMode string
|
||||
|
||||
const (
|
||||
// ActionsTokenPermissionModePermissive - write access by default (current behavior, backwards compatible)
|
||||
ActionsTokenPermissionModePermissive ActionsTokenPermissionMode = "permissive"
|
||||
// ActionsTokenPermissionModeRestricted - read access by default
|
||||
ActionsTokenPermissionModeRestricted ActionsTokenPermissionMode = "restricted"
|
||||
)
|
||||
|
||||
func (ActionsTokenPermissionMode) EnumValues() []ActionsTokenPermissionMode {
|
||||
return []ActionsTokenPermissionMode{ActionsTokenPermissionModePermissive /* default */, ActionsTokenPermissionModeRestricted}
|
||||
}
|
||||
|
||||
// ActionsTokenPermissions defines the permissions for different repository units
|
||||
type ActionsTokenPermissions struct {
|
||||
UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"`
|
||||
}
|
||||
|
||||
var ActionsTokenUnitTypes = []unit.Type{
|
||||
unit.TypeCode,
|
||||
unit.TypeIssues,
|
||||
unit.TypePullRequests,
|
||||
unit.TypePackages,
|
||||
unit.TypeActions,
|
||||
unit.TypeWiki,
|
||||
unit.TypeReleases,
|
||||
unit.TypeProjects,
|
||||
}
|
||||
|
||||
func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTokenPermissions) {
|
||||
ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
|
||||
for _, u := range ActionsTokenUnitTypes {
|
||||
ret.UnitAccessModes[u] = unitAccessMode
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// ClampActionsTokenPermissions ensures that the given permissions don't exceed the maximum
|
||||
func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTokenPermissions) {
|
||||
ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
|
||||
for _, ut := range ActionsTokenUnitTypes {
|
||||
ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut])
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// MakeRestrictedPermissions returns the restricted permissions
|
||||
func MakeRestrictedPermissions() ActionsTokenPermissions {
|
||||
ret := MakeActionsTokenPermissions(perm.AccessModeNone)
|
||||
ret.UnitAccessModes[unit.TypeCode] = perm.AccessModeRead
|
||||
ret.UnitAccessModes[unit.TypePackages] = perm.AccessModeRead
|
||||
ret.UnitAccessModes[unit.TypeReleases] = perm.AccessModeRead
|
||||
return ret
|
||||
}
|
||||
|
||||
type ActionsConfig struct {
|
||||
DisabledWorkflows []string
|
||||
// CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos.
|
||||
// Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions.
|
||||
CollaborativeOwnerIDs []int64
|
||||
// TokenPermissionMode defines the default permission mode (permissive, restricted, or custom)
|
||||
TokenPermissionMode ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"`
|
||||
// MaxTokenPermissions defines the absolute maximum permissions any token can have in this context.
|
||||
// Workflow YAML "permissions" keywords can reduce permissions but never exceed this ceiling.
|
||||
MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"`
|
||||
// OverrideOwnerConfig indicates if this repository should override the owner-level configuration (User or Org)
|
||||
OverrideOwnerConfig bool `json:"override_owner_config,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) EnableWorkflow(file string) {
|
||||
cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool {
|
||||
return slices.Contains(cfg.DisabledWorkflows, file)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) DisableWorkflow(file string) {
|
||||
if slices.Contains(cfg.DisabledWorkflows, file) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) {
|
||||
if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) {
|
||||
cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) {
|
||||
cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool {
|
||||
return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID)
|
||||
}
|
||||
|
||||
// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode.
|
||||
// It does not apply MaxTokenPermissions; callers must clamp if needed.
|
||||
func (cfg *ActionsConfig) GetDefaultTokenPermissions() ActionsTokenPermissions {
|
||||
switch cfg.TokenPermissionMode {
|
||||
case ActionsTokenPermissionModeRestricted:
|
||||
return MakeRestrictedPermissions()
|
||||
case ActionsTokenPermissionModePermissive:
|
||||
return MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
default:
|
||||
return ActionsTokenPermissions{}
|
||||
}
|
||||
}
|
||||
|
||||
// GetMaxTokenPermissions returns the maximum allowed permissions
|
||||
func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions {
|
||||
if cfg.MaxTokenPermissions != nil {
|
||||
return *cfg.MaxTokenPermissions
|
||||
}
|
||||
// Default max is write for everything
|
||||
return MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
}
|
||||
|
||||
// ClampPermissions ensures that the given permissions don't exceed the maximum
|
||||
func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) ActionsTokenPermissions {
|
||||
maxPerms := cfg.GetMaxTokenPermissions()
|
||||
return ClampActionsTokenPermissions(perms, maxPerms)
|
||||
}
|
||||
|
||||
// FromDB fills up a ActionsConfig from serialized format.
|
||||
func (cfg *ActionsConfig) FromDB(bs []byte) error {
|
||||
_ = json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
||||
cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDB exports a ActionsConfig to a serialized format.
|
||||
func (cfg *ActionsConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/unit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestActionsConfig(t *testing.T) {
|
||||
cfg := &ActionsConfig{}
|
||||
cfg.DisableWorkflow("test1.yaml")
|
||||
assert.Equal(t, []string{"test1.yaml"}, cfg.DisabledWorkflows)
|
||||
|
||||
cfg.DisableWorkflow("test1.yaml")
|
||||
assert.Equal(t, []string{"test1.yaml"}, cfg.DisabledWorkflows)
|
||||
|
||||
cfg.EnableWorkflow("test1.yaml")
|
||||
assert.Equal(t, []string{}, cfg.DisabledWorkflows)
|
||||
|
||||
cfg.EnableWorkflow("test1.yaml")
|
||||
assert.Equal(t, []string{}, cfg.DisabledWorkflows)
|
||||
|
||||
cfg.DisableWorkflow("test1.yaml")
|
||||
cfg.DisableWorkflow("test2.yaml")
|
||||
cfg.DisableWorkflow("test3.yaml")
|
||||
assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", strings.Join(cfg.DisabledWorkflows, ","))
|
||||
}
|
||||
|
||||
func TestActionsConfigTokenPermissions(t *testing.T) {
|
||||
t.Run("Default Permission Mode", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{TokenPermissionMode: "invalid-value"}
|
||||
_ = cfg.FromDB(nil)
|
||||
assert.Equal(t, ActionsTokenPermissionModePermissive, cfg.TokenPermissionMode)
|
||||
assert.Equal(t, perm.AccessModeWrite, cfg.GetDefaultTokenPermissions().UnitAccessModes[unit.TypeCode])
|
||||
})
|
||||
|
||||
t.Run("Explicit Permission Mode", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{
|
||||
TokenPermissionMode: ActionsTokenPermissionModeRestricted,
|
||||
}
|
||||
assert.Equal(t, ActionsTokenPermissionModeRestricted, cfg.TokenPermissionMode)
|
||||
})
|
||||
|
||||
t.Run("Effective Permissions - Permissive Mode", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{
|
||||
TokenPermissionMode: ActionsTokenPermissionModePermissive,
|
||||
}
|
||||
defaultPerms := cfg.GetDefaultTokenPermissions()
|
||||
perms := cfg.ClampPermissions(defaultPerms)
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypePackages])
|
||||
})
|
||||
|
||||
t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{
|
||||
TokenPermissionMode: ActionsTokenPermissionModeRestricted,
|
||||
}
|
||||
defaultPerms := cfg.GetDefaultTokenPermissions()
|
||||
perms := cfg.ClampPermissions(defaultPerms)
|
||||
assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeNone, perms.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypePackages])
|
||||
})
|
||||
|
||||
t.Run("Clamp Permissions", func(t *testing.T) {
|
||||
cfg := &ActionsConfig{
|
||||
MaxTokenPermissions: &ActionsTokenPermissions{
|
||||
UnitAccessModes: map[unit.Type]perm.AccessMode{
|
||||
unit.TypeCode: perm.AccessModeRead,
|
||||
unit.TypeIssues: perm.AccessModeWrite,
|
||||
unit.TypePullRequests: perm.AccessModeRead,
|
||||
unit.TypePackages: perm.AccessModeRead,
|
||||
unit.TypeActions: perm.AccessModeNone,
|
||||
unit.TypeWiki: perm.AccessModeWrite,
|
||||
},
|
||||
},
|
||||
}
|
||||
input := ActionsTokenPermissions{
|
||||
UnitAccessModes: map[unit.Type]perm.AccessMode{
|
||||
unit.TypeCode: perm.AccessModeWrite, // Should be clamped to Read
|
||||
unit.TypeIssues: perm.AccessModeWrite, // Should stay Write
|
||||
unit.TypePullRequests: perm.AccessModeWrite, // Should be clamped to Read
|
||||
unit.TypePackages: perm.AccessModeWrite, // Should be clamped to Read
|
||||
unit.TypeActions: perm.AccessModeRead, // Should be clamped to None
|
||||
unit.TypeWiki: perm.AccessModeRead, // Should stay Read
|
||||
},
|
||||
}
|
||||
clamped := cfg.ClampPermissions(input)
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeWrite, clamped.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePullRequests])
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeNone, clamped.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeWiki])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import "gitea.dev/models/db"
|
||||
|
||||
// OrderByMap represents all possible search order
|
||||
var OrderByMap = map[string]map[string]db.SearchOrderBy{
|
||||
"asc": {
|
||||
"alpha": "owner_name ASC, name ASC",
|
||||
"created": db.SearchOrderByOldest,
|
||||
"updated": db.SearchOrderByLeastUpdated,
|
||||
"size": "size ASC",
|
||||
"git_size": "git_size ASC",
|
||||
"lfs_size": "lfs_size ASC",
|
||||
"id": db.SearchOrderByID,
|
||||
"stars": db.SearchOrderByStars,
|
||||
"forks": db.SearchOrderByForks,
|
||||
},
|
||||
"desc": {
|
||||
"alpha": "owner_name DESC, name DESC",
|
||||
"created": db.SearchOrderByNewest,
|
||||
"updated": db.SearchOrderByRecentUpdated,
|
||||
"size": "size DESC",
|
||||
"git_size": "git_size DESC",
|
||||
"lfs_size": "lfs_size DESC",
|
||||
"id": db.SearchOrderByIDReverse,
|
||||
"stars": db.SearchOrderByStarsReverse,
|
||||
"forks": db.SearchOrderByForksReverse,
|
||||
},
|
||||
}
|
||||
|
||||
// OrderByFlatMap is similar to OrderByMap but use human language keywords
|
||||
// to decide between asc and desc
|
||||
var OrderByFlatMap = map[string]db.SearchOrderBy{
|
||||
"newest": OrderByMap["desc"]["created"],
|
||||
"oldest": OrderByMap["asc"]["created"],
|
||||
"recentupdate": OrderByMap["desc"]["updated"],
|
||||
"leastupdate": OrderByMap["asc"]["updated"],
|
||||
"reversealphabetically": OrderByMap["desc"]["alpha"],
|
||||
"alphabetically": OrderByMap["asc"]["alpha"],
|
||||
"reversesize": OrderByMap["desc"]["size"],
|
||||
"size": OrderByMap["asc"]["size"],
|
||||
"reversegitsize": OrderByMap["desc"]["git_size"],
|
||||
"gitsize": OrderByMap["asc"]["git_size"],
|
||||
"reverselfssize": OrderByMap["desc"]["lfs_size"],
|
||||
"lfssize": OrderByMap["asc"]["lfs_size"],
|
||||
"moststars": OrderByMap["desc"]["stars"],
|
||||
"feweststars": OrderByMap["asc"]["stars"],
|
||||
"mostforks": OrderByMap["desc"]["forks"],
|
||||
"fewestforks": OrderByMap["asc"]["forks"],
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// Star represents a starred repo by a user.
|
||||
type Star struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UID int64 `xorm:"UNIQUE(s)"`
|
||||
RepoID int64 `xorm:"UNIQUE(s)"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Star))
|
||||
}
|
||||
|
||||
// StarRepo or unstar repository.
|
||||
func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star bool) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
staring := IsStaring(ctx, doer.ID, repo.ID)
|
||||
|
||||
if star {
|
||||
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
if staring {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, &Star{UID: doer.ID, RepoID: repo.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = num_stars + 1 WHERE id = ?", repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars + 1 WHERE id = ?", doer.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if !staring {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := db.DeleteByBean(ctx, &Star{UID: doer.ID, RepoID: repo.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = num_stars - 1 WHERE id = ?", repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars - 1 WHERE id = ?", doer.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// IsStaring checks if user has starred given repository.
|
||||
func IsStaring(ctx context.Context, userID, repoID int64) bool {
|
||||
has, _ := db.GetEngine(ctx).Get(&Star{UID: userID, RepoID: repoID})
|
||||
return has
|
||||
}
|
||||
|
||||
// GetStargazers returns the users that starred the repo.
|
||||
func GetStargazers(ctx context.Context, repo *Repository, opts db.ListOptions) ([]*user_model.User, error) {
|
||||
sess := db.GetEngine(ctx).Where("star.repo_id = ?", repo.ID).
|
||||
Join("LEFT", "star", "`user`.id = star.uid")
|
||||
if opts.Page > 0 {
|
||||
db.SetSessionPagination(sess, &opts)
|
||||
|
||||
users := make([]*user_model.User, 0, opts.PageSize)
|
||||
return users, sess.Find(&users)
|
||||
}
|
||||
|
||||
users := make([]*user_model.User, 0, 8)
|
||||
return users, sess.Find(&users)
|
||||
}
|
||||
|
||||
// ClearRepoStars clears all stars for a repository and from the user that starred it.
|
||||
// Used when a repository is set to private.
|
||||
func ClearRepoStars(ctx context.Context, repoID int64) error {
|
||||
if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars=num_stars-1 WHERE id IN (SELECT `uid` FROM `star` WHERE repo_id = ?)", repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = 0 WHERE id = ?", repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.DeleteBeans(ctx, Star{RepoID: repoID})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
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 TestStarRepo(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID})
|
||||
assert.NoError(t, repo_model.StarRepo(t.Context(), user, repo, true))
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID})
|
||||
assert.NoError(t, repo_model.StarRepo(t.Context(), user, repo, true))
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID})
|
||||
assert.NoError(t, repo_model.StarRepo(t.Context(), user, repo, false))
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID})
|
||||
}
|
||||
|
||||
func TestIsStaring(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
assert.True(t, repo_model.IsStaring(t.Context(), 2, 4))
|
||||
assert.False(t, repo_model.IsStaring(t.Context(), 3, 4))
|
||||
}
|
||||
|
||||
func TestRepository_GetStargazers(t *testing.T) {
|
||||
// repo with stargazers
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
gazers, err := repo_model.GetStargazers(t.Context(), repo, db.ListOptions{Page: 0})
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, gazers, 1) {
|
||||
assert.Equal(t, int64(2), gazers[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_GetStargazers2(t *testing.T) {
|
||||
// repo with stargazers
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
gazers, err := repo_model.GetStargazers(t.Context(), repo, db.ListOptions{Page: 0})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, gazers)
|
||||
}
|
||||
|
||||
func TestClearRepoStars(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID})
|
||||
assert.NoError(t, repo_model.StarRepo(t.Context(), user, repo, true))
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID})
|
||||
assert.NoError(t, repo_model.StarRepo(t.Context(), user, repo, false))
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID})
|
||||
assert.NoError(t, repo_model.ClearRepoStars(t.Context(), repo.ID))
|
||||
unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID})
|
||||
|
||||
gazers, err := repo_model.GetStargazers(t.Context(), repo, db.ListOptions{Page: 0})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, gazers)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Topic))
|
||||
db.RegisterModel(new(RepoTopic))
|
||||
}
|
||||
|
||||
var topicPattern = regexp.MustCompile(`^[a-z0-9][-.a-z0-9]*$`)
|
||||
|
||||
// Topic represents a topic of repositories
|
||||
type Topic struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Name string `xorm:"UNIQUE VARCHAR(50)"`
|
||||
RepoCount int
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
// RepoTopic represents associated repositories and topics
|
||||
type RepoTopic struct { //revive:disable-line:exported
|
||||
RepoID int64 `xorm:"pk"`
|
||||
TopicID int64 `xorm:"pk"`
|
||||
}
|
||||
|
||||
// ErrTopicNotExist represents an error that a topic is not exist
|
||||
type ErrTopicNotExist struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// Error implements error interface
|
||||
func (err ErrTopicNotExist) Error() string {
|
||||
return fmt.Sprintf("topic is not exist [name: %s]", err.Name)
|
||||
}
|
||||
|
||||
func (err ErrTopicNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ValidateTopic checks a topic by length and match pattern rules
|
||||
func ValidateTopic(topic string) bool {
|
||||
return len(topic) <= 35 && topicPattern.MatchString(topic)
|
||||
}
|
||||
|
||||
// SanitizeAndValidateTopics sanitizes and checks an array or topics
|
||||
func SanitizeAndValidateTopics(topics []string) (validTopics, invalidTopics []string) {
|
||||
validTopics = make([]string, 0)
|
||||
mValidTopics := make(container.Set[string])
|
||||
invalidTopics = make([]string, 0)
|
||||
|
||||
for _, topic := range topics {
|
||||
topic = strings.TrimSpace(strings.ToLower(topic))
|
||||
// ignore empty string
|
||||
if len(topic) == 0 {
|
||||
continue
|
||||
}
|
||||
// ignore same topic twice
|
||||
if mValidTopics.Contains(topic) {
|
||||
continue
|
||||
}
|
||||
if ValidateTopic(topic) {
|
||||
validTopics = append(validTopics, topic)
|
||||
mValidTopics.Add(topic)
|
||||
} else {
|
||||
invalidTopics = append(invalidTopics, topic)
|
||||
}
|
||||
}
|
||||
|
||||
return validTopics, invalidTopics
|
||||
}
|
||||
|
||||
// GetTopicByName retrieves topic by name
|
||||
func GetTopicByName(ctx context.Context, name string) (*Topic, error) {
|
||||
var topic Topic
|
||||
if has, err := db.GetEngine(ctx).Where("name = ?", name).Get(&topic); err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrTopicNotExist{name}
|
||||
}
|
||||
return &topic, nil
|
||||
}
|
||||
|
||||
// addTopicByNameToRepo adds a topic name to a repo and increments the topic count.
|
||||
// Returns topic after the addition
|
||||
func addTopicByNameToRepo(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
|
||||
var topic Topic
|
||||
e := db.GetEngine(ctx)
|
||||
has, err := e.Where("name = ?", topicName).Get(&topic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
topic.Name = topicName
|
||||
topic.RepoCount = 1
|
||||
if err := db.Insert(ctx, &topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
topic.RepoCount++
|
||||
if _, err := e.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, &RepoTopic{
|
||||
RepoID: repoID,
|
||||
TopicID: topic.ID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &topic, nil
|
||||
}
|
||||
|
||||
// removeTopicFromRepo remove a topic from a repo and decrements the topic repo count
|
||||
func removeTopicFromRepo(ctx context.Context, repoID int64, topic *Topic) error {
|
||||
topic.RepoCount--
|
||||
e := db.GetEngine(ctx)
|
||||
if _, err := e.ID(topic.ID).Cols("repo_count").Update(topic); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := e.Delete(&RepoTopic{
|
||||
RepoID: repoID,
|
||||
TopicID: topic.ID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTopicsFromRepo remove all topics from the repo and decrements respective topics repo count
|
||||
func RemoveTopicsFromRepo(ctx context.Context, repoID int64) error {
|
||||
e := db.GetEngine(ctx)
|
||||
_, err := e.Where(
|
||||
builder.In("id",
|
||||
builder.Select("topic_id").From("repo_topic").Where(builder.Eq{"repo_id": repoID}),
|
||||
),
|
||||
).Decr("repo_count").Update(&Topic{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = e.Delete(&RepoTopic{RepoID: repoID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindTopicOptions represents the options when fdin topics
|
||||
type FindTopicOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
Keyword string
|
||||
}
|
||||
|
||||
func (opts *FindTopicOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.RepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"repo_topic.repo_id": opts.RepoID})
|
||||
}
|
||||
|
||||
if opts.Keyword != "" {
|
||||
cond = cond.And(builder.Like{"topic.name", opts.Keyword})
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts *FindTopicOptions) ToOrders() string {
|
||||
orderBy := "topic.repo_count DESC"
|
||||
if opts.RepoID > 0 {
|
||||
orderBy = "topic.name" // when render topics for a repo, it's better to sort them by name, to get consistent result
|
||||
}
|
||||
return orderBy
|
||||
}
|
||||
|
||||
func (opts *FindTopicOptions) ToJoins() []db.JoinFunc {
|
||||
if opts.RepoID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return []db.JoinFunc{
|
||||
func(e db.Engine) error {
|
||||
e.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRepoTopicByName retrieves topic from name for a repo if it exist
|
||||
func GetRepoTopicByName(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
|
||||
cond := builder.NewCond()
|
||||
var topic Topic
|
||||
cond = cond.And(builder.Eq{"repo_topic.repo_id": repoID}).And(builder.Eq{"topic.name": topicName})
|
||||
sess := db.GetEngine(ctx).Table("topic").Where(cond)
|
||||
sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
|
||||
has, err := sess.Select("topic.*").Get(&topic)
|
||||
if has {
|
||||
return &topic, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// AddTopic adds a topic name to a repository (if it does not already have it)
|
||||
func AddTopic(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Topic, error) {
|
||||
topic, err := GetRepoTopicByName(ctx, repoID, topicName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if topic != nil {
|
||||
// Repo already have topic
|
||||
return topic, nil
|
||||
}
|
||||
|
||||
topic, err = addTopicByNameToRepo(ctx, repoID, topicName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = syncTopicsInRepository(ctx, repoID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return topic, nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTopic removes a topic name from a repository (if it has it)
|
||||
func DeleteTopic(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
|
||||
topic, err := GetRepoTopicByName(ctx, repoID, topicName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if topic == nil {
|
||||
// Repo doesn't have topic, can't be removed
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the topic does not exist
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Topic, error) {
|
||||
if err = removeTopicFromRepo(ctx, repoID, topic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = syncTopicsInRepository(ctx, repoID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return topic, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SaveTopics save topics to a repository
|
||||
func SaveTopics(ctx context.Context, repoID int64, topicNames ...string) error {
|
||||
topics, err := db.Find[Topic](ctx, &FindTopicOptions{
|
||||
RepoID: repoID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
var addedTopicNames []string
|
||||
for _, topicName := range topicNames {
|
||||
if strings.TrimSpace(topicName) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, t := range topics {
|
||||
if strings.EqualFold(topicName, t.Name) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
addedTopicNames = append(addedTopicNames, topicName)
|
||||
}
|
||||
}
|
||||
|
||||
var removeTopics []*Topic
|
||||
for _, t := range topics {
|
||||
var found bool
|
||||
for _, topicName := range topicNames {
|
||||
if strings.EqualFold(topicName, t.Name) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
removeTopics = append(removeTopics, t)
|
||||
}
|
||||
}
|
||||
|
||||
for _, topicName := range addedTopicNames {
|
||||
_, err := addTopicByNameToRepo(ctx, repoID, topicName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, topic := range removeTopics {
|
||||
err := removeTopicFromRepo(ctx, repoID, topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return syncTopicsInRepository(ctx, repoID)
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateTopics generates topics from a template repository
|
||||
func GenerateTopics(ctx context.Context, templateRepo, generateRepo *Repository) error {
|
||||
for _, topic := range templateRepo.Topics {
|
||||
if _, err := addTopicByNameToRepo(ctx, generateRepo.ID, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return syncTopicsInRepository(ctx, generateRepo.ID)
|
||||
}
|
||||
|
||||
// syncTopicsInRepository makes sure topics in the topics table are copied into the topics field of the repository
|
||||
func syncTopicsInRepository(ctx context.Context, repoID int64) error {
|
||||
topicNames := make([]string, 0, 25)
|
||||
if err := db.GetEngine(ctx).Table("topic").Cols("name").
|
||||
Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id").
|
||||
Where("repo_topic.repo_id = ?", repoID).Asc("topic.name").Find(&topicNames); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).ID(repoID).Cols("topics").Update(&Repository{
|
||||
Topics: topicNames,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountOrphanedAttachments returns the number of topics that don't belong to any repository.
|
||||
func CountOrphanedTopics(ctx context.Context) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("repo_count = 0").Count(new(Topic))
|
||||
}
|
||||
|
||||
// DeleteOrphanedAttachments delete all topics that don't belong to any repository.
|
||||
func DeleteOrphanedTopics(ctx context.Context) (int64, error) {
|
||||
return db.GetEngine(ctx).Where("repo_count = 0").Delete(new(Topic))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAddTopic(t *testing.T) {
|
||||
totalNrOfTopics := 6
|
||||
repo1NrOfTopics := 3
|
||||
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
topics, err := db.Find[repo_model.Topic](t.Context(), &repo_model.FindTopicOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, topics, totalNrOfTopics)
|
||||
|
||||
topics, total, err := db.FindAndCount[repo_model.Topic](t.Context(), &repo_model.FindTopicOptions{
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: 2},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, topics, 2)
|
||||
assert.EqualValues(t, 6, total)
|
||||
|
||||
topics, err = db.Find[repo_model.Topic](t.Context(), &repo_model.FindTopicOptions{
|
||||
RepoID: 1,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, topics, repo1NrOfTopics)
|
||||
|
||||
assert.NoError(t, repo_model.SaveTopics(t.Context(), 2, "golang"))
|
||||
repo2NrOfTopics := 1
|
||||
topics, err = db.Find[repo_model.Topic](t.Context(), &repo_model.FindTopicOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, topics, totalNrOfTopics)
|
||||
|
||||
topics, err = db.Find[repo_model.Topic](t.Context(), &repo_model.FindTopicOptions{
|
||||
RepoID: 2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, topics, repo2NrOfTopics)
|
||||
|
||||
assert.NoError(t, repo_model.SaveTopics(t.Context(), 2, "golang", "gitea"))
|
||||
repo2NrOfTopics = 2
|
||||
totalNrOfTopics++
|
||||
topic, err := repo_model.GetTopicByName(t.Context(), "gitea")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, topic.RepoCount)
|
||||
|
||||
topics, err = db.Find[repo_model.Topic](t.Context(), &repo_model.FindTopicOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, topics, totalNrOfTopics)
|
||||
|
||||
topics, err = db.Find[repo_model.Topic](t.Context(), &repo_model.FindTopicOptions{
|
||||
RepoID: 2,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, topics, repo2NrOfTopics)
|
||||
}
|
||||
|
||||
func TestTopicValidator(t *testing.T) {
|
||||
assert.True(t, repo_model.ValidateTopic("12345"))
|
||||
assert.True(t, repo_model.ValidateTopic("2-test"))
|
||||
assert.True(t, repo_model.ValidateTopic("foo.bar"))
|
||||
assert.True(t, repo_model.ValidateTopic("test-3"))
|
||||
assert.True(t, repo_model.ValidateTopic("first"))
|
||||
assert.True(t, repo_model.ValidateTopic("second-test-topic"))
|
||||
assert.True(t, repo_model.ValidateTopic("third-project-topic-with-max-length"))
|
||||
|
||||
assert.False(t, repo_model.ValidateTopic("$fourth-test,topic"))
|
||||
assert.False(t, repo_model.ValidateTopic("-fifth-test-topic"))
|
||||
assert.False(t, repo_model.ValidateTopic("sixth-go-project-topic-with-excess-length"))
|
||||
assert.False(t, repo_model.ValidateTopic(".foo"))
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrNoPendingRepoTransfer is an error type for repositories without a pending
|
||||
// transfer request
|
||||
type ErrNoPendingRepoTransfer struct {
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
func (err ErrNoPendingRepoTransfer) Error() string {
|
||||
return fmt.Sprintf("repository doesn't have a pending transfer [repo_id: %d]", err.RepoID)
|
||||
}
|
||||
|
||||
// IsErrNoPendingTransfer is an error type when a repository has no pending
|
||||
// transfers
|
||||
func IsErrNoPendingTransfer(err error) bool {
|
||||
_, ok := err.(ErrNoPendingRepoTransfer)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrNoPendingRepoTransfer) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// ErrRepoTransferInProgress represents the state of a repository that has an
|
||||
// ongoing transfer
|
||||
type ErrRepoTransferInProgress struct {
|
||||
Uname string
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsErrRepoTransferInProgress checks if an error is a ErrRepoTransferInProgress.
|
||||
func IsErrRepoTransferInProgress(err error) bool {
|
||||
_, ok := err.(ErrRepoTransferInProgress)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRepoTransferInProgress) Error() string {
|
||||
return fmt.Sprintf("repository is already being transferred [uname: %s, name: %s]", err.Uname, err.Name)
|
||||
}
|
||||
|
||||
func (err ErrRepoTransferInProgress) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// RepoTransfer is used to manage repository transfers
|
||||
type RepoTransfer struct { //nolint:revive // export stutter
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
DoerID int64
|
||||
Doer *user_model.User `xorm:"-"`
|
||||
RecipientID int64
|
||||
Recipient *user_model.User `xorm:"-"`
|
||||
RepoID int64
|
||||
Repo *Repository `xorm:"-"`
|
||||
TeamIDs []int64
|
||||
Teams []*organization.Team `xorm:"-"`
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(RepoTransfer))
|
||||
}
|
||||
|
||||
func (r *RepoTransfer) LoadRecipient(ctx context.Context) error {
|
||||
if r.Recipient == nil {
|
||||
u, err := user_model.GetUserByID(ctx, r.RecipientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Recipient = u
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RepoTransfer) LoadRepo(ctx context.Context) error {
|
||||
if r.Repo == nil {
|
||||
repo, err := GetRepositoryByID(ctx, r.RepoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Repo = repo
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAttributes fetches the transfer recipient from the database
|
||||
func (r *RepoTransfer) LoadAttributes(ctx context.Context) error {
|
||||
if err := r.LoadRecipient(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.Recipient.IsOrganization() && r.Teams == nil {
|
||||
teamsMap, err := organization.GetTeamsByIDs(ctx, r.TeamIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, team := range teamsMap {
|
||||
r.Teams = append(r.Teams, team)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.LoadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.Doer == nil {
|
||||
u, err := user_model.GetUserByID(ctx, r.DoerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Doer = u
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanUserAcceptOrRejectTransfer checks if the user has the rights to accept/decline a repo transfer.
|
||||
// For user, it checks if it's himself
|
||||
// For organizations, it checks if the user is able to create repos
|
||||
func (r *RepoTransfer) CanUserAcceptOrRejectTransfer(ctx context.Context, u *user_model.User) bool {
|
||||
if err := r.LoadAttributes(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if !r.Recipient.IsOrganization() {
|
||||
return r.RecipientID == u.ID
|
||||
}
|
||||
|
||||
allowed, err := organization.CanCreateOrgRepo(ctx, r.RecipientID, u.ID)
|
||||
if err != nil {
|
||||
log.Error("CanCreateOrgRepo: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
type PendingRepositoryTransferOptions struct {
|
||||
RepoID int64
|
||||
SenderID int64
|
||||
RecipientID int64
|
||||
}
|
||||
|
||||
func (opts *PendingRepositoryTransferOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.RepoID != 0 {
|
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
|
||||
}
|
||||
if opts.SenderID != 0 {
|
||||
cond = cond.And(builder.Eq{"doer_id": opts.SenderID})
|
||||
}
|
||||
if opts.RecipientID != 0 {
|
||||
cond = cond.And(builder.Eq{"recipient_id": opts.RecipientID})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func GetPendingRepositoryTransfers(ctx context.Context, opts *PendingRepositoryTransferOptions) ([]*RepoTransfer, error) {
|
||||
transfers := make([]*RepoTransfer, 0, 10)
|
||||
return transfers, db.GetEngine(ctx).
|
||||
Where(opts.ToConds()).
|
||||
Find(&transfers)
|
||||
}
|
||||
|
||||
func IsRepositoryTransferExist(ctx context.Context, repoID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).Where("repo_id = ?", repoID).Exist(new(RepoTransfer))
|
||||
}
|
||||
|
||||
// GetPendingRepositoryTransfer fetches the most recent and ongoing transfer
|
||||
// process for the repository
|
||||
func GetPendingRepositoryTransfer(ctx context.Context, repo *Repository) (*RepoTransfer, error) {
|
||||
transfers, err := GetPendingRepositoryTransfers(ctx, &PendingRepositoryTransferOptions{RepoID: repo.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(transfers) != 1 {
|
||||
return nil, ErrNoPendingRepoTransfer{RepoID: repo.ID}
|
||||
}
|
||||
|
||||
return transfers[0], nil
|
||||
}
|
||||
|
||||
func DeleteRepositoryTransfer(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(&RepoTransfer{})
|
||||
return err
|
||||
}
|
||||
|
||||
// TestRepositoryReadyForTransfer make sure repo is ready to transfer
|
||||
func TestRepositoryReadyForTransfer(status RepositoryStatus) error {
|
||||
switch status {
|
||||
case RepositoryBeingMigrated:
|
||||
return errors.New("repo is not ready, currently migrating")
|
||||
case RepositoryPendingTransfer:
|
||||
return ErrRepoTransferInProgress{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePendingRepositoryTransfer transfer a repo from one owner to a new one.
|
||||
// it marks the repository transfer as "pending"
|
||||
func CreatePendingRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.User, repoID int64, teams []*organization.Team) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
repo, err := GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := user_model.GetUserByID(ctx, newOwner.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure repo is ready to transfer
|
||||
if err := TestRepositoryReadyForTransfer(repo.Status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exist, err := IsRepositoryTransferExist(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
return ErrRepoTransferInProgress{
|
||||
Uname: repo.Owner.LowerName,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
repo.Status = RepositoryPendingTransfer
|
||||
if err := UpdateRepositoryColsNoAutoTime(ctx, repo, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if new owner has repository with same name.
|
||||
if has, err := IsRepositoryModelExist(ctx, newOwner, repo.Name); err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return ErrRepoAlreadyExist{
|
||||
Uname: newOwner.LowerName,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
transfer := &RepoTransfer{
|
||||
RepoID: repo.ID,
|
||||
RecipientID: newOwner.ID,
|
||||
CreatedUnix: timeutil.TimeStampNow(),
|
||||
UpdatedUnix: timeutil.TimeStampNow(),
|
||||
DoerID: doer.ID,
|
||||
TeamIDs: make([]int64, 0, len(teams)),
|
||||
}
|
||||
|
||||
for k := range teams {
|
||||
transfer.TeamIDs = append(transfer.TeamIDs, teams[k].ID)
|
||||
}
|
||||
|
||||
return db.Insert(ctx, transfer)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// UpdateRepositoryOwnerNames updates repository owner_names (this should only be used when the ownerName has changed case)
|
||||
func UpdateRepositoryOwnerNames(ctx context.Context, ownerID int64, ownerName string) error {
|
||||
if ownerID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).Where("owner_id = ?", ownerID).Cols("owner_name").NoAutoTime().Update(&Repository{
|
||||
OwnerName: ownerName,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRepositoryUpdatedTime updates a repository's updated time
|
||||
func UpdateRepositoryUpdatedTime(ctx context.Context, repoID int64, updateTime time.Time) error {
|
||||
_, err := db.GetEngine(ctx).Exec("UPDATE repository SET updated_unix = ? WHERE id = ?", updateTime.Unix(), repoID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateRepositoryColsWithAutoTime updates repository's columns and the timestamp fields automatically
|
||||
func UpdateRepositoryColsWithAutoTime(ctx context.Context, repo *Repository, colName string, moreColNames ...string) error {
|
||||
_, err := db.GetEngine(ctx).ID(repo.ID).Cols(append([]string{colName}, moreColNames...)...).Update(repo)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateRepositoryColsNoAutoTime updates repository's columns, doesn't change timestamp field automatically
|
||||
func UpdateRepositoryColsNoAutoTime(ctx context.Context, repo *Repository, colName string, moreColNames ...string) error {
|
||||
_, err := db.GetEngine(ctx).ID(repo.ID).Cols(append([]string{colName}, moreColNames...)...).NoAutoTime().Update(repo)
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrReachLimitOfRepo represents a "ReachLimitOfRepo" kind of error.
|
||||
type ErrReachLimitOfRepo struct {
|
||||
Limit int
|
||||
}
|
||||
|
||||
// IsErrReachLimitOfRepo checks if an error is a ErrReachLimitOfRepo.
|
||||
func IsErrReachLimitOfRepo(err error) bool {
|
||||
_, ok := err.(ErrReachLimitOfRepo)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrReachLimitOfRepo) Error() string {
|
||||
return fmt.Sprintf("user has reached maximum limit of repositories [limit: %d]", err.Limit)
|
||||
}
|
||||
|
||||
func (err ErrReachLimitOfRepo) Unwrap() error {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// ErrRepoAlreadyExist represents a "RepoAlreadyExist" kind of error.
|
||||
type ErrRepoAlreadyExist struct {
|
||||
Uname string
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsErrRepoAlreadyExist checks if an error is a ErrRepoAlreadyExist.
|
||||
func IsErrRepoAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrRepoAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRepoAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name)
|
||||
}
|
||||
|
||||
func (err ErrRepoAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrRepoFilesAlreadyExist represents a "RepoFilesAlreadyExist" kind of error.
|
||||
type ErrRepoFilesAlreadyExist struct {
|
||||
Uname string
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsErrRepoFilesAlreadyExist checks if an error is a ErrRepoAlreadyExist.
|
||||
func IsErrRepoFilesAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrRepoFilesAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRepoFilesAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("repository files already exist [uname: %s, name: %s]", err.Uname, err.Name)
|
||||
}
|
||||
|
||||
func (err ErrRepoFilesAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// UpdateRepoSize updates the repository size, calculating it using getDirectorySize
|
||||
func UpdateRepoSize(ctx context.Context, repoID, gitSize, lfsSize int64) error {
|
||||
_, err := db.GetEngine(ctx).ID(repoID).Cols("size", "git_size", "lfs_size").NoAutoTime().Update(&Repository{
|
||||
Size: gitSize + lfsSize,
|
||||
GitSize: gitSize,
|
||||
LFSSize: lfsSize,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
gouuid "github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrUploadNotExist represents a "UploadNotExist" kind of error.
|
||||
type ErrUploadNotExist struct {
|
||||
ID int64
|
||||
UUID string
|
||||
}
|
||||
|
||||
// IsErrUploadNotExist checks if an error is a ErrUploadNotExist.
|
||||
func IsErrUploadNotExist(err error) bool {
|
||||
_, ok := err.(ErrUploadNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUploadNotExist) Error() string {
|
||||
return fmt.Sprintf("attachment does not exist [id: %d, uuid: %s]", err.ID, err.UUID)
|
||||
}
|
||||
|
||||
func (err ErrUploadNotExist) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// Upload represent a uploaded file to a repo to be deleted when moved
|
||||
type Upload struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UUID string `xorm:"uuid UNIQUE"`
|
||||
Name string
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Upload))
|
||||
}
|
||||
|
||||
// LocalPath returns where uploads are temporarily stored in local file system based on given UUID.
|
||||
func (upload *Upload) LocalPath() string {
|
||||
uuid := upload.UUID
|
||||
return setting.AppDataTempDir("repo-uploads").JoinPath(uuid[0:1], uuid[1:2], uuid)
|
||||
}
|
||||
|
||||
// NewUpload creates a new upload object.
|
||||
func NewUpload(ctx context.Context, name string, buf []byte, file multipart.File) (_ *Upload, err error) {
|
||||
upload := &Upload{
|
||||
UUID: gouuid.New().String(),
|
||||
Name: name,
|
||||
}
|
||||
|
||||
localPath := upload.LocalPath()
|
||||
if err = os.MkdirAll(filepath.Dir(localPath), os.ModePerm); err != nil {
|
||||
return nil, fmt.Errorf("MkdirAll: %w", err)
|
||||
}
|
||||
|
||||
fw, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Create: %w", err)
|
||||
}
|
||||
defer fw.Close()
|
||||
|
||||
if _, err = fw.Write(buf); err != nil {
|
||||
return nil, fmt.Errorf("Write: %w", err)
|
||||
} else if _, err = io.Copy(fw, file); err != nil {
|
||||
return nil, fmt.Errorf("Copy: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).Insert(upload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return upload, nil
|
||||
}
|
||||
|
||||
// GetUploadByUUID returns the Upload by UUID
|
||||
func GetUploadByUUID(ctx context.Context, uuid string) (*Upload, error) {
|
||||
upload := &Upload{}
|
||||
has, err := db.GetEngine(ctx).Where("uuid=?", uuid).Get(upload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrUploadNotExist{0, uuid}
|
||||
}
|
||||
return upload, nil
|
||||
}
|
||||
|
||||
// GetUploadsByUUIDs returns multiple uploads by UUIDS
|
||||
func GetUploadsByUUIDs(ctx context.Context, uuids []string) ([]*Upload, error) {
|
||||
if len(uuids) == 0 {
|
||||
return []*Upload{}, nil
|
||||
}
|
||||
|
||||
// Silently drop invalid uuids.
|
||||
uploads := make([]*Upload, 0, len(uuids))
|
||||
return uploads, db.GetEngine(ctx).In("uuid", uuids).Find(&uploads)
|
||||
}
|
||||
|
||||
// DeleteUploads deletes multiple uploads
|
||||
func DeleteUploads(ctx context.Context, uploads ...*Upload) (err error) {
|
||||
if len(uploads) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]int64, len(uploads))
|
||||
for i := range uploads {
|
||||
ids[i] = uploads[i].ID
|
||||
}
|
||||
if err = db.DeleteByIDs[Upload](ctx, ids...); err != nil {
|
||||
return fmt.Errorf("delete uploads: %w", err)
|
||||
}
|
||||
|
||||
for _, upload := range uploads {
|
||||
localPath := upload.LocalPath()
|
||||
if err := util.Remove(localPath); err != nil {
|
||||
// just continue, don't fail the whole operation if a file is missing (removed by others)
|
||||
log.Error("unable to remove upload file %s: %v", localPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUploadByUUID deletes a upload by UUID
|
||||
func DeleteUploadByUUID(ctx context.Context, uuid string) error {
|
||||
upload, err := GetUploadByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
if IsErrUploadNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("GetUploadByUUID: %w", err)
|
||||
}
|
||||
|
||||
if err := DeleteUploads(ctx, upload); err != nil {
|
||||
return fmt.Errorf("DeleteUpload: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
type StarredReposOptions struct {
|
||||
db.ListOptions
|
||||
StarrerID int64
|
||||
RepoOwnerID int64
|
||||
IncludePrivate bool
|
||||
}
|
||||
|
||||
func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
if publicOnly {
|
||||
opts.IncludePrivate = false
|
||||
}
|
||||
}
|
||||
|
||||
func (opts *StarredReposOptions) ToConds() builder.Cond {
|
||||
var cond builder.Cond = builder.Eq{
|
||||
"star.uid": opts.StarrerID,
|
||||
}
|
||||
if opts.RepoOwnerID != 0 {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.owner_id": opts.RepoOwnerID,
|
||||
})
|
||||
}
|
||||
if !opts.IncludePrivate {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.is_private": false,
|
||||
})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts *StarredReposOptions) ToJoins() []db.JoinFunc {
|
||||
return []db.JoinFunc{
|
||||
func(e db.Engine) error {
|
||||
e.Join("INNER", "star", "`repository`.id=`star`.repo_id")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetStarredRepos returns the repos starred by a particular user
|
||||
func GetStarredRepos(ctx context.Context, opts *StarredReposOptions) ([]*Repository, error) {
|
||||
return db.Find[Repository](ctx, opts)
|
||||
}
|
||||
|
||||
type WatchedReposOptions struct {
|
||||
db.ListOptions
|
||||
WatcherID int64
|
||||
RepoOwnerID int64
|
||||
IncludePrivate bool
|
||||
}
|
||||
|
||||
func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
if publicOnly {
|
||||
opts.IncludePrivate = false
|
||||
}
|
||||
}
|
||||
|
||||
func (opts *WatchedReposOptions) ToConds() builder.Cond {
|
||||
var cond builder.Cond = builder.Eq{
|
||||
"watch.user_id": opts.WatcherID,
|
||||
}
|
||||
if opts.RepoOwnerID != 0 {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.owner_id": opts.RepoOwnerID,
|
||||
})
|
||||
}
|
||||
if !opts.IncludePrivate {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.is_private": false,
|
||||
})
|
||||
}
|
||||
return cond.And(builder.Neq{
|
||||
"watch.mode": WatchModeDont,
|
||||
})
|
||||
}
|
||||
|
||||
func (opts *WatchedReposOptions) ToJoins() []db.JoinFunc {
|
||||
return []db.JoinFunc{
|
||||
func(e db.Engine) error {
|
||||
e.Join("INNER", "watch", "`repository`.id=`watch`.repo_id")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetWatchedRepos returns the repos watched by a particular user
|
||||
func GetWatchedRepos(ctx context.Context, opts *WatchedReposOptions) ([]*Repository, int64, error) {
|
||||
return db.FindAndCount[Repository](ctx, opts)
|
||||
}
|
||||
|
||||
// GetRepoAssignees returns all users that have write access and can be assigned to issues or pull-requests of the repository,
|
||||
func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.User, err error) {
|
||||
if err = repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e := db.GetEngine(ctx)
|
||||
userIDs := make([]int64, 0, 10)
|
||||
if err = e.Table("access").
|
||||
Where("repo_id = ? AND mode >= ?", repo.ID, perm.AccessModeWrite).
|
||||
Select("user_id").
|
||||
Find(&userIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uniqueUserIDs := make(container.Set[int64])
|
||||
uniqueUserIDs.AddMultiple(userIDs...)
|
||||
|
||||
if repo.Owner.IsOrganization() {
|
||||
// issues and pull requests both need "assignee list"
|
||||
additionalUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypeIssues, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uniqueUserIDs.AddMultiple(additionalUserIDs...)
|
||||
}
|
||||
|
||||
// Leave a seat for owner itself to append later, but if owner is an organization
|
||||
// and just waste 1 unit is cheaper than re-allocate memory once.
|
||||
users := make([]*user_model.User, 0, len(uniqueUserIDs)+1)
|
||||
if len(uniqueUserIDs) > 0 {
|
||||
if err = e.In("id", uniqueUserIDs.Values()).
|
||||
Where(builder.Eq{"`user`.is_active": true}).
|
||||
OrderBy(user_model.GetOrderByName()).
|
||||
Find(&users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !repo.Owner.IsOrganization() && !uniqueUserIDs.Contains(repo.OwnerID) {
|
||||
users = append(users, repo.Owner)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// GetIssuePostersWithSearch returns users with limit of 30 whose username started with prefix that have authored an issue/pull request for the given repository
|
||||
// It searches with the "user.name" and "user.full_name" fields case-insensitively.
|
||||
func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull bool, search string) ([]*user_model.User, error) {
|
||||
users := make([]*user_model.User, 0, 30)
|
||||
|
||||
cond := builder.In("`user`.id",
|
||||
builder.Select("poster_id").From("issue").Where(
|
||||
builder.Eq{"repo_id": repo.ID}.
|
||||
And(builder.Eq{"is_pull": isPull}),
|
||||
).GroupBy("poster_id"))
|
||||
|
||||
if search != "" {
|
||||
var prefixCond builder.Cond = builder.Like{"lower_name", strings.ToLower(search) + "%"}
|
||||
prefixCond = prefixCond.Or(db.BuildCaseInsensitiveLike("full_name", "%"+search+"%"))
|
||||
cond = cond.And(prefixCond)
|
||||
}
|
||||
|
||||
return users, db.GetEngine(ctx).
|
||||
Where(cond).
|
||||
Cols("id", "name", "full_name", "avatar", "avatar_email", "use_custom_avatar").
|
||||
OrderBy("name").
|
||||
Limit(30).
|
||||
Find(&users)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserRepo(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
t.Run("GetIssuePostersWithSearch", testUserRepoGetIssuePostersWithSearch)
|
||||
t.Run("Assignees", testUserRepoAssignees)
|
||||
t.Run("AssigneesNoTeamUnit", testRepoAssigneesNoTeamUnit)
|
||||
}
|
||||
|
||||
func testUserRepoAssignees(t *testing.T) {
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
users, err := repo_model.GetRepoAssignees(t.Context(), repo2)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, users, 1)
|
||||
assert.Equal(t, int64(2), users[0].ID)
|
||||
|
||||
repo21 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21})
|
||||
users, err = repo_model.GetRepoAssignees(t.Context(), repo21)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, users, 4) {
|
||||
assert.ElementsMatch(t, []int64{10, 15, 16, 18}, []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID})
|
||||
}
|
||||
|
||||
// do not return deactivated users
|
||||
assert.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: 15, IsActive: false}, "is_active"))
|
||||
users, err = repo_model.GetRepoAssignees(t.Context(), repo21)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, users, 3) {
|
||||
assert.NotContains(t, []int64{users[0].ID, users[1].ID, users[2].ID}, 15)
|
||||
}
|
||||
}
|
||||
|
||||
func testRepoAssigneesNoTeamUnit(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32})
|
||||
require.NoError(t, repo.LoadOwner(ctx))
|
||||
require.True(t, repo.Owner.IsOrganization())
|
||||
|
||||
require.NoError(t, db.TruncateBeans(ctx, &organization.Team{}, &organization.TeamUser{}, &organization.TeamRepo{}, &organization.TeamUnit{}, &access_model.Access{}))
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
team := &organization.Team{OrgID: repo.OwnerID, LowerName: "admin-team", AccessMode: perm_model.AccessModeAdmin}
|
||||
require.NoError(t, db.Insert(ctx, team))
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: repo.OwnerID, TeamID: team.ID, UID: user.ID}))
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamRepo{OrgID: repo.OwnerID, TeamID: team.ID, RepoID: repo.ID}))
|
||||
require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: repo.OwnerID, TeamID: team.ID, Type: unit.TypePullRequests, AccessMode: perm_model.AccessModeNone}))
|
||||
|
||||
users, err := repo_model.GetRepoAssignees(ctx, repo)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.ElementsMatch(t, []int64{4}, []int64{users[0].ID})
|
||||
}
|
||||
|
||||
func testUserRepoGetIssuePostersWithSearch(t *testing.T) {
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
|
||||
users, err := repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "USER")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, "user2", users[0].Name)
|
||||
|
||||
users, err = repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "TW%O")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, "user2", users[0].Name)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// WatchMode specifies what kind of watch the user has on a repository
|
||||
type WatchMode int8
|
||||
|
||||
const (
|
||||
// WatchModeNone don't watch
|
||||
WatchModeNone WatchMode = iota // 0
|
||||
// WatchModeNormal watch repository (from other sources)
|
||||
WatchModeNormal // 1
|
||||
// WatchModeDont explicit don't auto-watch
|
||||
WatchModeDont // 2
|
||||
// WatchModeAuto watch repository (from AutoWatchOnChanges)
|
||||
WatchModeAuto // 3
|
||||
)
|
||||
|
||||
// Watch is connection request for receiving repository notification.
|
||||
type Watch struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"UNIQUE(watch)"`
|
||||
RepoID int64 `xorm:"UNIQUE(watch)"`
|
||||
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Watch))
|
||||
}
|
||||
|
||||
// GetWatch gets what kind of subscription a user has on a given repository; returns dummy record if none found
|
||||
func GetWatch(ctx context.Context, userID, repoID int64) (Watch, error) {
|
||||
watch := Watch{UserID: userID, RepoID: repoID}
|
||||
has, err := db.GetEngine(ctx).Get(&watch)
|
||||
if err != nil {
|
||||
return watch, err
|
||||
}
|
||||
if !has {
|
||||
watch.Mode = WatchModeNone
|
||||
}
|
||||
return watch, nil
|
||||
}
|
||||
|
||||
// IsWatchMode Decodes watchability of WatchMode
|
||||
func IsWatchMode(mode WatchMode) bool {
|
||||
return mode != WatchModeNone && mode != WatchModeDont
|
||||
}
|
||||
|
||||
// IsWatching checks if user has watched given repository.
|
||||
func IsWatching(ctx context.Context, userID, repoID int64) bool {
|
||||
watch, err := GetWatch(ctx, userID, repoID)
|
||||
return err == nil && IsWatchMode(watch.Mode)
|
||||
}
|
||||
|
||||
func watchRepoMode(ctx context.Context, watch Watch, mode WatchMode) (err error) {
|
||||
if watch.Mode == mode {
|
||||
return nil
|
||||
}
|
||||
if mode == WatchModeAuto && (watch.Mode == WatchModeDont || IsWatchMode(watch.Mode)) {
|
||||
// Don't auto watch if already watching or deliberately not watching
|
||||
return nil
|
||||
}
|
||||
|
||||
hadrec := watch.Mode != WatchModeNone
|
||||
needsrec := mode != WatchModeNone
|
||||
repodiff := 0
|
||||
|
||||
if IsWatchMode(mode) && !IsWatchMode(watch.Mode) {
|
||||
repodiff = 1
|
||||
} else if !IsWatchMode(mode) && IsWatchMode(watch.Mode) {
|
||||
repodiff = -1
|
||||
}
|
||||
|
||||
watch.Mode = mode
|
||||
|
||||
if !hadrec && needsrec {
|
||||
watch.Mode = mode
|
||||
if err = db.Insert(ctx, watch); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if needsrec {
|
||||
watch.Mode = mode
|
||||
if _, err := db.GetEngine(ctx).ID(watch.ID).AllCols().Update(watch); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err = db.DeleteByID[Watch](ctx, watch.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if repodiff != 0 {
|
||||
_, err = db.GetEngine(ctx).Exec("UPDATE `repository` SET num_watches = num_watches + ? WHERE id = ?", repodiff, watch.RepoID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// WatchRepo watch or unwatch repository.
|
||||
func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doWatch bool) error {
|
||||
watch, err := GetWatch(ctx, doer.ID, repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !doWatch && watch.Mode == WatchModeAuto {
|
||||
return watchRepoMode(ctx, watch, WatchModeDont)
|
||||
} else if !doWatch {
|
||||
return watchRepoMode(ctx, watch, WatchModeNone)
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
|
||||
return watchRepoMode(ctx, watch, WatchModeNormal)
|
||||
}
|
||||
|
||||
// GetWatchers returns all watchers of given repository.
|
||||
func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
|
||||
watches := make([]*Watch, 0, 10)
|
||||
return watches, db.GetEngine(ctx).Where("`watch`.repo_id=?", repoID).
|
||||
And("`watch`.mode<>?", WatchModeDont).
|
||||
And("`user`.is_active=?", true).
|
||||
And("`user`.prohibit_login=?", false).
|
||||
Join("INNER", "`user`", "`user`.id = `watch`.user_id").
|
||||
Find(&watches)
|
||||
}
|
||||
|
||||
// GetRepoWatchersIDs returns IDs of watchers for a given repo ID
|
||||
// but avoids joining with `user` for performance reasons
|
||||
// User permissions must be verified elsewhere if required
|
||||
func GetRepoWatchersIDs(ctx context.Context, repoID int64) ([]int64, error) {
|
||||
ids := make([]int64, 0, 64)
|
||||
return ids, db.GetEngine(ctx).Table("watch").
|
||||
Where("watch.repo_id=?", repoID).
|
||||
And("watch.mode<>?", WatchModeDont).
|
||||
Select("user_id").
|
||||
Find(&ids)
|
||||
}
|
||||
|
||||
// GetRepoWatchers returns range of users watching given repository.
|
||||
func GetRepoWatchers(ctx context.Context, repoID int64, opts db.ListOptions) ([]*user_model.User, error) {
|
||||
sess := db.GetEngine(ctx).Where("watch.repo_id=?", repoID).
|
||||
Join("LEFT", "watch", "`user`.id=`watch`.user_id").
|
||||
And("`watch`.mode<>?", WatchModeDont)
|
||||
if opts.Page > 0 {
|
||||
db.SetSessionPagination(sess, &opts)
|
||||
users := make([]*user_model.User, 0, opts.PageSize)
|
||||
|
||||
return users, sess.Find(&users)
|
||||
}
|
||||
|
||||
users := make([]*user_model.User, 0, 8)
|
||||
return users, sess.Find(&users)
|
||||
}
|
||||
|
||||
// WatchIfAuto subscribes to repo if AutoWatchOnChanges is set
|
||||
func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error {
|
||||
if !isWrite || !setting.Service.AutoWatchOnChanges {
|
||||
return nil
|
||||
}
|
||||
watch, err := GetWatch(ctx, userID, repoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if watch.Mode != WatchModeNone {
|
||||
return nil
|
||||
}
|
||||
return watchRepoMode(ctx, watch, WatchModeAuto)
|
||||
}
|
||||
|
||||
// ClearRepoWatches clears all watches for a repository and from the user that watched it.
|
||||
// Used when a repository is set to private.
|
||||
func ClearRepoWatches(ctx context.Context, repoID int64) error {
|
||||
if _, err := db.Exec(ctx, "UPDATE `repository` SET num_watches = 0 WHERE id = ?", repoID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.DeleteBeans(ctx, Watch{RepoID: repoID})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsWatching(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
assert.True(t, repo_model.IsWatching(t.Context(), 1, 1))
|
||||
assert.True(t, repo_model.IsWatching(t.Context(), 4, 1))
|
||||
assert.True(t, repo_model.IsWatching(t.Context(), 11, 1))
|
||||
|
||||
assert.False(t, repo_model.IsWatching(t.Context(), 1, 5))
|
||||
assert.False(t, repo_model.IsWatching(t.Context(), 8, 1))
|
||||
assert.False(t, repo_model.IsWatching(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
|
||||
}
|
||||
|
||||
func TestGetWatchers(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
|
||||
assert.NoError(t, err)
|
||||
// One watchers are inactive, thus minus 1
|
||||
assert.Len(t, watches, repo.NumWatches-1)
|
||||
for _, watch := range watches {
|
||||
assert.Equal(t, repo.ID, watch.RepoID)
|
||||
}
|
||||
|
||||
watches, err = repo_model.GetWatchers(t.Context(), unittest.NonexistentID)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, watches)
|
||||
}
|
||||
|
||||
func TestRepository_GetWatchers(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
watchers, err := repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, repo.NumWatches)
|
||||
for _, watcher := range watchers {
|
||||
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: watcher.ID, RepoID: repo.ID})
|
||||
}
|
||||
|
||||
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 9})
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, watchers)
|
||||
}
|
||||
|
||||
func TestWatchIfAuto(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
user12 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 12})
|
||||
|
||||
watchers, err := repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, repo.NumWatches)
|
||||
|
||||
setting.Service.AutoWatchOnChanges = false
|
||||
|
||||
prevCount := repo.NumWatches
|
||||
|
||||
// Must not add watch
|
||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, prevCount)
|
||||
|
||||
// Should not add watch
|
||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 10, 1, true))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, prevCount)
|
||||
|
||||
setting.Service.AutoWatchOnChanges = true
|
||||
|
||||
// Must not add watch
|
||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, prevCount)
|
||||
|
||||
// Should not add watch
|
||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, prevCount)
|
||||
|
||||
// Should add watch
|
||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, prevCount+1)
|
||||
|
||||
// Should remove watch, inhibit from adding auto
|
||||
assert.NoError(t, repo_model.WatchRepo(t.Context(), user12, repo, false))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, prevCount)
|
||||
|
||||
// Must not add watch
|
||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, prevCount)
|
||||
}
|
||||
|
||||
func TestClearRepoWatches(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const repoID int64 = 1
|
||||
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repoID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, watchers)
|
||||
|
||||
assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID))
|
||||
|
||||
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repoID)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, watchers)
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
assert.Zero(t, repo.NumWatches)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrWikiAlreadyExist represents a "WikiAlreadyExist" kind of error.
|
||||
type ErrWikiAlreadyExist struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
// IsErrWikiAlreadyExist checks if an error is an ErrWikiAlreadyExist.
|
||||
func IsErrWikiAlreadyExist(err error) bool {
|
||||
_, ok := err.(ErrWikiAlreadyExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrWikiAlreadyExist) Error() string {
|
||||
return fmt.Sprintf("wiki page already exists [title: %s]", err.Title)
|
||||
}
|
||||
|
||||
func (err ErrWikiAlreadyExist) Unwrap() error {
|
||||
return util.ErrAlreadyExist
|
||||
}
|
||||
|
||||
// ErrWikiReservedName represents a reserved name error.
|
||||
type ErrWikiReservedName struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
// IsErrWikiReservedName checks if an error is an ErrWikiReservedName.
|
||||
func IsErrWikiReservedName(err error) bool {
|
||||
_, ok := err.(ErrWikiReservedName)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrWikiReservedName) Error() string {
|
||||
return "wiki title is reserved: " + err.Title
|
||||
}
|
||||
|
||||
func (err ErrWikiReservedName) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// ErrWikiInvalidFileName represents an invalid wiki file name.
|
||||
type ErrWikiInvalidFileName struct {
|
||||
FileName string
|
||||
}
|
||||
|
||||
// IsErrWikiInvalidFileName checks if an error is an ErrWikiInvalidFileName.
|
||||
func IsErrWikiInvalidFileName(err error) bool {
|
||||
_, ok := err.(ErrWikiInvalidFileName)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrWikiInvalidFileName) Error() string {
|
||||
return "Invalid wiki filename: " + err.FileName
|
||||
}
|
||||
|
||||
func (err ErrWikiInvalidFileName) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// WikiCloneLink returns clone URLs of repository wiki.
|
||||
func (repo *Repository) WikiCloneLink(ctx context.Context, doer *user_model.User) *CloneLink {
|
||||
return repo.cloneLink(ctx, doer, repo.Name+".wiki")
|
||||
}
|
||||
|
||||
func RelativeWikiPath(ownerName, repoName string) string {
|
||||
return strings.ToLower(ownerName) + "/" + strings.ToLower(repoName) + ".wiki.git"
|
||||
}
|
||||
|
||||
// WikiStorageRepo returns the storage repo for the wiki
|
||||
// The wiki repository should have the same object format as the code repository
|
||||
func (repo *Repository) WikiStorageRepo() StorageRepo {
|
||||
return StorageRepo(RelativeWikiPath(repo.OwnerName, repo.Name))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_WikiCloneLink(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
cloneLink := repo.WikiCloneLink(t.Context(), nil)
|
||||
assert.Equal(t, "ssh://sshuser@try.gitea.io:3000/user2/repo1.wiki.git", cloneLink.SSH)
|
||||
assert.Equal(t, "https://try.gitea.io/user2/repo1.wiki.git", cloneLink.HTTPS)
|
||||
}
|
||||
|
||||
func TestRepository_RelativeWikiPath(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
assert.Equal(t, "user2/repo1.wiki.git", repo_model.RelativeWikiPath(repo.OwnerName, repo.Name))
|
||||
assert.Equal(t, "user2/repo1.wiki.git", repo.WikiStorageRepo().RelativePath())
|
||||
}
|
||||
Reference in New Issue
Block a user