mirror of
https://github.com/go-gitea/gitea
synced 2025-12-07 13:28:25 +00:00
Merge branch 'main' into allow-force-push-protected-branches
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type actionsClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Scp string `json:"scp"`
|
||||
TaskID int64
|
||||
RunID int64
|
||||
JobID int64
|
||||
}
|
||||
|
||||
func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
claims := actionsClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
Scp: fmt.Sprintf("Actions.Results:%d:%d", runID, jobID),
|
||||
TaskID: taskID,
|
||||
RunID: runID,
|
||||
JobID: jobID,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
tokenString, err := token.SignedString([]byte(setting.SecretKey))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func ParseAuthorizationToken(req *http.Request) (int64, error) {
|
||||
h := req.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
log.Error("split token failed: %s", h)
|
||||
return 0, fmt.Errorf("split token failed")
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(parts[1], &actionsClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(setting.SecretKey), nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
c, ok := token.Claims.(*actionsClaims)
|
||||
if !token.Valid || !ok {
|
||||
return 0, fmt.Errorf("invalid token claim")
|
||||
}
|
||||
|
||||
return c.TaskID, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateAuthorizationToken(t *testing.T) {
|
||||
var taskID int64 = 23
|
||||
token, err := CreateAuthorizationToken(taskID, 1, 2)
|
||||
assert.Nil(t, err)
|
||||
assert.NotEqual(t, "", token)
|
||||
claims := jwt.MapClaims{}
|
||||
_, err = jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(setting.SecretKey), nil
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
scp, ok := claims["scp"]
|
||||
assert.True(t, ok, "Has scp claim in jwt token")
|
||||
assert.Contains(t, scp, "Actions.Results:1:2")
|
||||
taskIDClaim, ok := claims["TaskID"]
|
||||
assert.True(t, ok, "Has TaskID claim in jwt token")
|
||||
assert.Equal(t, float64(taskID), taskIDClaim, "Supplied taskid must match stored one")
|
||||
}
|
||||
|
||||
func TestParseAuthorizationToken(t *testing.T) {
|
||||
var taskID int64 = 23
|
||||
token, err := CreateAuthorizationToken(taskID, 1, 2)
|
||||
assert.Nil(t, err)
|
||||
assert.NotEqual(t, "", token)
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
rTaskID, err := ParseAuthorizationToken(&http.Request{
|
||||
Header: headers,
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, taskID, rTaskID)
|
||||
}
|
||||
|
||||
func TestParseAuthorizationTokenNoAuthHeader(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
rTaskID, err := ParseAuthorizationToken(&http.Request{
|
||||
Header: headers,
|
||||
})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, int64(0), rTaskID)
|
||||
}
|
||||
@@ -157,10 +157,11 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
|
||||
var detectedWorkflows []*actions_module.DetectedWorkflow
|
||||
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
|
||||
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && git.RefName(input.Ref).BranchName() == input.Repo.DefaultBranch
|
||||
workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit,
|
||||
input.Event,
|
||||
input.Payload,
|
||||
input.Event == webhook_module.HookEventPush && git.RefName(input.Ref).BranchName() == input.Repo.DefaultBranch,
|
||||
shouldDetectSchedules,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DetectWorkflows: %w", err)
|
||||
@@ -207,8 +208,10 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := handleSchedules(ctx, schedules, commit, input, ref); err != nil {
|
||||
return err
|
||||
if shouldDetectSchedules {
|
||||
if err := handleSchedules(ctx, schedules, commit, input, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return handleWorkflows(ctx, detectedWorkflows, commit, input, ref)
|
||||
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"code.gitea.io/gitea/modules/auth/webauthn"
|
||||
gitea_context "code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/session"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
)
|
||||
|
||||
// Init should be called exactly once when the application starts to allow plugins
|
||||
@@ -85,8 +87,10 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore
|
||||
// If the user does not have a locale set, we save the current one.
|
||||
if len(user.Language) == 0 {
|
||||
lc := middleware.Locale(resp, req)
|
||||
user.Language = lc.Language()
|
||||
if err := user_model.UpdateUserCols(req.Context(), user, "language"); err != nil {
|
||||
opts := &user_service.UpdateOptions{
|
||||
Language: optional.Some(lc.Language()),
|
||||
}
|
||||
if err := user_service.UpdateUser(req.Context(), user, opts); err != nil {
|
||||
log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", user.ID, user.Language))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"code.gitea.io/gitea/models/auth"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
auth_module "code.gitea.io/gitea/modules/auth"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
source_service "code.gitea.io/gitea/services/auth/source"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
@@ -49,20 +50,17 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
|
||||
}
|
||||
}
|
||||
if user != nil && !user.ProhibitLogin {
|
||||
cols := make([]string, 0)
|
||||
opts := &user_service.UpdateOptions{}
|
||||
if len(source.AdminFilter) > 0 && user.IsAdmin != sr.IsAdmin {
|
||||
// Change existing admin flag only if AdminFilter option is set
|
||||
user.IsAdmin = sr.IsAdmin
|
||||
cols = append(cols, "is_admin")
|
||||
opts.IsAdmin = optional.Some(sr.IsAdmin)
|
||||
}
|
||||
if !user.IsAdmin && len(source.RestrictedFilter) > 0 && user.IsRestricted != sr.IsRestricted {
|
||||
if !sr.IsAdmin && len(source.RestrictedFilter) > 0 && user.IsRestricted != sr.IsRestricted {
|
||||
// Change existing restricted flag only if RestrictedFilter option is set
|
||||
user.IsRestricted = sr.IsRestricted
|
||||
cols = append(cols, "is_restricted")
|
||||
opts.IsRestricted = optional.Some(sr.IsRestricted)
|
||||
}
|
||||
if len(cols) > 0 {
|
||||
err = user_model.UpdateUserCols(ctx, user, cols...)
|
||||
if err != nil {
|
||||
if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
|
||||
if err := user_service.UpdateUser(ctx, user, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
auth_module "code.gitea.io/gitea/modules/auth"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
source_service "code.gitea.io/gitea/services/auth/source"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
@@ -158,23 +159,25 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
|
||||
|
||||
log.Trace("SyncExternalUsers[%s]: Updating user %s", source.authSource.Name, usr.Name)
|
||||
|
||||
usr.FullName = fullName
|
||||
emailChanged := usr.Email != su.Mail
|
||||
usr.Email = su.Mail
|
||||
// Change existing admin flag only if AdminFilter option is set
|
||||
if len(source.AdminFilter) > 0 {
|
||||
usr.IsAdmin = su.IsAdmin
|
||||
opts := &user_service.UpdateOptions{
|
||||
FullName: optional.Some(fullName),
|
||||
IsActive: optional.Some(true),
|
||||
}
|
||||
if source.AdminFilter != "" {
|
||||
opts.IsAdmin = optional.Some(su.IsAdmin)
|
||||
}
|
||||
// Change existing restricted flag only if RestrictedFilter option is set
|
||||
if !usr.IsAdmin && len(source.RestrictedFilter) > 0 {
|
||||
usr.IsRestricted = su.IsRestricted
|
||||
if !su.IsAdmin && source.RestrictedFilter != "" {
|
||||
opts.IsRestricted = optional.Some(su.IsRestricted)
|
||||
}
|
||||
usr.IsActive = true
|
||||
|
||||
err = user_model.UpdateUser(ctx, usr, emailChanged, "full_name", "email", "is_admin", "is_restricted", "is_active")
|
||||
if err != nil {
|
||||
if err := user_service.UpdateUser(ctx, usr, opts); err != nil {
|
||||
log.Error("SyncExternalUsers[%s]: Error updating user %s: %v", source.authSource.Name, usr.Name, err)
|
||||
}
|
||||
|
||||
if err := user_service.ReplacePrimaryEmailAddress(ctx, usr, su.Mail); err != nil {
|
||||
log.Error("SyncExternalUsers[%s]: Error updating user %s primary email %s: %v", source.authSource.Name, usr.Name, su.Mail, err)
|
||||
}
|
||||
}
|
||||
|
||||
if usr.IsUploadAvatarChanged(su.Avatar) {
|
||||
@@ -215,9 +218,10 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
|
||||
|
||||
log.Trace("SyncExternalUsers[%s]: Deactivating user %s", source.authSource.Name, usr.Name)
|
||||
|
||||
usr.IsActive = false
|
||||
err = user_model.UpdateUserCols(ctx, usr, "is_active")
|
||||
if err != nil {
|
||||
opts := &user_service.UpdateOptions{
|
||||
IsActive: optional.Some(false),
|
||||
}
|
||||
if err := user_service.UpdateUser(ctx, usr, opts); err != nil {
|
||||
log.Error("SyncExternalUsers[%s]: Error deactivating user %s: %v", source.authSource.Name, usr.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/generate"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
@@ -336,7 +337,7 @@ func InitSigningKey() error {
|
||||
// loadSymmetricKey checks if the configured secret is valid.
|
||||
// If it is not valid, it will return an error.
|
||||
func loadSymmetricKey() (any, error) {
|
||||
return util.Base64FixedDecode(base64.RawURLEncoding, []byte(setting.OAuth2.JWTSecretBase64), 32)
|
||||
return generate.DecodeJwtSecretBase64(setting.OAuth2.JWTSecretBase64)
|
||||
}
|
||||
|
||||
// loadOrCreateAsymmetricKey checks if the configured private key exists.
|
||||
|
||||
@@ -98,7 +98,8 @@ func toIssue(ctx context.Context, issue *issues_model.Issue, getDownloadURL func
|
||||
}
|
||||
if issue.PullRequest != nil {
|
||||
apiIssue.PullRequest = &api.PullRequestMeta{
|
||||
HasMerged: issue.PullRequest.HasMerged,
|
||||
HasMerged: issue.PullRequest.HasMerged,
|
||||
IsWorkInProgress: issue.PullRequest.IsWorkInProgress(ctx),
|
||||
}
|
||||
if issue.PullRequest.HasMerged {
|
||||
apiIssue.PullRequest.Merged = issue.PullRequest.MergedUnix.AsTimePtr()
|
||||
|
||||
@@ -93,6 +93,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
|
||||
allowRebase := false
|
||||
allowRebaseMerge := false
|
||||
allowSquash := false
|
||||
allowFastForwardOnly := false
|
||||
allowRebaseUpdate := false
|
||||
defaultDeleteBranchAfterMerge := false
|
||||
defaultMergeStyle := repo_model.MergeStyleMerge
|
||||
@@ -105,6 +106,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
|
||||
allowRebase = config.AllowRebase
|
||||
allowRebaseMerge = config.AllowRebaseMerge
|
||||
allowSquash = config.AllowSquash
|
||||
allowFastForwardOnly = config.AllowFastForwardOnly
|
||||
allowRebaseUpdate = config.AllowRebaseUpdate
|
||||
defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge
|
||||
defaultMergeStyle = config.GetDefaultMergeStyle()
|
||||
@@ -219,6 +221,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
|
||||
AllowRebase: allowRebase,
|
||||
AllowRebaseMerge: allowRebaseMerge,
|
||||
AllowSquash: allowSquash,
|
||||
AllowFastForwardOnly: allowFastForwardOnly,
|
||||
AllowRebaseUpdate: allowRebaseUpdate,
|
||||
DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge,
|
||||
DefaultMergeStyle: string(defaultMergeStyle),
|
||||
|
||||
@@ -70,7 +70,7 @@ func (b *BaseConfig) DoNoticeOnSuccess() bool {
|
||||
// Please note the `status` string will be concatenated with `admin.dashboard.cron.` and `admin.dashboard.task.` to provide locale messages. Similarly `name` will be composed with `admin.dashboard.` to provide the locale name for the task.
|
||||
func (b *BaseConfig) FormatMessage(locale translation.Locale, name, status, doer string, args ...any) string {
|
||||
realArgs := make([]any, 0, len(args)+2)
|
||||
realArgs = append(realArgs, locale.Tr("admin.dashboard."+name))
|
||||
realArgs = append(realArgs, locale.TrString("admin.dashboard."+name))
|
||||
if doer == "" {
|
||||
realArgs = append(realArgs, "(Cron)")
|
||||
} else {
|
||||
@@ -80,7 +80,7 @@ func (b *BaseConfig) FormatMessage(locale translation.Locale, name, status, doer
|
||||
realArgs = append(realArgs, args...)
|
||||
}
|
||||
if doer == "" {
|
||||
return locale.Tr("admin.dashboard.cron."+status, realArgs...)
|
||||
return locale.TrString("admin.dashboard.cron."+status, realArgs...)
|
||||
}
|
||||
return locale.Tr("admin.dashboard.task."+status, realArgs...)
|
||||
return locale.TrString("admin.dashboard.task."+status, realArgs...)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func RegisterTask(name string, config Config, fun func(context.Context, *user_mo
|
||||
log.Debug("Registering task: %s", name)
|
||||
|
||||
i18nKey := "admin.dashboard." + name
|
||||
if value := translation.NewLocale("en-US").Tr(i18nKey); value == i18nKey {
|
||||
if value := translation.NewLocale("en-US").TrString(i18nKey); value == i18nKey {
|
||||
return fmt.Errorf("translation is missing for task %q, please add translation for %q", name, i18nKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ type RepoSettingForm struct {
|
||||
PullsAllowRebase bool
|
||||
PullsAllowRebaseMerge bool
|
||||
PullsAllowSquash bool
|
||||
PullsAllowFastForwardOnly bool
|
||||
PullsAllowManualMerge bool
|
||||
PullsDefaultMergeStyle string
|
||||
EnableAutodetectManualMerge bool
|
||||
@@ -317,7 +318,7 @@ func (f *NewSlackHookForm) Validate(req *http.Request, errs binding.Errors) bind
|
||||
errs = append(errs, binding.Error{
|
||||
FieldNames: []string{"Channel"},
|
||||
Classification: "",
|
||||
Message: ctx.Tr("repo.settings.add_webhook.invalid_channel_name"),
|
||||
Message: ctx.Locale.TrString("repo.settings.add_webhook.invalid_channel_name"),
|
||||
})
|
||||
}
|
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||
@@ -602,8 +603,8 @@ func (f *InitializeLabelsForm) Validate(req *http.Request, errs binding.Errors)
|
||||
// swagger:model MergePullRequestOption
|
||||
type MergePullRequestForm struct {
|
||||
// required: true
|
||||
// enum: merge,rebase,rebase-merge,squash,manually-merged
|
||||
Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,manually-merged)"`
|
||||
// enum: merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged
|
||||
Do string `binding:"Required;In(merge,rebase,rebase-merge,squash,fast-forward-only,manually-merged)"`
|
||||
MergeTitleField string
|
||||
MergeMessageField string
|
||||
MergeCommitID string // only used for manually-merged
|
||||
|
||||
+16
-14
@@ -109,21 +109,23 @@ func IsTemplateConfig(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetTemplatesFromDefaultBranch checks for issue templates in the repo's default branch,
|
||||
// returns valid templates and the errors of invalid template files.
|
||||
func GetTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) ([]*api.IssueTemplate, map[string]error) {
|
||||
var issueTemplates []*api.IssueTemplate
|
||||
|
||||
// ParseTemplatesFromDefaultBranch parses the issue templates in the repo's default branch,
|
||||
// returns valid templates and the errors of invalid template files (the errors map is guaranteed to be non-nil).
|
||||
func ParseTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repository) (ret struct {
|
||||
IssueTemplates []*api.IssueTemplate
|
||||
TemplateErrors map[string]error
|
||||
},
|
||||
) {
|
||||
ret.TemplateErrors = map[string]error{}
|
||||
if repo.IsEmpty {
|
||||
return issueTemplates, nil
|
||||
return ret
|
||||
}
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if err != nil {
|
||||
return issueTemplates, nil
|
||||
return ret
|
||||
}
|
||||
|
||||
invalidFiles := map[string]error{}
|
||||
for _, dirName := range templateDirCandidates {
|
||||
tree, err := commit.SubTree(dirName)
|
||||
if err != nil {
|
||||
@@ -133,7 +135,7 @@ func GetTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repositor
|
||||
entries, err := tree.ListEntries()
|
||||
if err != nil {
|
||||
log.Debug("list entries in %s: %v", dirName, err)
|
||||
return issueTemplates, nil
|
||||
return ret
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !template.CouldBe(entry.Name()) {
|
||||
@@ -141,16 +143,16 @@ func GetTemplatesFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repositor
|
||||
}
|
||||
fullName := path.Join(dirName, entry.Name())
|
||||
if it, err := template.UnmarshalFromEntry(entry, dirName); err != nil {
|
||||
invalidFiles[fullName] = err
|
||||
ret.TemplateErrors[fullName] = err
|
||||
} else {
|
||||
if !strings.HasPrefix(it.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref>
|
||||
it.Ref = git.BranchPrefix + it.Ref
|
||||
}
|
||||
issueTemplates = append(issueTemplates, it)
|
||||
ret.IssueTemplates = append(ret.IssueTemplates, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
return issueTemplates, invalidFiles
|
||||
return ret
|
||||
}
|
||||
|
||||
// GetTemplateConfigFromDefaultBranch returns the issue config for this repo.
|
||||
@@ -179,8 +181,8 @@ func GetTemplateConfigFromDefaultBranch(repo *repo.Repository, gitRepo *git.Repo
|
||||
}
|
||||
|
||||
func HasTemplatesOrContactLinks(repo *repo.Repository, gitRepo *git.Repository) bool {
|
||||
ret, _ := GetTemplatesFromDefaultBranch(repo, gitRepo)
|
||||
if len(ret) > 0 {
|
||||
ret := ParseTemplatesFromDefaultBranch(repo, gitRepo)
|
||||
if len(ret.IssueTemplates) > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+19
-13
@@ -94,7 +94,7 @@ func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
|
||||
// No mail service configured
|
||||
return
|
||||
}
|
||||
sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.Tr("mail.activate_account"), "activate account")
|
||||
sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account")
|
||||
}
|
||||
|
||||
// SendResetPasswordMail sends a password reset mail to the user
|
||||
@@ -104,11 +104,11 @@ func SendResetPasswordMail(u *user_model.User) {
|
||||
return
|
||||
}
|
||||
locale := translation.NewLocale(u.Language)
|
||||
sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.Tr("mail.reset_password"), "recover account")
|
||||
sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account")
|
||||
}
|
||||
|
||||
// SendActivateEmailMail sends confirmation email to confirm new email address
|
||||
func SendActivateEmailMail(u *user_model.User, email *user_model.EmailAddress) {
|
||||
func SendActivateEmailMail(u *user_model.User, email string) {
|
||||
if setting.MailService == nil {
|
||||
// No mail service configured
|
||||
return
|
||||
@@ -118,8 +118,8 @@ func SendActivateEmailMail(u *user_model.User, email *user_model.EmailAddress) {
|
||||
"locale": locale,
|
||||
"DisplayName": u.DisplayName(),
|
||||
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
|
||||
"Code": u.GenerateEmailActivateCode(email.Email),
|
||||
"Email": email.Email,
|
||||
"Code": u.GenerateEmailActivateCode(email),
|
||||
"Email": email,
|
||||
"Language": locale.Language(),
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func SendActivateEmailMail(u *user_model.User, email *user_model.EmailAddress) {
|
||||
return
|
||||
}
|
||||
|
||||
msg := NewMessage(email.Email, locale.Tr("mail.activate_email"), content.String())
|
||||
msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
@@ -158,7 +158,7 @@ func SendRegisterNotifyMail(u *user_model.User) {
|
||||
return
|
||||
}
|
||||
|
||||
msg := NewMessage(u.Email, locale.Tr("mail.register_notify"), content.String())
|
||||
msg := NewMessage(u.Email, locale.TrString("mail.register_notify"), content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
@@ -173,7 +173,7 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository)
|
||||
locale := translation.NewLocale(u.Language)
|
||||
repoName := repo.FullName()
|
||||
|
||||
subject := locale.Tr("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
|
||||
subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
|
||||
data := map[string]any{
|
||||
"locale": locale,
|
||||
"Subject": subject,
|
||||
@@ -310,7 +310,13 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient
|
||||
|
||||
msgs := make([]*Message, 0, len(recipients))
|
||||
for _, recipient := range recipients {
|
||||
msg := NewMessageFrom(recipient.Email, ctx.Doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String())
|
||||
msg := NewMessageFrom(
|
||||
recipient.Email,
|
||||
ctx.Doer.GetCompleteName(),
|
||||
setting.MailService.FromEmail,
|
||||
subject,
|
||||
mailBody.String(),
|
||||
)
|
||||
msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
|
||||
|
||||
msg.SetHeader("Message-ID", msgID)
|
||||
@@ -394,8 +400,8 @@ func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient
|
||||
|
||||
"X-Mailer": "Gitea",
|
||||
"X-Gitea-Reason": reason,
|
||||
"X-Gitea-Sender": ctx.Doer.DisplayName(),
|
||||
"X-Gitea-Recipient": recipient.DisplayName(),
|
||||
"X-Gitea-Sender": ctx.Doer.Name,
|
||||
"X-Gitea-Recipient": recipient.Name,
|
||||
"X-Gitea-Recipient-Address": recipient.Email,
|
||||
"X-Gitea-Repository": repo.Name,
|
||||
"X-Gitea-Repository-Path": repo.FullName(),
|
||||
@@ -404,8 +410,8 @@ func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient
|
||||
"X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
|
||||
|
||||
"X-GitHub-Reason": reason,
|
||||
"X-GitHub-Sender": ctx.Doer.DisplayName(),
|
||||
"X-GitHub-Recipient": recipient.DisplayName(),
|
||||
"X-GitHub-Sender": ctx.Doer.Name,
|
||||
"X-GitHub-Recipient": recipient.Name,
|
||||
"X-GitHub-Recipient-Address": recipient.Email,
|
||||
|
||||
"X-GitLab-NotificationReason": reason,
|
||||
|
||||
@@ -68,12 +68,13 @@ func mailNewRelease(ctx context.Context, lang string, tos []string, rel *repo_mo
|
||||
return
|
||||
}
|
||||
|
||||
subject := locale.Tr("mail.release.new.subject", rel.TagName, rel.Repo.FullName())
|
||||
subject := locale.TrString("mail.release.new.subject", rel.TagName, rel.Repo.FullName())
|
||||
mailMeta := map[string]any{
|
||||
"locale": locale,
|
||||
"Release": rel,
|
||||
"Subject": subject,
|
||||
"Language": locale.Language(),
|
||||
"Link": rel.HTMLURL(),
|
||||
}
|
||||
|
||||
var mailBody bytes.Buffer
|
||||
|
||||
@@ -56,11 +56,11 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
|
||||
content bytes.Buffer
|
||||
)
|
||||
|
||||
destination := locale.Tr("mail.repo.transfer.to_you")
|
||||
subject := locale.Tr("mail.repo.transfer.subject_to_you", doer.DisplayName(), repo.FullName())
|
||||
destination := locale.TrString("mail.repo.transfer.to_you")
|
||||
subject := locale.TrString("mail.repo.transfer.subject_to_you", doer.DisplayName(), repo.FullName())
|
||||
if newOwner.IsOrganization() {
|
||||
destination = newOwner.DisplayName()
|
||||
subject = locale.Tr("mail.repo.transfer.subject_to", doer.DisplayName(), repo.FullName(), destination)
|
||||
subject = locale.TrString("mail.repo.transfer.subject_to", doer.DisplayName(), repo.FullName(), destination)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
|
||||
@@ -50,7 +50,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
|
||||
inviteURL = fmt.Sprintf("%suser/login?redirect_to=%s", setting.AppURL, inviteRedirect)
|
||||
}
|
||||
|
||||
subject := locale.Tr("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName())
|
||||
subject := locale.TrString("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName())
|
||||
mailMeta := map[string]any{
|
||||
"locale": locale,
|
||||
"Inviter": inviter,
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestGenerateAdditionalHeaders(t *testing.T) {
|
||||
doer, _, issue, _ := prepareMailerTest(t)
|
||||
|
||||
ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer}
|
||||
recipient := &user_model.User{Name: "Test", Email: "test@gitea.com"}
|
||||
recipient := &user_model.User{Name: "test", Email: "test@gitea.com"}
|
||||
|
||||
headers := generateAdditionalHeaders(ctx, "dummy-reason", recipient)
|
||||
|
||||
@@ -247,8 +247,8 @@ func TestGenerateAdditionalHeaders(t *testing.T) {
|
||||
"List-ID": "user2/repo1 <repo1.user2.localhost>",
|
||||
"List-Archive": "<https://try.gitea.io/user2/repo1>",
|
||||
"X-Gitea-Reason": "dummy-reason",
|
||||
"X-Gitea-Sender": "< U<se>r Tw<o > ><",
|
||||
"X-Gitea-Recipient": "Test",
|
||||
"X-Gitea-Sender": "user2",
|
||||
"X-Gitea-Recipient": "test",
|
||||
"X-Gitea-Recipient-Address": "test@gitea.com",
|
||||
"X-Gitea-Repository": "repo1",
|
||||
"X-Gitea-Repository-Path": "user2/repo1",
|
||||
|
||||
@@ -114,7 +114,7 @@ func (m *mailNotifier) PullRequestCodeComment(ctx context.Context, pr *issues_mo
|
||||
|
||||
func (m *mailNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) {
|
||||
// mail only sent to added assignees and not self-assignee
|
||||
if !removed && doer.ID != assignee.ID && assignee.EmailNotifications() != user_model.EmailNotificationsDisabled {
|
||||
if !removed && doer.ID != assignee.ID && assignee.EmailNotificationsPreference != user_model.EmailNotificationsDisabled {
|
||||
ct := fmt.Sprintf("Assigned #%d.", issue.Index)
|
||||
if err := SendIssueAssignedMail(ctx, issue, doer, ct, comment, []*user_model.User{assignee}); err != nil {
|
||||
log.Error("Error in SendIssueAssignedMail for issue[%d] to assignee[%d]: %v", issue.ID, assignee.ID, err)
|
||||
@@ -123,7 +123,7 @@ func (m *mailNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model
|
||||
}
|
||||
|
||||
func (m *mailNotifier) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) {
|
||||
if isRequest && doer.ID != reviewer.ID && reviewer.EmailNotifications() != user_model.EmailNotificationsDisabled {
|
||||
if isRequest && doer.ID != reviewer.ID && reviewer.EmailNotificationsPreference != user_model.EmailNotificationsDisabled {
|
||||
ct := fmt.Sprintf("Requested to review %s.", issue.HTMLURL())
|
||||
if err := SendIssueAssignedMail(ctx, issue, doer, ct, comment, []*user_model.User{reviewer}); err != nil {
|
||||
log.Error("Error in SendIssueAssignedMail for issue[%d] to reviewer[%d]: %v", issue.ID, reviewer.ID, err)
|
||||
|
||||
@@ -230,6 +230,12 @@ func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *package
|
||||
if len(pd.FileMetadata.Provides) > 0 {
|
||||
fmt.Fprintf(&buf, "p:%s\n", strings.Join(pd.FileMetadata.Provides, " "))
|
||||
}
|
||||
if pd.FileMetadata.InstallIf != "" {
|
||||
fmt.Fprintf(&buf, "i:%s\n", pd.FileMetadata.InstallIf)
|
||||
}
|
||||
if pd.FileMetadata.ProviderPriority > 0 {
|
||||
fmt.Fprintf(&buf, "k:%d\n", pd.FileMetadata.ProviderPriority)
|
||||
}
|
||||
fmt.Fprint(&buf, "\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -342,7 +342,7 @@ func buildReleaseFiles(ctx context.Context, ownerID int64, repoVersion *packages
|
||||
fmt.Fprintf(w, "Components: %s\n", strings.Join(components, " "))
|
||||
fmt.Fprintf(w, "Architectures: %s\n", strings.Join(architectures, " "))
|
||||
fmt.Fprintf(w, "Date: %s\n", time.Now().UTC().Format(time.RFC1123))
|
||||
fmt.Fprint(w, "Acquire-By-Hash: yes")
|
||||
fmt.Fprint(w, "Acquire-By-Hash: yes\n")
|
||||
|
||||
pfds, err := packages_model.GetPackageFileDescriptors(ctx, pfs)
|
||||
if err != nil {
|
||||
|
||||
@@ -267,6 +267,10 @@ func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *use
|
||||
if err := doMergeStyleSquash(mergeCtx, message); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case repo_model.MergeStyleFastForwardOnly:
|
||||
if err := doMergeStyleFastForwardOnly(mergeCtx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
return "", models.ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
|
||||
}
|
||||
@@ -377,6 +381,13 @@ func runMergeCommand(ctx *mergeContext, mergeStyle repo_model.MergeStyle, cmd *g
|
||||
StdErr: ctx.errbuf.String(),
|
||||
Err: err,
|
||||
}
|
||||
} else if mergeStyle == repo_model.MergeStyleFastForwardOnly && strings.Contains(ctx.errbuf.String(), "Not possible to fast-forward, aborting") {
|
||||
log.Debug("MergeDivergingFastForwardOnly %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
|
||||
return models.ErrMergeDivergingFastForwardOnly{
|
||||
StdOut: ctx.outbuf.String(),
|
||||
StdErr: ctx.errbuf.String(),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
log.Error("git merge %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
|
||||
return fmt.Errorf("git merge %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), ctx.errbuf.String())
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
// doMergeStyleFastForwardOnly merges the tracking into the current HEAD - which is assumed to be staging branch (equal to the pr.BaseBranch)
|
||||
func doMergeStyleFastForwardOnly(ctx *mergeContext) error {
|
||||
cmd := git.NewCommand(ctx, "merge", "--ff-only").AddDynamicArguments(trackingBranch)
|
||||
if err := runMergeCommand(ctx, repo_model.MergeStyleFastForwardOnly, cmd); err != nil {
|
||||
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
)
|
||||
|
||||
// doMergeStyleMerge merges the tracking into the current HEAD - which is assumed to tbe staging branch (equal to the pr.BaseBranch)
|
||||
// doMergeStyleMerge merges the tracking branch into the current HEAD - which is assumed to be the staging branch (equal to the pr.BaseBranch)
|
||||
func doMergeStyleMerge(ctx *mergeContext, message string) error {
|
||||
cmd := git.NewCommand(ctx, "merge", "--no-ff", "--no-commit").AddDynamicArguments(trackingBranch)
|
||||
if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil {
|
||||
|
||||
@@ -91,7 +91,6 @@ func GitGcRepo(ctx context.Context, repo *repo_model.Repository, timeout time.Du
|
||||
var stdout string
|
||||
var err error
|
||||
stdout, _, err = command.RunStdString(&git.RunOpts{Timeout: timeout, Dir: repo.RepoPath()})
|
||||
|
||||
if err != nil {
|
||||
log.Error("Repository garbage collection failed for %-v. Stdout: %s\nError: %v", repo, stdout, err)
|
||||
desc := fmt.Sprintf("Repository garbage collection failed for %s. Stdout: %s\nError: %v", repo.RepoPath(), stdout, err)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/avatars"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
|
||||
"gitea.com/go-chi/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
contributorStatsCacheKey = "GetContributorStats/%s/%s"
|
||||
contributorStatsCacheTimeout int64 = 60 * 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAwaitGeneration = errors.New("generation took longer than ")
|
||||
awaitGenerationTime = time.Second * 5
|
||||
generateLock = sync.Map{}
|
||||
)
|
||||
|
||||
type WeekData struct {
|
||||
Week int64 `json:"week"` // Starting day of the week as Unix timestamp
|
||||
Additions int `json:"additions"` // Number of additions in that week
|
||||
Deletions int `json:"deletions"` // Number of deletions in that week
|
||||
Commits int `json:"commits"` // Number of commits in that week
|
||||
}
|
||||
|
||||
// ContributorData represents statistical git commit count data
|
||||
type ContributorData struct {
|
||||
Name string `json:"name"` // Display name of the contributor
|
||||
Login string `json:"login"` // Login name of the contributor in case it exists
|
||||
AvatarLink string `json:"avatar_link"`
|
||||
HomeLink string `json:"home_link"`
|
||||
TotalCommits int64 `json:"total_commits"`
|
||||
Weeks map[int64]*WeekData `json:"weeks"`
|
||||
}
|
||||
|
||||
// ExtendedCommitStats contains information for commit stats with author data
|
||||
type ExtendedCommitStats struct {
|
||||
Author *api.CommitUser `json:"author"`
|
||||
Stats *api.CommitStats `json:"stats"`
|
||||
}
|
||||
|
||||
const layout = time.DateOnly
|
||||
|
||||
func findLastSundayBeforeDate(dateStr string) (string, error) {
|
||||
date, err := time.Parse(layout, dateStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
weekday := date.Weekday()
|
||||
daysToSubtract := int(weekday) - int(time.Sunday)
|
||||
if daysToSubtract < 0 {
|
||||
daysToSubtract += 7
|
||||
}
|
||||
|
||||
lastSunday := date.AddDate(0, 0, -daysToSubtract)
|
||||
return lastSunday.Format(layout), nil
|
||||
}
|
||||
|
||||
// GetContributorStats returns contributors stats for git commits for given revision or default branch
|
||||
func GetContributorStats(ctx context.Context, cache cache.Cache, repo *repo_model.Repository, revision string) (map[string]*ContributorData, error) {
|
||||
// as GetContributorStats is resource intensive we cache the result
|
||||
cacheKey := fmt.Sprintf(contributorStatsCacheKey, repo.FullName(), revision)
|
||||
if !cache.IsExist(cacheKey) {
|
||||
genReady := make(chan struct{})
|
||||
|
||||
// dont start multible async generations
|
||||
_, run := generateLock.Load(cacheKey)
|
||||
if run {
|
||||
return nil, ErrAwaitGeneration
|
||||
}
|
||||
|
||||
generateLock.Store(cacheKey, struct{}{})
|
||||
// run generation async
|
||||
go generateContributorStats(genReady, cache, cacheKey, repo, revision)
|
||||
|
||||
select {
|
||||
case <-time.After(awaitGenerationTime):
|
||||
return nil, ErrAwaitGeneration
|
||||
case <-genReady:
|
||||
// we got generation ready before timeout
|
||||
break
|
||||
}
|
||||
}
|
||||
// TODO: renew timeout of cache cache.UpdateTimeout(cacheKey, contributorStatsCacheTimeout)
|
||||
|
||||
switch v := cache.Get(cacheKey).(type) {
|
||||
case error:
|
||||
return nil, v
|
||||
case map[string]*ContributorData:
|
||||
return v, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected type in cache detected")
|
||||
}
|
||||
}
|
||||
|
||||
// getExtendedCommitStats return the list of *ExtendedCommitStats for the given revision
|
||||
func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int */) ([]*ExtendedCommitStats, error) {
|
||||
baseCommit, err := repo.GetCommit(revision)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdoutReader, stdoutWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = stdoutReader.Close()
|
||||
_ = stdoutWriter.Close()
|
||||
}()
|
||||
|
||||
gitCmd := git.NewCommand(repo.Ctx, "log", "--shortstat", "--no-merges", "--pretty=format:---%n%aN%n%aE%n%as", "--reverse")
|
||||
// AddOptionFormat("--max-count=%d", limit)
|
||||
gitCmd.AddDynamicArguments(baseCommit.ID.String())
|
||||
|
||||
var extendedCommitStats []*ExtendedCommitStats
|
||||
stderr := new(strings.Builder)
|
||||
err = gitCmd.Run(&git.RunOpts{
|
||||
Dir: repo.Path,
|
||||
Stdout: stdoutWriter,
|
||||
Stderr: stderr,
|
||||
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
|
||||
_ = stdoutWriter.Close()
|
||||
scanner := bufio.NewScanner(stdoutReader)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line != "---" {
|
||||
continue
|
||||
}
|
||||
scanner.Scan()
|
||||
authorName := strings.TrimSpace(scanner.Text())
|
||||
scanner.Scan()
|
||||
authorEmail := strings.TrimSpace(scanner.Text())
|
||||
scanner.Scan()
|
||||
date := strings.TrimSpace(scanner.Text())
|
||||
scanner.Scan()
|
||||
stats := strings.TrimSpace(scanner.Text())
|
||||
if authorName == "" || authorEmail == "" || date == "" || stats == "" {
|
||||
// FIXME: find a better way to parse the output so that we will handle this properly
|
||||
log.Warn("Something is wrong with git log output, skipping...")
|
||||
log.Warn("authorName: %s, authorEmail: %s, date: %s, stats: %s", authorName, authorEmail, date, stats)
|
||||
continue
|
||||
}
|
||||
// 1 file changed, 1 insertion(+), 1 deletion(-)
|
||||
fields := strings.Split(stats, ",")
|
||||
|
||||
commitStats := api.CommitStats{}
|
||||
for _, field := range fields[1:] {
|
||||
parts := strings.Split(strings.TrimSpace(field), " ")
|
||||
value, contributionType := parts[0], parts[1]
|
||||
amount, _ := strconv.Atoi(value)
|
||||
|
||||
if strings.HasPrefix(contributionType, "insertion") {
|
||||
commitStats.Additions = amount
|
||||
} else {
|
||||
commitStats.Deletions = amount
|
||||
}
|
||||
}
|
||||
commitStats.Total = commitStats.Additions + commitStats.Deletions
|
||||
scanner.Scan()
|
||||
scanner.Text() // empty line at the end
|
||||
|
||||
res := &ExtendedCommitStats{
|
||||
Author: &api.CommitUser{
|
||||
Identity: api.Identity{
|
||||
Name: authorName,
|
||||
Email: authorEmail,
|
||||
},
|
||||
Date: date,
|
||||
},
|
||||
Stats: &commitStats,
|
||||
}
|
||||
extendedCommitStats = append(extendedCommitStats, res)
|
||||
|
||||
}
|
||||
_ = stdoutReader.Close()
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get ContributorsCommitStats for repository.\nError: %w\nStderr: %s", err, stderr)
|
||||
}
|
||||
|
||||
return extendedCommitStats, nil
|
||||
}
|
||||
|
||||
func generateContributorStats(genDone chan struct{}, cache cache.Cache, cacheKey string, repo *repo_model.Repository, revision string) {
|
||||
ctx := graceful.GetManager().HammerContext()
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("OpenRepository: %w", err)
|
||||
_ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
|
||||
return
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
if len(revision) == 0 {
|
||||
revision = repo.DefaultBranch
|
||||
}
|
||||
extendedCommitStats, err := getExtendedCommitStats(gitRepo, revision)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("ExtendedCommitStats: %w", err)
|
||||
_ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
|
||||
return
|
||||
}
|
||||
if len(extendedCommitStats) == 0 {
|
||||
err := fmt.Errorf("no commit stats returned for revision '%s'", revision)
|
||||
_ = cache.Put(cacheKey, err, contributorStatsCacheTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
layout := time.DateOnly
|
||||
|
||||
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLinkWithSize(ctx, 0)
|
||||
contributorsCommitStats := make(map[string]*ContributorData)
|
||||
contributorsCommitStats["total"] = &ContributorData{
|
||||
Name: "Total",
|
||||
Weeks: make(map[int64]*WeekData),
|
||||
}
|
||||
total := contributorsCommitStats["total"]
|
||||
|
||||
for _, v := range extendedCommitStats {
|
||||
userEmail := v.Author.Email
|
||||
if len(userEmail) == 0 {
|
||||
continue
|
||||
}
|
||||
u, _ := user_model.GetUserByEmail(ctx, userEmail)
|
||||
if u != nil {
|
||||
// update userEmail with user's primary email address so
|
||||
// that different mail addresses will linked to same account
|
||||
userEmail = u.GetEmail()
|
||||
}
|
||||
// duplicated logic
|
||||
if _, ok := contributorsCommitStats[userEmail]; !ok {
|
||||
if u == nil {
|
||||
avatarLink := avatars.GenerateEmailAvatarFastLink(ctx, userEmail, 0)
|
||||
if avatarLink == "" {
|
||||
avatarLink = unknownUserAvatarLink
|
||||
}
|
||||
contributorsCommitStats[userEmail] = &ContributorData{
|
||||
Name: v.Author.Name,
|
||||
AvatarLink: avatarLink,
|
||||
Weeks: make(map[int64]*WeekData),
|
||||
}
|
||||
} else {
|
||||
contributorsCommitStats[userEmail] = &ContributorData{
|
||||
Name: u.DisplayName(),
|
||||
Login: u.LowerName,
|
||||
AvatarLink: u.AvatarLinkWithSize(ctx, 0),
|
||||
HomeLink: u.HomeLink(),
|
||||
Weeks: make(map[int64]*WeekData),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update user statistics
|
||||
user := contributorsCommitStats[userEmail]
|
||||
startingOfWeek, _ := findLastSundayBeforeDate(v.Author.Date)
|
||||
|
||||
val, _ := time.Parse(layout, startingOfWeek)
|
||||
week := val.UnixMilli()
|
||||
|
||||
if user.Weeks[week] == nil {
|
||||
user.Weeks[week] = &WeekData{
|
||||
Additions: 0,
|
||||
Deletions: 0,
|
||||
Commits: 0,
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
if total.Weeks[week] == nil {
|
||||
total.Weeks[week] = &WeekData{
|
||||
Additions: 0,
|
||||
Deletions: 0,
|
||||
Commits: 0,
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
user.Weeks[week].Additions += v.Stats.Additions
|
||||
user.Weeks[week].Deletions += v.Stats.Deletions
|
||||
user.Weeks[week].Commits++
|
||||
user.TotalCommits++
|
||||
|
||||
// Update overall statistics
|
||||
total.Weeks[week].Additions += v.Stats.Additions
|
||||
total.Weeks[week].Deletions += v.Stats.Deletions
|
||||
total.Weeks[week].Commits++
|
||||
total.TotalCommits++
|
||||
}
|
||||
|
||||
_ = cache.Put(cacheKey, contributorsCommitStats, contributorStatsCacheTimeout)
|
||||
generateLock.Delete(cacheKey)
|
||||
if genDone != nil {
|
||||
genDone <- struct{}{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
|
||||
"gitea.com/go-chi/cache"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepository_ContributorsGraph(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
assert.NoError(t, repo.LoadOwner(db.DefaultContext))
|
||||
mockCache, err := cache.NewCacher(cache.Options{
|
||||
Adapter: "memory",
|
||||
Interval: 24 * 60,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
generateContributorStats(nil, mockCache, "key", repo, "404ref")
|
||||
err, isErr := mockCache.Get("key").(error)
|
||||
assert.True(t, isErr)
|
||||
assert.ErrorAs(t, err, &git.ErrNotExist{})
|
||||
|
||||
generateContributorStats(nil, mockCache, "key2", repo, "master")
|
||||
data, isData := mockCache.Get("key2").(map[string]*ContributorData)
|
||||
assert.True(t, isData)
|
||||
var keys []string
|
||||
for k := range data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
assert.EqualValues(t, []string{
|
||||
"ethantkoenig@gmail.com",
|
||||
"jimmy.praet@telenet.be",
|
||||
"jon@allspice.io",
|
||||
"total", // generated summary
|
||||
}, keys)
|
||||
|
||||
assert.EqualValues(t, &ContributorData{
|
||||
Name: "Ethan Koenig",
|
||||
AvatarLink: "https://secure.gravatar.com/avatar/b42fb195faa8c61b8d88abfefe30e9e3?d=identicon",
|
||||
TotalCommits: 1,
|
||||
Weeks: map[int64]*WeekData{
|
||||
1511654400000: {
|
||||
Week: 1511654400000, // sunday 2017-11-26
|
||||
Additions: 3,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
},
|
||||
}, data["ethantkoenig@gmail.com"])
|
||||
assert.EqualValues(t, &ContributorData{
|
||||
Name: "Total",
|
||||
AvatarLink: "",
|
||||
TotalCommits: 3,
|
||||
Weeks: map[int64]*WeekData{
|
||||
1511654400000: {
|
||||
Week: 1511654400000, // sunday 2017-11-26 (2017-11-26 20:31:18 -0800)
|
||||
Additions: 3,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
1607817600000: {
|
||||
Week: 1607817600000, // sunday 2020-12-13 (2020-12-15 15:23:11 -0500)
|
||||
Additions: 10,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
1624752000000: {
|
||||
Week: 1624752000000, // sunday 2021-06-27 (2021-06-29 21:54:09 +0200)
|
||||
Additions: 2,
|
||||
Deletions: 0,
|
||||
Commits: 1,
|
||||
},
|
||||
},
|
||||
}, data["total"])
|
||||
}
|
||||
@@ -21,12 +21,12 @@ import (
|
||||
func TestIncludesAllRepositoriesTeams(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
testTeamRepositories := func(teamID int64, repoIds []int64) {
|
||||
testTeamRepositories := func(teamID int64, repoIDs []int64) {
|
||||
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
|
||||
assert.NoError(t, team.LoadRepositories(db.DefaultContext), "%s: GetRepositories", team.Name)
|
||||
assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name)
|
||||
assert.Len(t, team.Repos, len(repoIds), "%s: repo count", team.Name)
|
||||
for i, rid := range repoIds {
|
||||
assert.Len(t, team.Repos, len(repoIDs), "%s: repo count", team.Name)
|
||||
for i, rid := range repoIDs {
|
||||
if rid > 0 {
|
||||
assert.True(t, HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i)
|
||||
}
|
||||
@@ -52,12 +52,12 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) {
|
||||
assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories")
|
||||
|
||||
// Create repos.
|
||||
repoIds := make([]int64, 0)
|
||||
repoIDs := make([]int64, 0)
|
||||
for i := 0; i < 3; i++ {
|
||||
r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)})
|
||||
assert.NoError(t, err, "CreateRepository %d", i)
|
||||
if r != nil {
|
||||
repoIds = append(repoIds, r.ID)
|
||||
repoIDs = append(repoIDs, r.ID)
|
||||
}
|
||||
}
|
||||
// Get fresh copy of Owner team after creating repos.
|
||||
@@ -93,10 +93,10 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) {
|
||||
},
|
||||
}
|
||||
teamRepos := [][]int64{
|
||||
repoIds,
|
||||
repoIds,
|
||||
repoIDs,
|
||||
repoIDs,
|
||||
{},
|
||||
repoIds,
|
||||
repoIDs,
|
||||
{},
|
||||
}
|
||||
for i, team := range teams {
|
||||
@@ -109,7 +109,7 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) {
|
||||
// Update teams and check repositories.
|
||||
teams[3].IncludesAllRepositories = false
|
||||
teams[4].IncludesAllRepositories = true
|
||||
teamRepos[4] = repoIds
|
||||
teamRepos[4] = repoIDs
|
||||
for i, team := range teams {
|
||||
assert.NoError(t, models.UpdateTeam(db.DefaultContext, team, false, true), "%s: UpdateTeam", team.Name)
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
@@ -119,27 +119,27 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) {
|
||||
r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: "repo-last"})
|
||||
assert.NoError(t, err, "CreateRepository last")
|
||||
if r != nil {
|
||||
repoIds = append(repoIds, r.ID)
|
||||
repoIDs = append(repoIDs, r.ID)
|
||||
}
|
||||
teamRepos[0] = repoIds
|
||||
teamRepos[1] = repoIds
|
||||
teamRepos[4] = repoIds
|
||||
teamRepos[0] = repoIDs
|
||||
teamRepos[1] = repoIDs
|
||||
teamRepos[4] = repoIDs
|
||||
for i, team := range teams {
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Remove repo and check teams repositories.
|
||||
assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, repoIds[0]), "DeleteRepository")
|
||||
teamRepos[0] = repoIds[1:]
|
||||
teamRepos[1] = repoIds[1:]
|
||||
teamRepos[3] = repoIds[1:3]
|
||||
teamRepos[4] = repoIds[1:]
|
||||
assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, repoIDs[0]), "DeleteRepository")
|
||||
teamRepos[0] = repoIDs[1:]
|
||||
teamRepos[1] = repoIDs[1:]
|
||||
teamRepos[3] = repoIDs[1:3]
|
||||
teamRepos[4] = repoIDs[1:]
|
||||
for i, team := range teams {
|
||||
testTeamRepositories(team.ID, teamRepos[i])
|
||||
}
|
||||
|
||||
// Wipe created items.
|
||||
for i, rid := range repoIds {
|
||||
for i, rid := range repoIDs {
|
||||
if i > 0 { // first repo already deleted.
|
||||
assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, rid), "DeleteRepository %d", i)
|
||||
}
|
||||
|
||||
@@ -270,3 +270,34 @@ func GetBlobBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TryGetContentLanguage tries to get the (linguist) language of the file content
|
||||
func TryGetContentLanguage(gitRepo *git.Repository, commitID, treePath string) (string, error) {
|
||||
indexFilename, worktree, deleteTemporaryFile, err := gitRepo.ReadTreeToTemporaryIndex(commitID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer deleteTemporaryFile()
|
||||
|
||||
filename2attribute2info, err := gitRepo.CheckAttribute(git.CheckAttributeOpts{
|
||||
CachedOnly: true,
|
||||
Attributes: []string{"linguist-language", "gitlab-language"},
|
||||
Filenames: []string{treePath},
|
||||
IndexFile: indexFilename,
|
||||
WorkTree: worktree,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
language := filename2attribute2info[treePath]["linguist-language"]
|
||||
if language == "" || language == "unspecified" {
|
||||
language = filename2attribute2info[treePath]["gitlab-language"]
|
||||
}
|
||||
if language == "unspecified" {
|
||||
language = ""
|
||||
}
|
||||
|
||||
return language, nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
@@ -16,6 +20,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/sync"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
)
|
||||
|
||||
@@ -37,7 +42,7 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep
|
||||
oldOwner := repo.Owner
|
||||
|
||||
repoWorkingPool.CheckIn(fmt.Sprint(repo.ID))
|
||||
if err := models.TransferOwnership(ctx, doer, newOwner.Name, repo); err != nil {
|
||||
if err := transferOwnership(ctx, doer, newOwner.Name, repo); err != nil {
|
||||
repoWorkingPool.CheckOut(fmt.Sprint(repo.ID))
|
||||
return err
|
||||
}
|
||||
@@ -59,6 +64,278 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep
|
||||
return nil
|
||||
}
|
||||
|
||||
// transferOwnership transfers all corresponding repository items from old user to new one.
|
||||
func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName string, repo *repo_model.Repository) (err error) {
|
||||
repoRenamed := false
|
||||
wikiRenamed := false
|
||||
oldOwnerName := doer.Name
|
||||
|
||||
defer func() {
|
||||
if !repoRenamed && !wikiRenamed {
|
||||
return
|
||||
}
|
||||
|
||||
recoverErr := recover()
|
||||
if err == nil && recoverErr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if repoRenamed {
|
||||
if err := util.Rename(repo_model.RepoPath(newOwnerName, repo.Name), repo_model.RepoPath(oldOwnerName, repo.Name)); err != nil {
|
||||
log.Critical("Unable to move repository %s/%s directory from %s back to correct place %s: %v", oldOwnerName, repo.Name,
|
||||
repo_model.RepoPath(newOwnerName, repo.Name), repo_model.RepoPath(oldOwnerName, repo.Name), err)
|
||||
}
|
||||
}
|
||||
|
||||
if wikiRenamed {
|
||||
if err := util.Rename(repo_model.WikiPath(newOwnerName, repo.Name), repo_model.WikiPath(oldOwnerName, repo.Name)); err != nil {
|
||||
log.Critical("Unable to move wiki for repository %s/%s directory from %s back to correct place %s: %v", oldOwnerName, repo.Name,
|
||||
repo_model.WikiPath(newOwnerName, repo.Name), repo_model.WikiPath(oldOwnerName, repo.Name), err)
|
||||
}
|
||||
}
|
||||
|
||||
if recoverErr != nil {
|
||||
log.Error("Panic within TransferOwnership: %v\n%s", recoverErr, log.Stack(2))
|
||||
panic(recoverErr)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
newOwner, err := user_model.GetUserByName(ctx, newOwnerName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get new owner '%s': %w", newOwnerName, err)
|
||||
}
|
||||
newOwnerName = newOwner.Name // ensure capitalisation matches
|
||||
|
||||
// Check if new owner has repository with same name.
|
||||
if has, err := repo_model.IsRepositoryModelOrDirExist(ctx, newOwner, repo.Name); err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
Uname: newOwnerName,
|
||||
Name: repo.Name,
|
||||
}
|
||||
}
|
||||
|
||||
oldOwner := repo.Owner
|
||||
oldOwnerName = oldOwner.Name
|
||||
|
||||
// Note: we have to set value here to make sure recalculate accesses is based on
|
||||
// new owner.
|
||||
repo.OwnerID = newOwner.ID
|
||||
repo.Owner = newOwner
|
||||
repo.OwnerName = newOwner.Name
|
||||
|
||||
// Update repository.
|
||||
if _, err := sess.ID(repo.ID).Update(repo); err != nil {
|
||||
return fmt.Errorf("update owner: %w", err)
|
||||
}
|
||||
|
||||
// Remove redundant collaborators.
|
||||
collaborators, err := repo_model.GetCollaborators(ctx, repo.ID, db.ListOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("getCollaborators: %w", err)
|
||||
}
|
||||
|
||||
// Dummy object.
|
||||
collaboration := &repo_model.Collaboration{RepoID: repo.ID}
|
||||
for _, c := range collaborators {
|
||||
if c.IsGhost() {
|
||||
collaboration.ID = c.Collaboration.ID
|
||||
if _, err := sess.Delete(collaboration); err != nil {
|
||||
return fmt.Errorf("remove collaborator '%d': %w", c.ID, err)
|
||||
}
|
||||
collaboration.ID = 0
|
||||
}
|
||||
|
||||
if c.ID != newOwner.ID {
|
||||
isMember, err := organization.IsOrganizationMember(ctx, newOwner.ID, c.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsOrgMember: %w", err)
|
||||
} else if !isMember {
|
||||
continue
|
||||
}
|
||||
}
|
||||
collaboration.UserID = c.ID
|
||||
if _, err := sess.Delete(collaboration); err != nil {
|
||||
return fmt.Errorf("remove collaborator '%d': %w", c.ID, err)
|
||||
}
|
||||
collaboration.UserID = 0
|
||||
}
|
||||
|
||||
// Remove old team-repository relations.
|
||||
if oldOwner.IsOrganization() {
|
||||
if err := organization.RemoveOrgRepo(ctx, oldOwner.ID, repo.ID); err != nil {
|
||||
return fmt.Errorf("removeOrgRepo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if newOwner.IsOrganization() {
|
||||
teams, err := organization.FindOrgTeams(ctx, newOwner.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LoadTeams: %w", err)
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.IncludesAllRepositories {
|
||||
if err := models.AddRepository(ctx, t, repo); err != nil {
|
||||
return fmt.Errorf("AddRepository: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err := access_model.RecalculateAccesses(ctx, repo); err != nil {
|
||||
// Organization called this in addRepository method.
|
||||
return fmt.Errorf("recalculateAccesses: %w", err)
|
||||
}
|
||||
|
||||
// Update repository count.
|
||||
if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil {
|
||||
return fmt.Errorf("increase new owner repository count: %w", err)
|
||||
} else if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", oldOwner.ID); err != nil {
|
||||
return fmt.Errorf("decrease old owner repository count: %w", err)
|
||||
}
|
||||
|
||||
if err := repo_model.WatchRepo(ctx, doer.ID, repo.ID, true); err != nil {
|
||||
return fmt.Errorf("watchRepo: %w", err)
|
||||
}
|
||||
|
||||
// Remove watch for organization.
|
||||
if oldOwner.IsOrganization() {
|
||||
if err := repo_model.WatchRepo(ctx, oldOwner.ID, repo.ID, false); err != nil {
|
||||
return fmt.Errorf("watchRepo [false]: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete labels that belong to the old organization and comments that added these labels
|
||||
if oldOwner.IsOrganization() {
|
||||
if _, err := sess.Exec(`DELETE FROM issue_label WHERE issue_label.id IN (
|
||||
SELECT il_too.id FROM (
|
||||
SELECT il_too_too.id
|
||||
FROM issue_label AS il_too_too
|
||||
INNER JOIN label ON il_too_too.label_id = label.id
|
||||
INNER JOIN issue on issue.id = il_too_too.issue_id
|
||||
WHERE
|
||||
issue.repo_id = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != ?))
|
||||
) AS il_too )`, repo.ID, newOwner.ID); err != nil {
|
||||
return fmt.Errorf("Unable to remove old org labels: %w", err)
|
||||
}
|
||||
|
||||
if _, err := sess.Exec(`DELETE FROM comment WHERE comment.id IN (
|
||||
SELECT il_too.id FROM (
|
||||
SELECT com.id
|
||||
FROM comment AS com
|
||||
INNER JOIN label ON com.label_id = label.id
|
||||
INNER JOIN issue ON issue.id = com.issue_id
|
||||
WHERE
|
||||
com.type = ? AND issue.repo_id = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != ?))
|
||||
) AS il_too)`, issues_model.CommentTypeLabel, repo.ID, newOwner.ID); err != nil {
|
||||
return fmt.Errorf("Unable to remove old org label comments: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rename remote repository to new path and delete local copy.
|
||||
dir := user_model.UserPath(newOwner.Name)
|
||||
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("Failed to create dir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
if err := util.Rename(repo_model.RepoPath(oldOwner.Name, repo.Name), repo_model.RepoPath(newOwner.Name, repo.Name)); err != nil {
|
||||
return fmt.Errorf("rename repository directory: %w", err)
|
||||
}
|
||||
repoRenamed = true
|
||||
|
||||
// Rename remote wiki repository to new path and delete local copy.
|
||||
wikiPath := repo_model.WikiPath(oldOwner.Name, repo.Name)
|
||||
|
||||
if isExist, err := util.IsExist(wikiPath); err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", wikiPath, err)
|
||||
return err
|
||||
} else if isExist {
|
||||
if err := util.Rename(wikiPath, repo_model.WikiPath(newOwner.Name, repo.Name)); err != nil {
|
||||
return fmt.Errorf("rename repository wiki: %w", err)
|
||||
}
|
||||
wikiRenamed = true
|
||||
}
|
||||
|
||||
if err := models.DeleteRepositoryTransfer(ctx, repo.ID); err != nil {
|
||||
return fmt.Errorf("deleteRepositoryTransfer: %w", err)
|
||||
}
|
||||
repo.Status = repo_model.RepositoryReady
|
||||
if err := repo_model.UpdateRepositoryCols(ctx, repo, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If there was previously a redirect at this location, remove it.
|
||||
if err := repo_model.DeleteRedirect(ctx, newOwner.ID, repo.Name); err != nil {
|
||||
return fmt.Errorf("delete repo redirect: %w", err)
|
||||
}
|
||||
|
||||
if err := repo_model.NewRedirect(ctx, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil {
|
||||
return fmt.Errorf("repo_model.NewRedirect: %w", err)
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// changeRepositoryName changes all corresponding setting from old repository name to new one.
|
||||
func changeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, newRepoName string) (err error) {
|
||||
oldRepoName := repo.Name
|
||||
newRepoName = strings.ToLower(newRepoName)
|
||||
if err = repo_model.IsUsableRepoName(newRepoName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := repo_model.IsRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IsRepositoryExist: %w", err)
|
||||
} else if has {
|
||||
return repo_model.ErrRepoAlreadyExist{
|
||||
Uname: repo.Owner.Name,
|
||||
Name: newRepoName,
|
||||
}
|
||||
}
|
||||
|
||||
newRepoPath := repo_model.RepoPath(repo.Owner.Name, newRepoName)
|
||||
if err = util.Rename(repo.RepoPath(), newRepoPath); err != nil {
|
||||
return fmt.Errorf("rename repository directory: %w", err)
|
||||
}
|
||||
|
||||
wikiPath := repo.WikiPath()
|
||||
isExist, err := util.IsExist(wikiPath)
|
||||
if err != nil {
|
||||
log.Error("Unable to check if %s exists. Error: %v", wikiPath, err)
|
||||
return err
|
||||
}
|
||||
if isExist {
|
||||
if err = util.Rename(wikiPath, repo_model.WikiPath(repo.Owner.Name, newRepoName)); err != nil {
|
||||
return fmt.Errorf("rename repository wiki: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
if err := repo_model.NewRedirect(ctx, repo.Owner.ID, repo.ID, oldRepoName, newRepoName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
// ChangeRepositoryName changes all corresponding setting from old repository name to new one.
|
||||
func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, newRepoName string) error {
|
||||
log.Trace("ChangeRepositoryName: %s/%s -> %s", doer.Name, repo.Name, newRepoName)
|
||||
@@ -70,7 +347,7 @@ func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo
|
||||
// local copy's origin accordingly.
|
||||
|
||||
repoWorkingPool.CheckIn(fmt.Sprint(repo.ID))
|
||||
if err := repo_model.ChangeRepositoryName(ctx, doer, repo, newRepoName); err != nil {
|
||||
if err := changeRepositoryName(ctx, doer, repo, newRepoName); err != nil {
|
||||
repoWorkingPool.CheckOut(fmt.Sprint(repo.ID))
|
||||
return err
|
||||
}
|
||||
@@ -130,3 +407,24 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelRepositoryTransfer marks the repository as ready and remove pending transfer entry,
|
||||
// thus cancel the transfer process.
|
||||
func CancelRepositoryTransfer(ctx context.Context, repo *repo_model.Repository) error {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer committer.Close()
|
||||
|
||||
repo.Status = repo_model.RepositoryReady
|
||||
if err := repo_model.UpdateRepositoryCols(ctx, repo, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := models.DeleteRepositoryTransfer(ctx, repo.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return committer.Commit()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
activities_model "code.gitea.io/gitea/models/activities"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
@@ -78,3 +79,45 @@ func TestStartRepositoryTransferSetPermission(t *testing.T) {
|
||||
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{}, &user_model.User{}, &organization.Team{})
|
||||
}
|
||||
|
||||
func TestRepositoryTransfer(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
|
||||
transfer, err := models.GetPendingRepositoryTransfer(db.DefaultContext, repo)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, transfer)
|
||||
|
||||
// Cancel transfer
|
||||
assert.NoError(t, CancelRepositoryTransfer(db.DefaultContext, repo))
|
||||
|
||||
transfer, err = models.GetPendingRepositoryTransfer(db.DefaultContext, repo)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, transfer)
|
||||
assert.True(t, models.IsErrNoPendingTransfer(err))
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
assert.NoError(t, models.CreatePendingRepositoryTransfer(db.DefaultContext, doer, user2, repo.ID, nil))
|
||||
|
||||
transfer, err = models.GetPendingRepositoryTransfer(db.DefaultContext, repo)
|
||||
assert.Nil(t, err)
|
||||
assert.NoError(t, transfer.LoadAttributes(db.DefaultContext))
|
||||
assert.Equal(t, "user2", transfer.Recipient.Name)
|
||||
|
||||
org6 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// Only transfer can be started at any given time
|
||||
err = models.CreatePendingRepositoryTransfer(db.DefaultContext, doer, org6, repo.ID, nil)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, models.IsErrRepoTransferInProgress(err))
|
||||
|
||||
// Unknown user
|
||||
err = models.CreatePendingRepositoryTransfer(db.DefaultContext, doer, &user_model.User{ID: 1000, LowerName: "user1000"}, repo.ID, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Cancel transfer
|
||||
assert.NoError(t, CancelRepositoryTransfer(db.DefaultContext, repo))
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func DeleteAvatar(ctx context.Context, u *user_model.User) error {
|
||||
u.UseCustomAvatar = false
|
||||
u.Avatar = ""
|
||||
if _, err := db.GetEngine(ctx).ID(u.ID).Cols("avatar, use_custom_avatar").Update(u); err != nil {
|
||||
return fmt.Errorf("UpdateUser: %w", err)
|
||||
return fmt.Errorf("DeleteAvatar: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -187,6 +187,10 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
|
||||
}
|
||||
// ***** END: ExternalLoginUser *****
|
||||
|
||||
if err := auth_model.DeleteAuthTokensByUserID(ctx, u.ID); err != nil {
|
||||
return fmt.Errorf("DeleteAuthTokensByUserID: %w", err)
|
||||
}
|
||||
|
||||
if _, err = db.DeleteByID[user_model.User](ctx, u.ID); err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
func AddOrSetPrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error {
|
||||
if strings.EqualFold(u.Email, emailStr) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := user_model.ValidateEmail(emailStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if address exists already
|
||||
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if email != nil && email.UID != u.ID {
|
||||
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
|
||||
}
|
||||
|
||||
// Update old primary address
|
||||
primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primary.IsPrimary = false
|
||||
if err := user_model.UpdateEmailAddress(ctx, primary); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new or update existing address
|
||||
if email != nil {
|
||||
email.IsPrimary = true
|
||||
email.IsActivated = true
|
||||
if err := user_model.UpdateEmailAddress(ctx, email); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
email = &user_model.EmailAddress{
|
||||
UID: u.ID,
|
||||
Email: emailStr,
|
||||
IsActivated: true,
|
||||
IsPrimary: true,
|
||||
}
|
||||
if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
u.Email = emailStr
|
||||
|
||||
return user_model.UpdateUserCols(ctx, u, "email")
|
||||
}
|
||||
|
||||
func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error {
|
||||
if strings.EqualFold(u.Email, emailStr) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := user_model.ValidateEmail(emailStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !u.IsOrganization() {
|
||||
// Check if address exists already
|
||||
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if email != nil {
|
||||
if email.IsPrimary && email.UID == u.ID {
|
||||
return nil
|
||||
}
|
||||
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
|
||||
}
|
||||
|
||||
// Remove old primary address
|
||||
primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new primary address
|
||||
email = &user_model.EmailAddress{
|
||||
UID: u.ID,
|
||||
Email: emailStr,
|
||||
IsActivated: true,
|
||||
IsPrimary: true,
|
||||
}
|
||||
if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
u.Email = emailStr
|
||||
|
||||
return user_model.UpdateUserCols(ctx, u, "email")
|
||||
}
|
||||
|
||||
func AddEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error {
|
||||
for _, emailStr := range emails {
|
||||
if err := user_model.ValidateEmail(emailStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if address exists already
|
||||
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if email != nil {
|
||||
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
|
||||
}
|
||||
|
||||
// Insert new address
|
||||
email = &user_model.EmailAddress{
|
||||
UID: u.ID,
|
||||
Email: emailStr,
|
||||
IsActivated: !setting.Service.RegisterEmailConfirm,
|
||||
IsPrimary: false,
|
||||
}
|
||||
if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error {
|
||||
for _, emailStr := range emails {
|
||||
// Check if address exists
|
||||
email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if email.IsPrimary {
|
||||
return user_model.ErrPrimaryEmailCannotDelete{Email: emailStr}
|
||||
}
|
||||
|
||||
// Remove address
|
||||
if _, err := db.DeleteByID[user_model.EmailAddress](ctx, email.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
organization_model "code.gitea.io/gitea/models/organization"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAddOrSetPrimaryEmailAddress(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 27})
|
||||
|
||||
emails, err := user_model.GetEmailAddresses(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 1)
|
||||
|
||||
primary, err := user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, "new-primary@example.com", primary.Email)
|
||||
assert.Equal(t, user.Email, primary.Email)
|
||||
|
||||
assert.NoError(t, AddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary@example.com"))
|
||||
|
||||
primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "new-primary@example.com", primary.Email)
|
||||
assert.Equal(t, user.Email, primary.Email)
|
||||
|
||||
emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 2)
|
||||
|
||||
assert.NoError(t, AddOrSetPrimaryEmailAddress(db.DefaultContext, user, "user27@example.com"))
|
||||
|
||||
primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "user27@example.com", primary.Email)
|
||||
assert.Equal(t, user.Email, primary.Email)
|
||||
|
||||
emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 2)
|
||||
}
|
||||
|
||||
func TestReplacePrimaryEmailAddress(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("User", func(t *testing.T) {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13})
|
||||
|
||||
emails, err := user_model.GetEmailAddresses(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 1)
|
||||
|
||||
primary, err := user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, "primary-13@example.com", primary.Email)
|
||||
assert.Equal(t, user.Email, primary.Email)
|
||||
|
||||
assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com"))
|
||||
|
||||
primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "primary-13@example.com", primary.Email)
|
||||
assert.Equal(t, user.Email, primary.Email)
|
||||
|
||||
emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 1)
|
||||
|
||||
assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com"))
|
||||
})
|
||||
|
||||
t.Run("Organization", func(t *testing.T) {
|
||||
org := unittest.AssertExistsAndLoadBean(t, &organization_model.Organization{ID: 3})
|
||||
|
||||
assert.Equal(t, "org3@example.com", org.Email)
|
||||
|
||||
assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, org.AsUser(), "primary-org@example.com"))
|
||||
|
||||
assert.Equal(t, "primary-org@example.com", org.Email)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddEmailAddresses(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
assert.Error(t, AddEmailAddresses(db.DefaultContext, user, []string{" invalid email "}))
|
||||
|
||||
emails := []string{"user1234@example.com", "user5678@example.com"}
|
||||
|
||||
assert.NoError(t, AddEmailAddresses(db.DefaultContext, user, emails))
|
||||
|
||||
err := AddEmailAddresses(db.DefaultContext, user, emails)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, user_model.IsErrEmailAlreadyUsed(err))
|
||||
}
|
||||
|
||||
func TestDeleteEmailAddresses(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
emails := []string{"user2-2@example.com"}
|
||||
|
||||
err := DeleteEmailAddresses(db.DefaultContext, user, emails)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = DeleteEmailAddresses(db.DefaultContext, user, emails)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, user_model.IsErrEmailAddressNotExist(err))
|
||||
|
||||
emails = []string{"user2@example.com"}
|
||||
|
||||
err = DeleteEmailAddresses(db.DefaultContext, user, emails)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err))
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
password_module "code.gitea.io/gitea/modules/auth/password"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
)
|
||||
|
||||
type UpdateOptions struct {
|
||||
KeepEmailPrivate optional.Option[bool]
|
||||
FullName optional.Option[string]
|
||||
Website optional.Option[string]
|
||||
Location optional.Option[string]
|
||||
Description optional.Option[string]
|
||||
AllowGitHook optional.Option[bool]
|
||||
AllowImportLocal optional.Option[bool]
|
||||
MaxRepoCreation optional.Option[int]
|
||||
IsRestricted optional.Option[bool]
|
||||
Visibility optional.Option[structs.VisibleType]
|
||||
KeepActivityPrivate optional.Option[bool]
|
||||
Language optional.Option[string]
|
||||
Theme optional.Option[string]
|
||||
DiffViewStyle optional.Option[string]
|
||||
AllowCreateOrganization optional.Option[bool]
|
||||
IsActive optional.Option[bool]
|
||||
IsAdmin optional.Option[bool]
|
||||
EmailNotificationsPreference optional.Option[string]
|
||||
SetLastLogin bool
|
||||
RepoAdminChangeTeamAccess optional.Option[bool]
|
||||
}
|
||||
|
||||
func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error {
|
||||
cols := make([]string, 0, 20)
|
||||
|
||||
if opts.KeepEmailPrivate.Has() {
|
||||
u.KeepEmailPrivate = opts.KeepEmailPrivate.Value()
|
||||
|
||||
cols = append(cols, "keep_email_private")
|
||||
}
|
||||
|
||||
if opts.FullName.Has() {
|
||||
u.FullName = opts.FullName.Value()
|
||||
|
||||
cols = append(cols, "full_name")
|
||||
}
|
||||
if opts.Website.Has() {
|
||||
u.Website = opts.Website.Value()
|
||||
|
||||
cols = append(cols, "website")
|
||||
}
|
||||
if opts.Location.Has() {
|
||||
u.Location = opts.Location.Value()
|
||||
|
||||
cols = append(cols, "location")
|
||||
}
|
||||
if opts.Description.Has() {
|
||||
u.Description = opts.Description.Value()
|
||||
|
||||
cols = append(cols, "description")
|
||||
}
|
||||
if opts.Language.Has() {
|
||||
u.Language = opts.Language.Value()
|
||||
|
||||
cols = append(cols, "language")
|
||||
}
|
||||
if opts.Theme.Has() {
|
||||
u.Theme = opts.Theme.Value()
|
||||
|
||||
cols = append(cols, "theme")
|
||||
}
|
||||
if opts.DiffViewStyle.Has() {
|
||||
u.DiffViewStyle = opts.DiffViewStyle.Value()
|
||||
|
||||
cols = append(cols, "diff_view_style")
|
||||
}
|
||||
|
||||
if opts.AllowGitHook.Has() {
|
||||
u.AllowGitHook = opts.AllowGitHook.Value()
|
||||
|
||||
cols = append(cols, "allow_git_hook")
|
||||
}
|
||||
if opts.AllowImportLocal.Has() {
|
||||
u.AllowImportLocal = opts.AllowImportLocal.Value()
|
||||
|
||||
cols = append(cols, "allow_import_local")
|
||||
}
|
||||
|
||||
if opts.MaxRepoCreation.Has() {
|
||||
u.MaxRepoCreation = opts.MaxRepoCreation.Value()
|
||||
|
||||
cols = append(cols, "max_repo_creation")
|
||||
}
|
||||
|
||||
if opts.IsActive.Has() {
|
||||
u.IsActive = opts.IsActive.Value()
|
||||
|
||||
cols = append(cols, "is_active")
|
||||
}
|
||||
if opts.IsRestricted.Has() {
|
||||
u.IsRestricted = opts.IsRestricted.Value()
|
||||
|
||||
cols = append(cols, "is_restricted")
|
||||
}
|
||||
if opts.IsAdmin.Has() {
|
||||
if !opts.IsAdmin.Value() && user_model.IsLastAdminUser(ctx, u) {
|
||||
return models.ErrDeleteLastAdminUser{UID: u.ID}
|
||||
}
|
||||
|
||||
u.IsAdmin = opts.IsAdmin.Value()
|
||||
|
||||
cols = append(cols, "is_admin")
|
||||
}
|
||||
|
||||
if opts.Visibility.Has() {
|
||||
if !u.IsOrganization() && !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(opts.Visibility.Value()) {
|
||||
return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String())
|
||||
}
|
||||
u.Visibility = opts.Visibility.Value()
|
||||
|
||||
cols = append(cols, "visibility")
|
||||
}
|
||||
if opts.KeepActivityPrivate.Has() {
|
||||
u.KeepActivityPrivate = opts.KeepActivityPrivate.Value()
|
||||
|
||||
cols = append(cols, "keep_activity_private")
|
||||
}
|
||||
|
||||
if opts.AllowCreateOrganization.Has() {
|
||||
u.AllowCreateOrganization = opts.AllowCreateOrganization.Value()
|
||||
|
||||
cols = append(cols, "allow_create_organization")
|
||||
}
|
||||
if opts.RepoAdminChangeTeamAccess.Has() {
|
||||
u.RepoAdminChangeTeamAccess = opts.RepoAdminChangeTeamAccess.Value()
|
||||
|
||||
cols = append(cols, "repo_admin_change_team_access")
|
||||
}
|
||||
|
||||
if opts.EmailNotificationsPreference.Has() {
|
||||
u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value()
|
||||
|
||||
cols = append(cols, "email_notifications_preference")
|
||||
}
|
||||
|
||||
if opts.SetLastLogin {
|
||||
u.SetLastLogin()
|
||||
|
||||
cols = append(cols, "last_login_unix")
|
||||
}
|
||||
|
||||
return user_model.UpdateUserCols(ctx, u, cols...)
|
||||
}
|
||||
|
||||
type UpdateAuthOptions struct {
|
||||
LoginSource optional.Option[int64]
|
||||
LoginName optional.Option[string]
|
||||
Password optional.Option[string]
|
||||
MustChangePassword optional.Option[bool]
|
||||
ProhibitLogin optional.Option[bool]
|
||||
}
|
||||
|
||||
func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions) error {
|
||||
if opts.LoginSource.Has() {
|
||||
source, err := auth_model.GetSourceByID(ctx, opts.LoginSource.Value())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.LoginType = source.Type
|
||||
u.LoginSource = source.ID
|
||||
}
|
||||
if opts.LoginName.Has() {
|
||||
u.LoginName = opts.LoginName.Value()
|
||||
}
|
||||
|
||||
deleteAuthTokens := false
|
||||
if opts.Password.Has() && (u.IsLocal() || u.IsOAuth2()) {
|
||||
password := opts.Password.Value()
|
||||
|
||||
if len(password) < setting.MinPasswordLength {
|
||||
return password_module.ErrMinLength
|
||||
}
|
||||
if !password_module.IsComplexEnough(password) {
|
||||
return password_module.ErrComplexity
|
||||
}
|
||||
if err := password_module.IsPwned(ctx, password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := u.SetPassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deleteAuthTokens = true
|
||||
}
|
||||
|
||||
if opts.MustChangePassword.Has() {
|
||||
u.MustChangePassword = opts.MustChangePassword.Value()
|
||||
}
|
||||
if opts.ProhibitLogin.Has() {
|
||||
u.ProhibitLogin = opts.ProhibitLogin.Value()
|
||||
}
|
||||
|
||||
if err := user_model.UpdateUserCols(ctx, u, "login_type", "login_source", "login_name", "passwd", "passwd_hash_algo", "salt", "must_change_password", "prohibit_login"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deleteAuthTokens {
|
||||
return auth_model.DeleteAuthTokensByUserID(ctx, u.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
password_module "code.gitea.io/gitea/modules/auth/password"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
assert.Error(t, UpdateUser(db.DefaultContext, admin, &UpdateOptions{
|
||||
IsAdmin: optional.Some(false),
|
||||
}))
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
|
||||
opts := &UpdateOptions{
|
||||
KeepEmailPrivate: optional.Some(false),
|
||||
FullName: optional.Some("Changed Name"),
|
||||
Website: optional.Some("https://gitea.com/"),
|
||||
Location: optional.Some("location"),
|
||||
Description: optional.Some("description"),
|
||||
AllowGitHook: optional.Some(true),
|
||||
AllowImportLocal: optional.Some(true),
|
||||
MaxRepoCreation: optional.Some[int](10),
|
||||
IsRestricted: optional.Some(true),
|
||||
IsActive: optional.Some(false),
|
||||
IsAdmin: optional.Some(true),
|
||||
Visibility: optional.Some(structs.VisibleTypePrivate),
|
||||
KeepActivityPrivate: optional.Some(true),
|
||||
Language: optional.Some("lang"),
|
||||
Theme: optional.Some("theme"),
|
||||
DiffViewStyle: optional.Some("split"),
|
||||
AllowCreateOrganization: optional.Some(false),
|
||||
EmailNotificationsPreference: optional.Some("disabled"),
|
||||
SetLastLogin: true,
|
||||
}
|
||||
assert.NoError(t, UpdateUser(db.DefaultContext, user, opts))
|
||||
|
||||
assert.Equal(t, opts.KeepEmailPrivate.Value(), user.KeepEmailPrivate)
|
||||
assert.Equal(t, opts.FullName.Value(), user.FullName)
|
||||
assert.Equal(t, opts.Website.Value(), user.Website)
|
||||
assert.Equal(t, opts.Location.Value(), user.Location)
|
||||
assert.Equal(t, opts.Description.Value(), user.Description)
|
||||
assert.Equal(t, opts.AllowGitHook.Value(), user.AllowGitHook)
|
||||
assert.Equal(t, opts.AllowImportLocal.Value(), user.AllowImportLocal)
|
||||
assert.Equal(t, opts.MaxRepoCreation.Value(), user.MaxRepoCreation)
|
||||
assert.Equal(t, opts.IsRestricted.Value(), user.IsRestricted)
|
||||
assert.Equal(t, opts.IsActive.Value(), user.IsActive)
|
||||
assert.Equal(t, opts.IsAdmin.Value(), user.IsAdmin)
|
||||
assert.Equal(t, opts.Visibility.Value(), user.Visibility)
|
||||
assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
|
||||
assert.Equal(t, opts.Language.Value(), user.Language)
|
||||
assert.Equal(t, opts.Theme.Value(), user.Theme)
|
||||
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
|
||||
assert.Equal(t, opts.AllowCreateOrganization.Value(), user.AllowCreateOrganization)
|
||||
assert.Equal(t, opts.EmailNotificationsPreference.Value(), user.EmailNotificationsPreference)
|
||||
|
||||
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
assert.Equal(t, opts.KeepEmailPrivate.Value(), user.KeepEmailPrivate)
|
||||
assert.Equal(t, opts.FullName.Value(), user.FullName)
|
||||
assert.Equal(t, opts.Website.Value(), user.Website)
|
||||
assert.Equal(t, opts.Location.Value(), user.Location)
|
||||
assert.Equal(t, opts.Description.Value(), user.Description)
|
||||
assert.Equal(t, opts.AllowGitHook.Value(), user.AllowGitHook)
|
||||
assert.Equal(t, opts.AllowImportLocal.Value(), user.AllowImportLocal)
|
||||
assert.Equal(t, opts.MaxRepoCreation.Value(), user.MaxRepoCreation)
|
||||
assert.Equal(t, opts.IsRestricted.Value(), user.IsRestricted)
|
||||
assert.Equal(t, opts.IsActive.Value(), user.IsActive)
|
||||
assert.Equal(t, opts.IsAdmin.Value(), user.IsAdmin)
|
||||
assert.Equal(t, opts.Visibility.Value(), user.Visibility)
|
||||
assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
|
||||
assert.Equal(t, opts.Language.Value(), user.Language)
|
||||
assert.Equal(t, opts.Theme.Value(), user.Theme)
|
||||
assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
|
||||
assert.Equal(t, opts.AllowCreateOrganization.Value(), user.AllowCreateOrganization)
|
||||
assert.Equal(t, opts.EmailNotificationsPreference.Value(), user.EmailNotificationsPreference)
|
||||
}
|
||||
|
||||
func TestUpdateAuth(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
copy := *user
|
||||
|
||||
assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
|
||||
LoginName: optional.Some("new-login"),
|
||||
}))
|
||||
assert.Equal(t, "new-login", user.LoginName)
|
||||
|
||||
assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
|
||||
Password: optional.Some("%$DRZUVB576tfzgu"),
|
||||
MustChangePassword: optional.Some(true),
|
||||
}))
|
||||
assert.True(t, user.MustChangePassword)
|
||||
assert.NotEqual(t, copy.Passwd, user.Passwd)
|
||||
assert.NotEqual(t, copy.Salt, user.Salt)
|
||||
|
||||
assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
|
||||
ProhibitLogin: optional.Some(true),
|
||||
}))
|
||||
assert.True(t, user.ProhibitLogin)
|
||||
|
||||
assert.ErrorIs(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
|
||||
Password: optional.Some("aaaa"),
|
||||
}), password_module.ErrMinLength)
|
||||
}
|
||||
@@ -41,10 +41,7 @@ func RenameUser(ctx context.Context, u *user_model.User, newUserName string) err
|
||||
}
|
||||
|
||||
if newUserName == u.Name {
|
||||
return user_model.ErrUsernameNotChanged{
|
||||
UID: u.ID,
|
||||
Name: u.Name,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := user_model.IsUsableUsername(newUserName); err != nil {
|
||||
|
||||
@@ -107,7 +107,7 @@ func TestRenameUser(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Same username", func(t *testing.T) {
|
||||
assert.ErrorIs(t, RenameUser(db.DefaultContext, user, user.Name), user_model.ErrUsernameNotChanged{UID: user.ID, Name: user.Name})
|
||||
assert.NoError(t, RenameUser(db.DefaultContext, user, user.Name))
|
||||
})
|
||||
|
||||
t.Run("Non usable username", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user