1
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-02-02 15:09:33 -05:00

Merge branch 'main' into lunny/add_last_commit_when

This commit is contained in:
Lunny Xiao 2024-12-27 21:49:48 -08:00
commit 14821a5a01
106 changed files with 1967 additions and 1230 deletions

View File

@ -674,7 +674,7 @@ module.exports = {
'no-this-before-super': [2], 'no-this-before-super': [2],
'no-throw-literal': [2], 'no-throw-literal': [2],
'no-undef-init': [2], 'no-undef-init': [2],
'no-undef': [0], 'no-undef': [2], // it is still needed by eslint & IDE to prompt undefined names in real time
'no-undefined': [0], 'no-undefined': [0],
'no-underscore-dangle': [0], 'no-underscore-dangle': [0],
'no-unexpected-multiline': [2], 'no-unexpected-multiline': [2],

View File

@ -1339,6 +1339,9 @@ LEVEL = Info
;; Number of repos that are displayed on one page ;; Number of repos that are displayed on one page
;REPO_PAGING_NUM = 15 ;REPO_PAGING_NUM = 15
;; Number of orgs that are displayed on profile page
;ORG_PAGING_NUM = 15
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[ui.meta] ;[ui.meta]

View File

@ -275,7 +275,7 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork
return err return err
} }
run.Index = index run.Index = index
run.Title, _ = util.SplitStringAtByteN(run.Title, 255) run.Title = util.EllipsisDisplayString(run.Title, 255)
if err := db.Insert(ctx, run); err != nil { if err := db.Insert(ctx, run); err != nil {
return err return err
@ -308,7 +308,7 @@ func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWork
} else { } else {
hasWaiting = true hasWaiting = true
} }
job.Name, _ = util.SplitStringAtByteN(job.Name, 255) job.Name = util.EllipsisDisplayString(job.Name, 255)
runJobs = append(runJobs, &ActionRunJob{ runJobs = append(runJobs, &ActionRunJob{
RunID: run.ID, RunID: run.ID,
RepoID: run.RepoID, RepoID: run.RepoID,
@ -402,7 +402,7 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
if len(cols) > 0 { if len(cols) > 0 {
sess.Cols(cols...) sess.Cols(cols...)
} }
run.Title, _ = util.SplitStringAtByteN(run.Title, 255) run.Title = util.EllipsisDisplayString(run.Title, 255)
affected, err := sess.Update(run) affected, err := sess.Update(run)
if err != nil { if err != nil {
return err return err

View File

@ -252,7 +252,7 @@ func GetRunnerByID(ctx context.Context, id int64) (*ActionRunner, error) {
// UpdateRunner updates runner's information. // UpdateRunner updates runner's information.
func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error { func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error {
e := db.GetEngine(ctx) e := db.GetEngine(ctx)
r.Name, _ = util.SplitStringAtByteN(r.Name, 255) r.Name = util.EllipsisDisplayString(r.Name, 255)
var err error var err error
if len(cols) == 0 { if len(cols) == 0 {
_, err = e.ID(r.ID).AllCols().Update(r) _, err = e.ID(r.ID).AllCols().Update(r)
@ -279,7 +279,7 @@ func CreateRunner(ctx context.Context, t *ActionRunner) error {
// Remove OwnerID to avoid confusion; it's not worth returning an error here. // Remove OwnerID to avoid confusion; it's not worth returning an error here.
t.OwnerID = 0 t.OwnerID = 0
} }
t.Name, _ = util.SplitStringAtByteN(t.Name, 255) t.Name = util.EllipsisDisplayString(t.Name, 255)
return db.Insert(ctx, t) return db.Insert(ctx, t)
} }

View File

@ -10,7 +10,6 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
@ -52,7 +51,7 @@ func GetRunnerToken(ctx context.Context, token string) (*ActionRunnerToken, erro
if err != nil { if err != nil {
return nil, err return nil, err
} else if !has { } else if !has {
return nil, fmt.Errorf(`runner token "%s...": %w`, base.TruncateString(token, 3), util.ErrNotExist) return nil, fmt.Errorf(`runner token "%s...": %w`, util.TruncateRunes(token, 3), util.ErrNotExist)
} }
return &runnerToken, nil return &runnerToken, nil
} }

View File

@ -68,7 +68,7 @@ func CreateScheduleTask(ctx context.Context, rows []*ActionSchedule) error {
// Loop through each schedule row // Loop through each schedule row
for _, row := range rows { for _, row := range rows {
row.Title, _ = util.SplitStringAtByteN(row.Title, 255) row.Title = util.EllipsisDisplayString(row.Title, 255)
// Create new schedule row // Create new schedule row
if err = db.Insert(ctx, row); err != nil { if err = db.Insert(ctx, row); err != nil {
return err return err

View File

@ -298,7 +298,7 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
if len(workflowJob.Steps) > 0 { if len(workflowJob.Steps) > 0 {
steps := make([]*ActionTaskStep, len(workflowJob.Steps)) steps := make([]*ActionTaskStep, len(workflowJob.Steps))
for i, v := range workflowJob.Steps { for i, v := range workflowJob.Steps {
name, _ := util.SplitStringAtByteN(v.String(), 255) name := util.EllipsisDisplayString(v.String(), 255)
steps[i] = &ActionTaskStep{ steps[i] = &ActionTaskStep{
Name: name, Name: name,
TaskID: task.ID, TaskID: task.ID,

View File

@ -20,12 +20,12 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder" "xorm.io/builder"
"xorm.io/xorm/schemas" "xorm.io/xorm/schemas"
@ -226,7 +226,7 @@ func (a *Action) GetActUserName(ctx context.Context) string {
// ShortActUserName gets the action's user name trimmed to max 20 // ShortActUserName gets the action's user name trimmed to max 20
// chars. // chars.
func (a *Action) ShortActUserName(ctx context.Context) string { func (a *Action) ShortActUserName(ctx context.Context) string {
return base.EllipsisString(a.GetActUserName(ctx), 20) return util.EllipsisDisplayString(a.GetActUserName(ctx), 20)
} }
// GetActDisplayName gets the action's display name based on DEFAULT_SHOW_FULL_NAME, or falls back to the username if it is blank. // GetActDisplayName gets the action's display name based on DEFAULT_SHOW_FULL_NAME, or falls back to the username if it is blank.
@ -260,7 +260,7 @@ func (a *Action) GetRepoUserName(ctx context.Context) string {
// ShortRepoUserName returns the name of the action repository owner // ShortRepoUserName returns the name of the action repository owner
// trimmed to max 20 chars. // trimmed to max 20 chars.
func (a *Action) ShortRepoUserName(ctx context.Context) string { func (a *Action) ShortRepoUserName(ctx context.Context) string {
return base.EllipsisString(a.GetRepoUserName(ctx), 20) return util.EllipsisDisplayString(a.GetRepoUserName(ctx), 20)
} }
// GetRepoName returns the name of the action repository. // GetRepoName returns the name of the action repository.
@ -275,7 +275,7 @@ func (a *Action) GetRepoName(ctx context.Context) string {
// ShortRepoName returns the name of the action repository // ShortRepoName returns the name of the action repository
// trimmed to max 33 chars. // trimmed to max 33 chars.
func (a *Action) ShortRepoName(ctx context.Context) string { func (a *Action) ShortRepoName(ctx context.Context) string {
return base.EllipsisString(a.GetRepoName(ctx), 33) return util.EllipsisDisplayString(a.GetRepoName(ctx), 33)
} }
// GetRepoPath returns the virtual path to the action repository. // GetRepoPath returns the virtual path to the action repository.

View File

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"xorm.io/builder"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -337,8 +338,10 @@ func newlyCreatedIssues(ctx context.Context, repoID int64, fromTime time.Time) *
func activeIssues(ctx context.Context, repoID int64, fromTime time.Time) *xorm.Session { func activeIssues(ctx context.Context, repoID int64, fromTime time.Time) *xorm.Session {
sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID). sess := db.GetEngine(ctx).Where("issue.repo_id = ?", repoID).
And("issue.is_pull = ?", false). And("issue.is_pull = ?", false).
And("issue.created_unix >= ?", fromTime.Unix()). And(builder.Or(
Or("issue.closed_unix >= ?", fromTime.Unix()) builder.Gte{"issue.created_unix": fromTime.Unix()},
builder.Gte{"issue.closed_unix": fromTime.Unix()},
))
return sess return sess
} }

View File

@ -96,3 +96,14 @@
num_issues: 0 num_issues: 0
num_closed_issues: 0 num_closed_issues: 0
archived_unix: 0 archived_unix: 0
-
id: 10
repo_id: 3
org_id: 0
name: repo3label1
color: '#112233'
exclusive: false
num_issues: 0
num_closed_issues: 0
archived_unix: 0

View File

@ -49,9 +49,13 @@ func TestCreateIssueDependency(t *testing.T) {
assert.False(t, left) assert.False(t, left)
// Close #2 and check again // Close #2 and check again
_, err = issues_model.ChangeIssueStatus(db.DefaultContext, issue2, user1, true) _, err = issues_model.CloseIssue(db.DefaultContext, issue2, user1)
assert.NoError(t, err) assert.NoError(t, err)
issue2Closed, err := issues_model.GetIssueByID(db.DefaultContext, 2)
assert.NoError(t, err)
assert.True(t, issue2Closed.IsClosed)
left, err = issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1) left, err = issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, left) assert.True(t, left)
@ -59,4 +63,11 @@ func TestCreateIssueDependency(t *testing.T) {
// Test removing the dependency // Test removing the dependency
err = issues_model.RemoveIssueDependency(db.DefaultContext, user1, issue1, issue2, issues_model.DependencyTypeBlockedBy) err = issues_model.RemoveIssueDependency(db.DefaultContext, user1, issue1, issue2, issues_model.DependencyTypeBlockedBy)
assert.NoError(t, err) assert.NoError(t, err)
_, err = issues_model.ReopenIssue(db.DefaultContext, issue2, user1)
assert.NoError(t, err)
issue2Reopened, err := issues_model.GetIssueByID(db.DefaultContext, 2)
assert.NoError(t, err)
assert.False(t, issue2Reopened.IsClosed)
} }

View File

@ -119,8 +119,8 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use
}) })
} }
// ChangeIssueStatus changes issue status to open or closed. // CloseIssue changes issue status to closed.
func ChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isClosed bool) (*Comment, error) { func CloseIssue(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
if err := issue.LoadRepo(ctx); err != nil { if err := issue.LoadRepo(ctx); err != nil {
return nil, err return nil, err
} }
@ -128,7 +128,45 @@ func ChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User,
return nil, err return nil, err
} }
return changeIssueStatus(ctx, issue, doer, isClosed, false) ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
comment, err := changeIssueStatus(ctx, issue, doer, true, false)
if err != nil {
return nil, err
}
if err := committer.Commit(); err != nil {
return nil, err
}
return comment, nil
}
// ReopenIssue changes issue status to open.
func ReopenIssue(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
if err := issue.LoadPoster(ctx); err != nil {
return nil, err
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
comment, err := changeIssueStatus(ctx, issue, doer, false, false)
if err != nil {
return nil, err
}
if err := committer.Commit(); err != nil {
return nil, err
}
return comment, nil
} }
// ChangeIssueTitle changes the title of this issue, as the given user. // ChangeIssueTitle changes the title of this issue, as the given user.
@ -139,7 +177,7 @@ func ChangeIssueTitle(ctx context.Context, issue *Issue, doer *user_model.User,
} }
defer committer.Close() defer committer.Close()
issue.Title, _ = util.SplitStringAtByteN(issue.Title, 255) issue.Title = util.EllipsisDisplayString(issue.Title, 255)
if err = UpdateIssueCols(ctx, issue, "name"); err != nil { if err = UpdateIssueCols(ctx, issue, "name"); err != nil {
return fmt.Errorf("updateIssueCols: %w", err) return fmt.Errorf("updateIssueCols: %w", err)
} }
@ -402,7 +440,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, la
} }
issue.Index = idx issue.Index = idx
issue.Title, _ = util.SplitStringAtByteN(issue.Title, 255) issue.Title = util.EllipsisDisplayString(issue.Title, 255)
if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{ if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
Repo: repo, Repo: repo,

View File

@ -98,7 +98,7 @@ func TestXRef_ResolveCrossReferences(t *testing.T) {
i1 := testCreateIssue(t, 1, 2, "title1", "content1", false) i1 := testCreateIssue(t, 1, 2, "title1", "content1", false)
i2 := testCreateIssue(t, 1, 2, "title2", "content2", false) i2 := testCreateIssue(t, 1, 2, "title2", "content2", false)
i3 := testCreateIssue(t, 1, 2, "title3", "content3", false) i3 := testCreateIssue(t, 1, 2, "title3", "content3", false)
_, err := issues_model.ChangeIssueStatus(db.DefaultContext, i3, d, true) _, err := issues_model.CloseIssue(db.DefaultContext, i3, d)
assert.NoError(t, err) assert.NoError(t, err)
pr := testCreatePR(t, 1, 2, "titlepr", fmt.Sprintf("closes #%d", i1.Index)) pr := testCreatePR(t, 1, 2, "titlepr", fmt.Sprintf("closes #%d", i1.Index))

View File

@ -349,6 +349,17 @@ func GetLabelIDsInRepoByNames(ctx context.Context, repoID int64, labelNames []st
Find(&labelIDs) Find(&labelIDs)
} }
// GetLabelIDsInOrgByNames returns a list of labelIDs by names in a given org.
func GetLabelIDsInOrgByNames(ctx context.Context, orgID int64, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
return labelIDs, db.GetEngine(ctx).Table("label").
Where("org_id = ?", orgID).
In("name", labelNames).
Asc("name").
Cols("id").
Find(&labelIDs)
}
// BuildLabelNamesIssueIDsCondition returns a builder where get issue ids match label names // BuildLabelNamesIssueIDsCondition returns a builder where get issue ids match label names
func BuildLabelNamesIssueIDsCondition(labelNames []string) *builder.Builder { func BuildLabelNamesIssueIDsCondition(labelNames []string) *builder.Builder {
return builder.Select("issue_label.issue_id"). return builder.Select("issue_label.issue_id").

View File

@ -572,7 +572,7 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *Iss
} }
issue.Index = idx issue.Index = idx
issue.Title, _ = util.SplitStringAtByteN(issue.Title, 255) issue.Title = util.EllipsisDisplayString(issue.Title, 255)
if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{ if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
Repo: repo, Repo: repo,

View File

@ -256,7 +256,7 @@ func NewProject(ctx context.Context, p *Project) error {
return util.NewInvalidArgumentErrorf("project type is not valid") return util.NewInvalidArgumentErrorf("project type is not valid")
} }
p.Title, _ = util.SplitStringAtByteN(p.Title, 255) p.Title = util.EllipsisDisplayString(p.Title, 255)
return db.WithTx(ctx, func(ctx context.Context) error { return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.Insert(ctx, p); err != nil { if err := db.Insert(ctx, p); err != nil {
@ -311,7 +311,7 @@ func UpdateProject(ctx context.Context, p *Project) error {
p.CardType = CardTypeTextOnly p.CardType = CardTypeTextOnly
} }
p.Title, _ = util.SplitStringAtByteN(p.Title, 255) p.Title = util.EllipsisDisplayString(p.Title, 255)
_, err := db.GetEngine(ctx).ID(p.ID).Cols( _, err := db.GetEngine(ctx).ID(p.ID).Cols(
"title", "title",
"description", "description",

View File

@ -156,7 +156,7 @@ func IsReleaseExist(ctx context.Context, repoID int64, tagName string) (bool, er
// UpdateRelease updates all columns of a release // UpdateRelease updates all columns of a release
func UpdateRelease(ctx context.Context, rel *Release) error { func UpdateRelease(ctx context.Context, rel *Release) error {
rel.Title, _ = util.SplitStringAtByteN(rel.Title, 255) rel.Title = util.EllipsisDisplayString(rel.Title, 255)
_, err := db.GetEngine(ctx).ID(rel.ID).AllCols().Update(rel) _, err := db.GetEngine(ctx).ID(rel.ID).AllCols().Update(rel)
return err return err
} }

View File

@ -357,8 +357,8 @@ func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddres
if user := GetVerifyUser(ctx, code); user != nil { if user := GetVerifyUser(ctx, code); user != nil {
// time limit code // time limit code
prefix := code[:base.TimeLimitCodeLength] prefix := code[:base.TimeLimitCodeLength]
data := fmt.Sprintf("%d%s%s%s%s", user.ID, email, user.LowerName, user.Passwd, user.Rands) opts := &TimeLimitCodeOptions{Purpose: TimeLimitCodeActivateEmail, NewEmail: email}
data := makeTimeLimitCodeHashData(opts, user)
if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) {
emailAddress := &EmailAddress{UID: user.ID, Email: email} emailAddress := &EmailAddress{UID: user.ID, Email: email}
if has, _ := db.GetEngine(ctx).Get(emailAddress); has { if has, _ := db.GetEngine(ctx).Get(emailAddress); has {
@ -486,10 +486,10 @@ func ActivateUserEmail(ctx context.Context, userID int64, email string, activate
// Activate/deactivate a user's primary email address and account // Activate/deactivate a user's primary email address and account
if addr.IsPrimary { if addr.IsPrimary {
user, exist, err := db.Get[User](ctx, builder.Eq{"id": userID, "email": email}) user, exist, err := db.Get[User](ctx, builder.Eq{"id": userID})
if err != nil { if err != nil {
return err return err
} else if !exist { } else if !exist || !strings.EqualFold(user.Email, email) {
return fmt.Errorf("no user with ID: %d and Email: %s", userID, email) return fmt.Errorf("no user with ID: %d and Email: %s", userID, email)
} }

View File

@ -181,7 +181,8 @@ func (u *User) BeforeUpdate() {
u.MaxRepoCreation = -1 u.MaxRepoCreation = -1
} }
// Organization does not need email // FIXME: this email doesn't need to be in lowercase, because the emails are mainly managed by the email table with lower_email field
// This trick could be removed in new releases to display the user inputed email as-is.
u.Email = strings.ToLower(u.Email) u.Email = strings.ToLower(u.Email)
if !u.IsOrganization() { if !u.IsOrganization() {
if len(u.AvatarEmail) == 0 { if len(u.AvatarEmail) == 0 {
@ -190,9 +191,9 @@ func (u *User) BeforeUpdate() {
} }
u.LowerName = strings.ToLower(u.Name) u.LowerName = strings.ToLower(u.Name)
u.Location = base.TruncateString(u.Location, 255) u.Location = util.TruncateRunes(u.Location, 255)
u.Website = base.TruncateString(u.Website, 255) u.Website = util.TruncateRunes(u.Website, 255)
u.Description = base.TruncateString(u.Description, 255) u.Description = util.TruncateRunes(u.Description, 255)
} }
// AfterLoad is invoked from XORM after filling all the fields of this object. // AfterLoad is invoked from XORM after filling all the fields of this object.
@ -310,17 +311,6 @@ func (u *User) OrganisationLink() string {
return setting.AppSubURL + "/org/" + url.PathEscape(u.Name) return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)
} }
// GenerateEmailActivateCode generates an activate code based on user information and given e-mail.
func (u *User) GenerateEmailActivateCode(email string) string {
code := base.CreateTimeLimitCode(
fmt.Sprintf("%d%s%s%s%s", u.ID, email, u.LowerName, u.Passwd, u.Rands),
setting.Service.ActiveCodeLives, time.Now(), nil)
// Add tail hex username
code += hex.EncodeToString([]byte(u.LowerName))
return code
}
// GetUserFollowers returns range of user's followers. // GetUserFollowers returns range of user's followers.
func GetUserFollowers(ctx context.Context, u, viewer *User, listOptions db.ListOptions) ([]*User, int64, error) { func GetUserFollowers(ctx context.Context, u, viewer *User, listOptions db.ListOptions) ([]*User, int64, error) {
sess := db.GetEngine(ctx). sess := db.GetEngine(ctx).
@ -501,9 +491,9 @@ func (u *User) GitName() string {
// ShortName ellipses username to length // ShortName ellipses username to length
func (u *User) ShortName(length int) string { func (u *User) ShortName(length int) string {
if setting.UI.DefaultShowFullName && len(u.FullName) > 0 { if setting.UI.DefaultShowFullName && len(u.FullName) > 0 {
return base.EllipsisString(u.FullName, length) return util.EllipsisDisplayString(u.FullName, length)
} }
return base.EllipsisString(u.Name, length) return util.EllipsisDisplayString(u.Name, length)
} }
// IsMailable checks if a user is eligible // IsMailable checks if a user is eligible
@ -863,12 +853,38 @@ func GetVerifyUser(ctx context.Context, code string) (user *User) {
return nil return nil
} }
// VerifyUserActiveCode verifies active code when active account type TimeLimitCodePurpose string
func VerifyUserActiveCode(ctx context.Context, code string) (user *User) {
const (
TimeLimitCodeActivateAccount TimeLimitCodePurpose = "activate_account"
TimeLimitCodeActivateEmail TimeLimitCodePurpose = "activate_email"
TimeLimitCodeResetPassword TimeLimitCodePurpose = "reset_password"
)
type TimeLimitCodeOptions struct {
Purpose TimeLimitCodePurpose
NewEmail string
}
func makeTimeLimitCodeHashData(opts *TimeLimitCodeOptions, u *User) string {
return fmt.Sprintf("%s|%d|%s|%s|%s|%s", opts.Purpose, u.ID, strings.ToLower(util.IfZero(opts.NewEmail, u.Email)), u.LowerName, u.Passwd, u.Rands)
}
// GenerateUserTimeLimitCode generates a time-limit code based on user information and given e-mail.
// TODO: need to use cache or db to store it to make sure a code can only be consumed once
func GenerateUserTimeLimitCode(opts *TimeLimitCodeOptions, u *User) string {
data := makeTimeLimitCodeHashData(opts, u)
code := base.CreateTimeLimitCode(data, setting.Service.ActiveCodeLives, time.Now(), nil)
code += hex.EncodeToString([]byte(u.LowerName)) // Add tail hex username
return code
}
// VerifyUserTimeLimitCode verifies the time-limit code
func VerifyUserTimeLimitCode(ctx context.Context, opts *TimeLimitCodeOptions, code string) (user *User) {
if user = GetVerifyUser(ctx, code); user != nil { if user = GetVerifyUser(ctx, code); user != nil {
// time limit code // time limit code
prefix := code[:base.TimeLimitCodeLength] prefix := code[:base.TimeLimitCodeLength]
data := fmt.Sprintf("%d%s%s%s%s", user.ID, user.Email, user.LowerName, user.Passwd, user.Rands) data := makeTimeLimitCodeHashData(opts, user)
if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) {
return user return user
} }

View File

@ -16,11 +16,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
) )
@ -35,7 +35,7 @@ func EncodeSha256(str string) string {
// ShortSha is basically just truncating. // ShortSha is basically just truncating.
// It is DEPRECATED and will be removed in the future. // It is DEPRECATED and will be removed in the future.
func ShortSha(sha1 string) string { func ShortSha(sha1 string) string {
return TruncateString(sha1, 10) return util.TruncateRunes(sha1, 10)
} }
// BasicAuthDecode decode basic auth string // BasicAuthDecode decode basic auth string
@ -116,27 +116,6 @@ func FileSize(s int64) string {
return humanize.IBytes(uint64(s)) return humanize.IBytes(uint64(s))
} }
// EllipsisString returns a truncated short string,
// it appends '...' in the end of the length of string is too large.
func EllipsisString(str string, length int) string {
if length <= 3 {
return "..."
}
if utf8.RuneCountInString(str) <= length {
return str
}
return string([]rune(str)[:length-3]) + "..."
}
// TruncateString returns a truncated string with given limit,
// it returns input string if length is not reached limit.
func TruncateString(str string, limit int) string {
if utf8.RuneCountInString(str) < limit {
return str
}
return string([]rune(str)[:limit])
}
// StringsToInt64s converts a slice of string to a slice of int64. // StringsToInt64s converts a slice of string to a slice of int64.
func StringsToInt64s(strs []string) ([]int64, error) { func StringsToInt64s(strs []string) ([]int64, error) {
if strs == nil { if strs == nil {

View File

@ -113,36 +113,6 @@ func TestFileSize(t *testing.T) {
assert.Equal(t, "2.0 EiB", FileSize(size)) assert.Equal(t, "2.0 EiB", FileSize(size))
} }
func TestEllipsisString(t *testing.T) {
assert.Equal(t, "...", EllipsisString("foobar", 0))
assert.Equal(t, "...", EllipsisString("foobar", 1))
assert.Equal(t, "...", EllipsisString("foobar", 2))
assert.Equal(t, "...", EllipsisString("foobar", 3))
assert.Equal(t, "f...", EllipsisString("foobar", 4))
assert.Equal(t, "fo...", EllipsisString("foobar", 5))
assert.Equal(t, "foobar", EllipsisString("foobar", 6))
assert.Equal(t, "foobar", EllipsisString("foobar", 10))
assert.Equal(t, "测...", EllipsisString("测试文本一二三四", 4))
assert.Equal(t, "测试...", EllipsisString("测试文本一二三四", 5))
assert.Equal(t, "测试文...", EllipsisString("测试文本一二三四", 6))
assert.Equal(t, "测试文本一二三四", EllipsisString("测试文本一二三四", 10))
}
func TestTruncateString(t *testing.T) {
assert.Equal(t, "", TruncateString("foobar", 0))
assert.Equal(t, "f", TruncateString("foobar", 1))
assert.Equal(t, "fo", TruncateString("foobar", 2))
assert.Equal(t, "foo", TruncateString("foobar", 3))
assert.Equal(t, "foob", TruncateString("foobar", 4))
assert.Equal(t, "fooba", TruncateString("foobar", 5))
assert.Equal(t, "foobar", TruncateString("foobar", 6))
assert.Equal(t, "foobar", TruncateString("foobar", 7))
assert.Equal(t, "测试文本", TruncateString("测试文本一二三四", 4))
assert.Equal(t, "测试文本一", TruncateString("测试文本一二三四", 5))
assert.Equal(t, "测试文本一二", TruncateString("测试文本一二三四", 6))
assert.Equal(t, "测试文本一二三", TruncateString("测试文本一二三四", 7))
}
func TestStringsToInt64s(t *testing.T) { func TestStringsToInt64s(t *testing.T) {
testSuccess := func(input []string, expected []int64) { testSuccess := func(input []string, expected []int64) {
result, err := StringsToInt64s(input) result, err := StringsToInt64s(input)

View File

@ -109,7 +109,7 @@ func unmarshal(filename string, content []byte) (*api.IssueTemplate, error) {
it.Content = string(content) it.Content = string(content)
it.Name = path.Base(it.FileName) // paths in Git are always '/' separated - do not use filepath! it.Name = path.Base(it.FileName) // paths in Git are always '/' separated - do not use filepath!
it.About, _ = util.SplitStringAtByteN(it.Content, 80) it.About = util.EllipsisDisplayString(it.Content, 80)
} else { } else {
it.Content = templateBody it.Content = templateBody
if it.About == "" { if it.About == "" {

View File

@ -9,6 +9,7 @@ import (
"strings" "strings"
"code.gitea.io/gitea/modules/markup/common" "code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html" "golang.org/x/net/html"
"golang.org/x/net/html/atom" "golang.org/x/net/html/atom"
@ -171,6 +172,10 @@ func linkProcessor(ctx *RenderContext, node *html.Node) {
} }
uri := node.Data[m[0]:m[1]] uri := node.Data[m[0]:m[1]]
remaining := node.Data[m[1]:]
if util.IsLikelyEllipsisLeftPart(remaining) {
return
}
replaceContent(node, m[0], m[1], createLink(ctx, uri, uri, "" /*link*/)) replaceContent(node, m[0], m[1], createLink(ctx, uri, uri, "" /*link*/))
node = node.NextSibling.NextSibling node = node.NextSibling.NextSibling
} }

View File

@ -206,6 +206,16 @@ func TestRender_links(t *testing.T) {
test( test(
"ftps://gitea.com", "ftps://gitea.com",
`<p>ftps://gitea.com</p>`) `<p>ftps://gitea.com</p>`)
t.Run("LinkEllipsis", func(t *testing.T) {
input := util.EllipsisDisplayString("http://10.1.2.3", 12)
assert.Equal(t, "http://10…", input)
test(input, "<p>http://10…</p>")
input = util.EllipsisDisplayString("http://10.1.2.3", 13)
assert.Equal(t, "http://10.…", input)
test(input, "<p>http://10.…</p>")
})
} }
func TestRender_email(t *testing.T) { func TestRender_email(t *testing.T) {

View File

@ -63,6 +63,7 @@ var UI = struct {
} `ini:"ui.admin"` } `ini:"ui.admin"`
User struct { User struct {
RepoPagingNum int RepoPagingNum int
OrgPagingNum int
} `ini:"ui.user"` } `ini:"ui.user"`
Meta struct { Meta struct {
Author string Author string
@ -127,8 +128,10 @@ var UI = struct {
}, },
User: struct { User: struct {
RepoPagingNum int RepoPagingNum int
OrgPagingNum int
}{ }{
RepoPagingNum: 15, RepoPagingNum: 15,
OrgPagingNum: 15,
}, },
Meta: struct { Meta: struct {
Author string Author string

View File

@ -11,9 +11,9 @@ import (
"strings" "strings"
texttmpl "text/template" texttmpl "text/template"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
) )
var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`) var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
@ -24,7 +24,7 @@ func mailSubjectTextFuncMap() texttmpl.FuncMap {
"dict": dict, "dict": dict,
"Eval": evalTokens, "Eval": evalTokens,
"EllipsisString": base.EllipsisString, "EllipsisString": util.EllipsisDisplayString,
"AppName": func() string { "AppName": func() string {
return setting.AppName return setting.AppName
}, },

View File

@ -8,7 +8,7 @@ import (
"html/template" "html/template"
"strings" "strings"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/util"
) )
type StringUtils struct{} type StringUtils struct{}
@ -54,7 +54,7 @@ func (su *StringUtils) Cut(s, sep string) []any {
} }
func (su *StringUtils) EllipsisString(s string, maxLength int) string { func (su *StringUtils) EllipsisString(s string, maxLength int) string {
return base.EllipsisString(s, maxLength) return util.EllipsisDisplayString(s, maxLength)
} }
func (su *StringUtils) ToUpper(s string) string { func (su *StringUtils) ToUpper(s string) string {

View File

@ -3,7 +3,10 @@
package util package util
import "unsafe" import (
"strings"
"unsafe"
)
func isSnakeCaseUpper(c byte) bool { func isSnakeCaseUpper(c byte) bool {
return 'A' <= c && c <= 'Z' return 'A' <= c && c <= 'Z'
@ -95,3 +98,15 @@ func UnsafeBytesToString(b []byte) string {
func UnsafeStringToBytes(s string) []byte { func UnsafeStringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s)) return unsafe.Slice(unsafe.StringData(s), len(s))
} }
// SplitTrimSpace splits the string at given separator and trims leading and trailing space
func SplitTrimSpace(input, sep string) []string {
input = strings.TrimSpace(input)
var stringList []string
for _, s := range strings.Split(input, sep) {
if s = strings.TrimSpace(s); s != "" {
stringList = append(stringList, s)
}
}
return stringList
}

View File

@ -45,3 +45,8 @@ func TestToSnakeCase(t *testing.T) {
assert.Equal(t, expected, ToSnakeCase(input)) assert.Equal(t, expected, ToSnakeCase(input))
} }
} }
func TestSplitTrimSpace(t *testing.T) {
assert.Equal(t, []string{"a", "b", "c"}, SplitTrimSpace("a\nb\nc", "\n"))
assert.Equal(t, []string{"a", "b"}, SplitTrimSpace("\r\na\n\r\nb\n\n", "\n"))
}

View File

@ -5,6 +5,7 @@ package util
import ( import (
"strings" "strings"
"unicode"
"unicode/utf8" "unicode/utf8"
) )
@ -14,43 +15,110 @@ const (
asciiEllipsis = "..." asciiEllipsis = "..."
) )
// SplitStringAtByteN splits a string at byte n accounting for rune boundaries. (Combining characters are not accounted for.) func IsLikelyEllipsisLeftPart(s string) bool {
func SplitStringAtByteN(input string, n int) (left, right string) { return strings.HasSuffix(s, utf8Ellipsis) || strings.HasSuffix(s, asciiEllipsis)
if len(input) <= n { }
return input, ""
func ellipsisGuessDisplayWidth(r rune) int {
// To make the truncated string as long as possible,
// CJK/emoji chars are considered as 2-ASCII width but not 3-4 bytes width.
// Here we only make the best guess (better than counting them in bytes),
// it's impossible to 100% correctly determine the width of a rune without a real font and render.
//
// ATTENTION: the guessed width can't be zero, more details in ellipsisDisplayString's comment
if r <= 255 {
return 1
} }
if !utf8.ValidString(input) { switch {
if n-3 < 0 { case r == '\u3000': /* ideographic (CJK) characters, still use 2 */
return input, "" return 2
case unicode.Is(unicode.M, r), /* (Mark) */
unicode.Is(unicode.Cf, r), /* (Other, format) */
unicode.Is(unicode.Cs, r), /* (Other, surrogate) */
unicode.Is(unicode.Z /* (Space) */, r):
return 1
default:
return 2
} }
return input[:n-3] + asciiEllipsis, asciiEllipsis + input[n-3:] }
// EllipsisDisplayString returns a truncated short string for display purpose.
// The length is the approximate number of ASCII-width in the string (CJK/emoji are 2-ASCII width)
// It appends "…" or "..." at the end of truncated string.
// It guarantees the length of the returned runes doesn't exceed the limit.
func EllipsisDisplayString(str string, limit int) string {
s, _, _, _ := ellipsisDisplayString(str, limit)
return s
}
// EllipsisDisplayStringX works like EllipsisDisplayString while it also returns the right part
func EllipsisDisplayStringX(str string, limit int) (left, right string) {
left, offset, truncated, encounterInvalid := ellipsisDisplayString(str, limit)
if truncated {
right = str[offset:]
r, _ := utf8.DecodeRune(UnsafeStringToBytes(right))
encounterInvalid = encounterInvalid || r == utf8.RuneError
ellipsis := utf8Ellipsis
if encounterInvalid {
ellipsis = asciiEllipsis
}
right = ellipsis + right
}
return left, right
}
func ellipsisDisplayString(str string, limit int) (res string, offset int, truncated, encounterInvalid bool) {
if len(str) <= limit {
return str, len(str), false, false
} }
end := 0 // To future maintainers: this logic must guarantee that the length of the returned runes doesn't exceed the limit,
for end <= n-3 { // because the returned string will also be used as database value. UTF-8 VARCHAR(10) could store 10 rune characters,
_, size := utf8.DecodeRuneInString(input[end:]) // So each rune must be countered as at least 1 width.
if end+size > n-3 { // Even if there are some special Unicode characters (zero-width, combining, etc.), they should NEVER be counted as zero.
pos, used := 0, 0
for i, r := range str {
encounterInvalid = encounterInvalid || r == utf8.RuneError
pos = i
runeWidth := ellipsisGuessDisplayWidth(r)
if used+runeWidth+3 > limit {
break break
} }
end += size used += runeWidth
offset += utf8.RuneLen(r)
} }
return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:] // if the remaining are fewer than 3 runes, then maybe we could add them, no need to ellipse
} if len(str)-pos <= 12 {
var nextCnt, nextWidth int
// SplitTrimSpace splits the string at given separator and trims leading and trailing space for _, r := range str[pos:] {
func SplitTrimSpace(input, sep string) []string { if nextCnt >= 4 {
// Trim initial leading & trailing space break
input = strings.TrimSpace(input)
// replace CRLF with LF
input = strings.ReplaceAll(input, "\r\n", "\n")
var stringList []string
for _, s := range strings.Split(input, sep) {
// trim leading and trailing space
stringList = append(stringList, strings.TrimSpace(s))
} }
nextWidth += ellipsisGuessDisplayWidth(r)
return stringList nextCnt++
}
if nextCnt <= 3 && used+nextWidth <= limit {
return str, len(str), false, false
}
}
if limit < 3 {
// if the limit is so small, do not add ellipsis
return str[:offset], offset, true, false
}
ellipsis := utf8Ellipsis
if encounterInvalid {
ellipsis = asciiEllipsis
}
return str[:offset] + ellipsis, offset, true, encounterInvalid
}
// TruncateRunes returns a truncated string with given rune limit,
// it returns input string if its rune length doesn't exceed the limit.
func TruncateRunes(str string, limit int) string {
if utf8.RuneCountInString(str) < limit {
return str
}
return string([]rune(str)[:limit])
} }

View File

@ -4,43 +4,127 @@
package util package util
import ( import (
"fmt"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSplitString(t *testing.T) { func TestEllipsisGuessDisplayWidth(t *testing.T) {
type testCase struct { cases := []struct {
input string r string
n int want int
leftSub string }{
ellipsis string {r: "a", want: 1},
{r: "é", want: 1},
{r: "测", want: 2},
{r: "⚽", want: 2},
{r: "☁️", want: 3}, // 2 runes, it has a mark
{r: "\u200B", want: 1}, // ZWSP
{r: "\u3000", want: 2}, // ideographic space
} }
for _, c := range cases {
test := func(tc []*testCase, f func(input string, n int) (left, right string)) { t.Run(c.r, func(t *testing.T) {
for _, c := range tc { w := 0
l, r := f(c.input, c.n) for _, r := range c.r {
if c.ellipsis != "" { w += ellipsisGuessDisplayWidth(r)
assert.Equal(t, c.leftSub+c.ellipsis, l, "test split %q at %d, expected leftSub: %q", c.input, c.n, c.leftSub)
assert.Equal(t, c.ellipsis+c.input[len(c.leftSub):], r, "test split %s at %d, expected rightSub: %q", c.input, c.n, c.input[len(c.leftSub):])
} else {
assert.Equal(t, c.leftSub, l, "test split %q at %d, expected leftSub: %q", c.input, c.n, c.leftSub)
assert.Empty(t, r, "test split %q at %d, expected rightSub: %q", c.input, c.n, "")
} }
assert.Equal(t, c.want, w, "hex=% x", []byte(c.r))
})
} }
} }
tc := []*testCase{ func TestEllipsisString(t *testing.T) {
{"abc123xyz", 0, "", utf8Ellipsis}, cases := []struct {
{"abc123xyz", 1, "", utf8Ellipsis}, limit int
{"abc123xyz", 4, "a", utf8Ellipsis},
{"啊bc123xyz", 4, "", utf8Ellipsis}, input, left, right string
{"啊bc123xyz", 6, "啊", utf8Ellipsis}, }{
{"啊bc", 5, "啊bc", ""}, {limit: 0, input: "abcde", left: "", right: "…abcde"},
{"啊bc", 6, "啊bc", ""}, {limit: 1, input: "abcde", left: "", right: "…abcde"},
{"abc\xef\x03\xfe", 3, "", asciiEllipsis}, {limit: 2, input: "abcde", left: "", right: "…abcde"},
{"abc\xef\x03\xfe", 4, "a", asciiEllipsis}, {limit: 3, input: "abcde", left: "…", right: "…abcde"},
{"\xef\x03", 1, "\xef\x03", ""}, {limit: 4, input: "abcde", left: "a…", right: "…bcde"},
} {limit: 5, input: "abcde", left: "abcde", right: ""},
test(tc, SplitStringAtByteN) {limit: 6, input: "abcde", left: "abcde", right: ""},
{limit: 7, input: "abcde", left: "abcde", right: ""},
// a CJK char or emoji is considered as 2-ASCII width, the ellipsis is 3-ASCII width
{limit: 0, input: "测试文本", left: "", right: "…测试文本"},
{limit: 1, input: "测试文本", left: "", right: "…测试文本"},
{limit: 2, input: "测试文本", left: "", right: "…测试文本"},
{limit: 3, input: "测试文本", left: "…", right: "…测试文本"},
{limit: 4, input: "测试文本", left: "…", right: "…测试文本"},
{limit: 5, input: "测试文本", left: "测…", right: "…试文本"},
{limit: 6, input: "测试文本", left: "测…", right: "…试文本"},
{limit: 7, input: "测试文本", left: "测试…", right: "…文本"},
{limit: 8, input: "测试文本", left: "测试文本", right: ""},
{limit: 9, input: "测试文本", left: "测试文本", right: ""},
{limit: 6, input: "测试abc", left: "测…", right: "…试abc"},
{limit: 7, input: "测试abc", left: "测试abc", right: ""}, // exactly 7-width
{limit: 8, input: "测试abc", left: "测试abc", right: ""},
{limit: 7, input: "测abc试啊", left: "测ab…", right: "…c试啊"},
{limit: 8, input: "测abc试啊", left: "测abc…", right: "…试啊"},
{limit: 9, input: "测abc试啊", left: "测abc试啊", right: ""}, // exactly 9-width
{limit: 10, input: "测abc试啊", left: "测abc试啊", right: ""},
}
for _, c := range cases {
t.Run(fmt.Sprintf("%s(%d)", c.input, c.limit), func(t *testing.T) {
left, right := EllipsisDisplayStringX(c.input, c.limit)
assert.Equal(t, c.left, left, "left")
assert.Equal(t, c.right, right, "right")
})
}
t.Run("LongInput", func(t *testing.T) {
left, right := EllipsisDisplayStringX(strings.Repeat("abc", 240), 90)
assert.Equal(t, strings.Repeat("abc", 29)+"…", left)
assert.Equal(t, "…"+strings.Repeat("abc", 211), right)
})
t.Run("InvalidUtf8", func(t *testing.T) {
invalidCases := []struct {
limit int
left, right string
}{
{limit: 0, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"},
{limit: 1, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"},
{limit: 2, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"},
{limit: 3, left: "...", right: "...\xef\x03\xfe\xef\x03\xfe"},
{limit: 4, left: "...", right: "...\xef\x03\xfe\xef\x03\xfe"},
{limit: 5, left: "\xef\x03\xfe...", right: "...\xef\x03\xfe"},
{limit: 6, left: "\xef\x03\xfe\xef\x03\xfe", right: ""},
{limit: 7, left: "\xef\x03\xfe\xef\x03\xfe", right: ""},
}
for _, c := range invalidCases {
t.Run(fmt.Sprintf("%d", c.limit), func(t *testing.T) {
left, right := EllipsisDisplayStringX("\xef\x03\xfe\xef\x03\xfe", c.limit)
assert.Equal(t, c.left, left, "left")
assert.Equal(t, c.right, right, "right")
})
}
})
t.Run("IsLikelyEllipsisLeftPart", func(t *testing.T) {
assert.True(t, IsLikelyEllipsisLeftPart("abcde…"))
assert.True(t, IsLikelyEllipsisLeftPart("abcde..."))
})
}
func TestTruncateRunes(t *testing.T) {
assert.Equal(t, "", TruncateRunes("", 0))
assert.Equal(t, "", TruncateRunes("", 1))
assert.Equal(t, "", TruncateRunes("ab", 0))
assert.Equal(t, "a", TruncateRunes("ab", 1))
assert.Equal(t, "ab", TruncateRunes("ab", 2))
assert.Equal(t, "ab", TruncateRunes("ab", 3))
assert.Equal(t, "", TruncateRunes("测试", 0))
assert.Equal(t, "测", TruncateRunes("测试", 1))
assert.Equal(t, "测试", TruncateRunes("测试", 2))
assert.Equal(t, "测试", TruncateRunes("测试", 3))
} }

View File

@ -4,18 +4,14 @@
package web package web
import ( import (
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"reflect" "reflect"
"regexp"
"strings" "strings"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
"gitea.com/go-chi/binding" "gitea.com/go-chi/binding"
@ -45,7 +41,7 @@ func GetForm(dataStore reqctx.RequestDataStore) any {
// Router defines a route based on chi's router // Router defines a route based on chi's router
type Router struct { type Router struct {
chiRouter chi.Router chiRouter *chi.Mux
curGroupPrefix string curGroupPrefix string
curMiddlewares []any curMiddlewares []any
} }
@ -97,16 +93,21 @@ func isNilOrFuncNil(v any) bool {
return r.Kind() == reflect.Func && r.IsNil() return r.Kind() == reflect.Func && r.IsNil()
} }
func (r *Router) wrapMiddlewareAndHandler(h []any) ([]func(http.Handler) http.Handler, http.HandlerFunc) { func wrapMiddlewareAndHandler(curMiddlewares, h []any) ([]func(http.Handler) http.Handler, http.HandlerFunc) {
handlerProviders := make([]func(http.Handler) http.Handler, 0, len(r.curMiddlewares)+len(h)+1) handlerProviders := make([]func(http.Handler) http.Handler, 0, len(curMiddlewares)+len(h)+1)
for _, m := range r.curMiddlewares { for _, m := range curMiddlewares {
if !isNilOrFuncNil(m) { if !isNilOrFuncNil(m) {
handlerProviders = append(handlerProviders, toHandlerProvider(m)) handlerProviders = append(handlerProviders, toHandlerProvider(m))
} }
} }
for _, m := range h { if len(h) == 0 {
panic("no endpoint handler provided")
}
for i, m := range h {
if !isNilOrFuncNil(m) { if !isNilOrFuncNil(m) {
handlerProviders = append(handlerProviders, toHandlerProvider(m)) handlerProviders = append(handlerProviders, toHandlerProvider(m))
} else if i == len(h)-1 {
panic("endpoint handler can't be nil")
} }
} }
middlewares := handlerProviders[:len(handlerProviders)-1] middlewares := handlerProviders[:len(handlerProviders)-1]
@ -121,7 +122,7 @@ func (r *Router) wrapMiddlewareAndHandler(h []any) ([]func(http.Handler) http.Ha
// Methods adds the same handlers for multiple http "methods" (separated by ","). // Methods adds the same handlers for multiple http "methods" (separated by ",").
// If any method is invalid, the lower level router will panic. // If any method is invalid, the lower level router will panic.
func (r *Router) Methods(methods, pattern string, h ...any) { func (r *Router) Methods(methods, pattern string, h ...any) {
middlewares, handlerFunc := r.wrapMiddlewareAndHandler(h) middlewares, handlerFunc := wrapMiddlewareAndHandler(r.curMiddlewares, h)
fullPattern := r.getPattern(pattern) fullPattern := r.getPattern(pattern)
if strings.Contains(methods, ",") { if strings.Contains(methods, ",") {
methods := strings.Split(methods, ",") methods := strings.Split(methods, ",")
@ -141,7 +142,7 @@ func (r *Router) Mount(pattern string, subRouter *Router) {
// Any delegate requests for all methods // Any delegate requests for all methods
func (r *Router) Any(pattern string, h ...any) { func (r *Router) Any(pattern string, h ...any) {
middlewares, handlerFunc := r.wrapMiddlewareAndHandler(h) middlewares, handlerFunc := wrapMiddlewareAndHandler(r.curMiddlewares, h)
r.chiRouter.With(middlewares...).HandleFunc(r.getPattern(pattern), handlerFunc) r.chiRouter.With(middlewares...).HandleFunc(r.getPattern(pattern), handlerFunc)
} }
@ -185,17 +186,6 @@ func (r *Router) NotFound(h http.HandlerFunc) {
r.chiRouter.NotFound(h) r.chiRouter.NotFound(h)
} }
type pathProcessorParam struct {
name string
captureGroup int
}
type PathProcessor struct {
methods container.Set[string]
re *regexp.Regexp
params []pathProcessorParam
}
func (r *Router) normalizeRequestPath(resp http.ResponseWriter, req *http.Request, next http.Handler) { func (r *Router) normalizeRequestPath(resp http.ResponseWriter, req *http.Request, next http.Handler) {
normalized := false normalized := false
normalizedPath := req.URL.EscapedPath() normalizedPath := req.URL.EscapedPath()
@ -253,121 +243,16 @@ func (r *Router) normalizeRequestPath(resp http.ResponseWriter, req *http.Reques
next.ServeHTTP(resp, req) next.ServeHTTP(resp, req)
} }
func (p *PathProcessor) ProcessRequestPath(chiCtx *chi.Context, path string) bool {
if !p.methods.Contains(chiCtx.RouteMethod) {
return false
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
pathMatches := p.re.FindStringSubmatchIndex(path) // Golang regexp match pairs [start, end, start, end, ...]
if pathMatches == nil {
return false
}
var paramMatches [][]int
for i := 2; i < len(pathMatches); {
paramMatches = append(paramMatches, []int{pathMatches[i], pathMatches[i+1]})
pmIdx := len(paramMatches) - 1
end := pathMatches[i+1]
i += 2
for ; i < len(pathMatches); i += 2 {
if pathMatches[i] >= end {
break
}
paramMatches[pmIdx] = append(paramMatches[pmIdx], pathMatches[i], pathMatches[i+1])
}
}
for i, pm := range paramMatches {
groupIdx := p.params[i].captureGroup * 2
chiCtx.URLParams.Add(p.params[i].name, path[pm[groupIdx]:pm[groupIdx+1]])
}
return true
}
func NewPathProcessor(methods, pattern string) *PathProcessor {
p := &PathProcessor{methods: make(container.Set[string])}
for _, method := range strings.Split(methods, ",") {
p.methods.Add(strings.TrimSpace(method))
}
re := []byte{'^'}
lastEnd := 0
for lastEnd < len(pattern) {
start := strings.IndexByte(pattern[lastEnd:], '<')
if start == -1 {
re = append(re, pattern[lastEnd:]...)
break
}
end := strings.IndexByte(pattern[lastEnd+start:], '>')
if end == -1 {
panic(fmt.Sprintf("invalid pattern: %s", pattern))
}
re = append(re, pattern[lastEnd:lastEnd+start]...)
partName, partExp, _ := strings.Cut(pattern[lastEnd+start+1:lastEnd+start+end], ":")
lastEnd += start + end + 1
// TODO: it could support to specify a "capture group" for the name, for example: "/<name[2]:(\d)-(\d)>"
// it is not used so no need to implement it now
param := pathProcessorParam{}
if partExp == "*" {
re = append(re, "(.*?)/?"...)
if lastEnd < len(pattern) {
if pattern[lastEnd] == '/' {
lastEnd++
}
}
} else {
partExp = util.IfZero(partExp, "[^/]+")
re = append(re, '(')
re = append(re, partExp...)
re = append(re, ')')
}
param.name = partName
p.params = append(p.params, param)
}
re = append(re, '$')
reStr := string(re)
p.re = regexp.MustCompile(reStr)
return p
}
// Combo delegates requests to Combo // Combo delegates requests to Combo
func (r *Router) Combo(pattern string, h ...any) *Combo { func (r *Router) Combo(pattern string, h ...any) *Combo {
return &Combo{r, pattern, h} return &Combo{r, pattern, h}
} }
// Combo represents a tiny group routes with same pattern // PathGroup creates a group of paths which could be matched by regexp.
type Combo struct { // It is only designed to resolve some special cases which chi router can't handle.
r *Router // For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient).
pattern string func (r *Router) PathGroup(pattern string, fn func(g *RouterPathGroup), h ...any) {
h []any g := &RouterPathGroup{r: r, pathParam: "*"}
} fn(g)
r.Any(pattern, append(h, g.ServeHTTP)...)
// Get delegates Get method
func (c *Combo) Get(h ...any) *Combo {
c.r.Get(c.pattern, append(c.h, h...)...)
return c
}
// Post delegates Post method
func (c *Combo) Post(h ...any) *Combo {
c.r.Post(c.pattern, append(c.h, h...)...)
return c
}
// Delete delegates Delete method
func (c *Combo) Delete(h ...any) *Combo {
c.r.Delete(c.pattern, append(c.h, h...)...)
return c
}
// Put delegates Put method
func (c *Combo) Put(h ...any) *Combo {
c.r.Put(c.pattern, append(c.h, h...)...)
return c
}
// Patch delegates Patch method
func (c *Combo) Patch(h ...any) *Combo {
c.r.Patch(c.pattern, append(c.h, h...)...)
return c
} }

View File

@ -0,0 +1,41 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
// Combo represents a tiny group routes with same pattern
type Combo struct {
r *Router
pattern string
h []any
}
// Get delegates Get method
func (c *Combo) Get(h ...any) *Combo {
c.r.Get(c.pattern, append(c.h, h...)...)
return c
}
// Post delegates Post method
func (c *Combo) Post(h ...any) *Combo {
c.r.Post(c.pattern, append(c.h, h...)...)
return c
}
// Delete delegates Delete method
func (c *Combo) Delete(h ...any) *Combo {
c.r.Delete(c.pattern, append(c.h, h...)...)
return c
}
// Put delegates Put method
func (c *Combo) Put(h ...any) *Combo {
c.r.Put(c.pattern, append(c.h, h...)...)
return c
}
// Patch delegates Patch method
func (c *Combo) Patch(h ...any) *Combo {
c.r.Patch(c.pattern, append(c.h, h...)...)
return c
}

135
modules/web/router_path.go Normal file
View File

@ -0,0 +1,135 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"fmt"
"net/http"
"regexp"
"strings"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/util"
"github.com/go-chi/chi/v5"
)
type RouterPathGroup struct {
r *Router
pathParam string
matchers []*routerPathMatcher
}
func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
chiCtx := chi.RouteContext(req.Context())
path := chiCtx.URLParam(g.pathParam)
for _, m := range g.matchers {
if m.matchPath(chiCtx, path) {
handler := m.handlerFunc
for i := len(m.middlewares) - 1; i >= 0; i-- {
handler = m.middlewares[i](handler).ServeHTTP
}
handler(resp, req)
return
}
}
g.r.chiRouter.NotFoundHandler().ServeHTTP(resp, req)
}
// MatchPath matches the request method, and uses regexp to match the path.
// The pattern uses "<...>" to define path parameters, for example: "/<name>" (different from chi router)
// It is only designed to resolve some special cases which chi router can't handle.
// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient).
func (g *RouterPathGroup) MatchPath(methods, pattern string, h ...any) {
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
}
type routerPathParam struct {
name string
captureGroup int
}
type routerPathMatcher struct {
methods container.Set[string]
re *regexp.Regexp
params []routerPathParam
middlewares []func(http.Handler) http.Handler
handlerFunc http.HandlerFunc
}
func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool {
if !p.methods.Contains(chiCtx.RouteMethod) {
return false
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
pathMatches := p.re.FindStringSubmatchIndex(path) // Golang regexp match pairs [start, end, start, end, ...]
if pathMatches == nil {
return false
}
var paramMatches [][]int
for i := 2; i < len(pathMatches); {
paramMatches = append(paramMatches, []int{pathMatches[i], pathMatches[i+1]})
pmIdx := len(paramMatches) - 1
end := pathMatches[i+1]
i += 2
for ; i < len(pathMatches); i += 2 {
if pathMatches[i] >= end {
break
}
paramMatches[pmIdx] = append(paramMatches[pmIdx], pathMatches[i], pathMatches[i+1])
}
}
for i, pm := range paramMatches {
groupIdx := p.params[i].captureGroup * 2
chiCtx.URLParams.Add(p.params[i].name, path[pm[groupIdx]:pm[groupIdx+1]])
}
return true
}
func newRouterPathMatcher(methods, pattern string, h ...any) *routerPathMatcher {
middlewares, handlerFunc := wrapMiddlewareAndHandler(nil, h)
p := &routerPathMatcher{methods: make(container.Set[string]), middlewares: middlewares, handlerFunc: handlerFunc}
for _, method := range strings.Split(methods, ",") {
p.methods.Add(strings.TrimSpace(method))
}
re := []byte{'^'}
lastEnd := 0
for lastEnd < len(pattern) {
start := strings.IndexByte(pattern[lastEnd:], '<')
if start == -1 {
re = append(re, pattern[lastEnd:]...)
break
}
end := strings.IndexByte(pattern[lastEnd+start:], '>')
if end == -1 {
panic(fmt.Sprintf("invalid pattern: %s", pattern))
}
re = append(re, pattern[lastEnd:lastEnd+start]...)
partName, partExp, _ := strings.Cut(pattern[lastEnd+start+1:lastEnd+start+end], ":")
lastEnd += start + end + 1
// TODO: it could support to specify a "capture group" for the name, for example: "/<name[2]:(\d)-(\d)>"
// it is not used so no need to implement it now
param := routerPathParam{}
if partExp == "*" {
re = append(re, "(.*?)/?"...)
if lastEnd < len(pattern) && pattern[lastEnd] == '/' {
lastEnd++ // the "*" pattern is able to handle the last slash, so skip it
}
} else {
partExp = util.IfZero(partExp, "[^/]+")
re = append(re, '(')
re = append(re, partExp...)
re = append(re, ')')
}
param.name = partName
p.params = append(p.params, param)
}
re = append(re, '$')
reStr := string(re)
p.re = regexp.MustCompile(reStr)
return p
}

View File

@ -27,17 +27,21 @@ func chiURLParamsToMap(chiCtx *chi.Context) map[string]string {
} }
m[key] = pathParams.Values[i] m[key] = pathParams.Values[i]
} }
return m return util.Iif(len(m) == 0, nil, m)
} }
func TestPathProcessor(t *testing.T) { func TestPathProcessor(t *testing.T) {
testProcess := func(pattern, uri string, expectedPathParams map[string]string) { testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
chiCtx := chi.NewRouteContext() chiCtx := chi.NewRouteContext()
chiCtx.RouteMethod = "GET" chiCtx.RouteMethod = "GET"
p := NewPathProcessor("GET", pattern) p := newRouterPathMatcher("GET", pattern, http.NotFound)
assert.True(t, p.ProcessRequestPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri) assert.True(t, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri)
assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri) assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri)
} }
// the "<...>" is intentionally designed to distinguish from chi's path parameters, because:
// 1. their behaviors are totally different, we do not want to mislead developers
// 2. we can write regexp in "<name:\w{3,4}>" easily and parse it easily
testProcess("/<p1>/<p2>", "/a/b", map[string]string{"p1": "a", "p2": "b"}) testProcess("/<p1>/<p2>", "/a/b", map[string]string{"p1": "a", "p2": "b"})
testProcess("/<p1:*>", "", map[string]string{"p1": ""}) // this is a special case, because chi router could use empty path testProcess("/<p1:*>", "", map[string]string{"p1": ""}) // this is a special case, because chi router could use empty path
testProcess("/<p1:*>", "/", map[string]string{"p1": ""}) testProcess("/<p1:*>", "/", map[string]string{"p1": ""})
@ -67,24 +71,31 @@ func TestRouter(t *testing.T) {
} }
} }
stopMark := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) {
mark := util.OptionalArg(optMark, "")
return func(resp http.ResponseWriter, req *http.Request) {
if stop := req.FormValue("stop"); stop != "" && (mark == "" || mark == stop) {
h(stop)(resp, req)
resp.WriteHeader(http.StatusOK)
}
}
}
r := NewRouter() r := NewRouter()
r.NotFound(h("not-found:/"))
r.Get("/{username}/{reponame}/{type:issues|pulls}", h("list-issues-a")) // this one will never be called r.Get("/{username}/{reponame}/{type:issues|pulls}", h("list-issues-a")) // this one will never be called
r.Group("/{username}/{reponame}", func() { r.Group("/{username}/{reponame}", func() {
r.Get("/{type:issues|pulls}", h("list-issues-b")) r.Get("/{type:issues|pulls}", h("list-issues-b"))
r.Group("", func() { r.Group("", func() {
r.Get("/{type:issues|pulls}/{index}", h("view-issue")) r.Get("/{type:issues|pulls}/{index}", h("view-issue"))
}, func(resp http.ResponseWriter, req *http.Request) { }, stopMark())
if stop := req.FormValue("stop"); stop != "" {
h(stop)(resp, req)
resp.WriteHeader(http.StatusOK)
}
})
r.Group("/issues/{index}", func() { r.Group("/issues/{index}", func() {
r.Post("/update", h("update-issue")) r.Post("/update", h("update-issue"))
}) })
}) })
m := NewRouter() m := NewRouter()
m.NotFound(h("not-found:/api/v1"))
r.Mount("/api/v1", m) r.Mount("/api/v1", m)
m.Group("/repos", func() { m.Group("/repos", func() {
m.Group("/{username}/{reponame}", func() { m.Group("/{username}/{reponame}", func() {
@ -96,11 +107,14 @@ func TestRouter(t *testing.T) {
m.Patch("", h()) m.Patch("", h())
m.Delete("", h()) m.Delete("", h())
}) })
m.PathGroup("/*", func(g *RouterPathGroup) {
g.MatchPath("GET", `/<dir:*>/<file:[a-z]{1,2}>`, stopMark("s2"), h("match-path"))
}, stopMark("s1"))
}) })
}) })
}) })
testRoute := func(methodPath string, expected resultStruct) { testRoute := func(t *testing.T, methodPath string, expected resultStruct) {
t.Run(methodPath, func(t *testing.T) { t.Run(methodPath, func(t *testing.T) {
res = resultStruct{} res = resultStruct{}
methodPathFields := strings.Fields(methodPath) methodPathFields := strings.Fields(methodPath)
@ -111,24 +125,24 @@ func TestRouter(t *testing.T) {
}) })
} }
t.Run("Root Router", func(t *testing.T) { t.Run("RootRouter", func(t *testing.T) {
testRoute("GET /the-user/the-repo/other", resultStruct{}) testRoute(t, "GET /the-user/the-repo/other", resultStruct{method: "GET", handlerMark: "not-found:/"})
testRoute("GET /the-user/the-repo/pulls", resultStruct{ testRoute(t, "GET /the-user/the-repo/pulls", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"},
handlerMark: "list-issues-b", handlerMark: "list-issues-b",
}) })
testRoute("GET /the-user/the-repo/issues/123", resultStruct{ testRoute(t, "GET /the-user/the-repo/issues/123", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
handlerMark: "view-issue", handlerMark: "view-issue",
}) })
testRoute("GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{ testRoute(t, "GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
handlerMark: "hijack", handlerMark: "hijack",
}) })
testRoute("POST /the-user/the-repo/issues/123/update", resultStruct{ testRoute(t, "POST /the-user/the-repo/issues/123/update", resultStruct{
method: "POST", method: "POST",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"},
handlerMark: "update-issue", handlerMark: "update-issue",
@ -136,31 +150,57 @@ func TestRouter(t *testing.T) {
}) })
t.Run("Sub Router", func(t *testing.T) { t.Run("Sub Router", func(t *testing.T) {
testRoute("GET /api/v1/repos/the-user/the-repo/branches", resultStruct{ testRoute(t, "GET /api/v1/other", resultStruct{method: "GET", handlerMark: "not-found:/api/v1"})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"},
}) })
testRoute("POST /api/v1/repos/the-user/the-repo/branches", resultStruct{ testRoute(t, "POST /api/v1/repos/the-user/the-repo/branches", resultStruct{
method: "POST", method: "POST",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"},
}) })
testRoute("GET /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/master", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
}) })
testRoute("PATCH /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ testRoute(t, "PATCH /api/v1/repos/the-user/the-repo/branches/master", resultStruct{
method: "PATCH", method: "PATCH",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
}) })
testRoute("DELETE /api/v1/repos/the-user/the-repo/branches/master", resultStruct{ testRoute(t, "DELETE /api/v1/repos/the-user/the-repo/branches/master", resultStruct{
method: "DELETE", method: "DELETE",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
}) })
}) })
t.Run("MatchPath", func(t *testing.T) {
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
handlerMark: "match-path",
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{
method: "GET",
pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"},
handlerMark: "not-found:/api/v1",
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s1", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"},
handlerMark: "s1",
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s2", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
handlerMark: "s2",
})
})
} }
func TestRouteNormalizePath(t *testing.T) { func TestRouteNormalizePath(t *testing.T) {

View File

@ -647,6 +647,7 @@ joined_on=Přidal/a se %s
repositories=Repozitáře repositories=Repozitáře
activity=Veřejná aktivita activity=Veřejná aktivita
followers=Sledující followers=Sledující
show_more=Zobrazit více
starred=Oblíbené repozitáře starred=Oblíbené repozitáře
watched=Sledované repozitáře watched=Sledované repozitáře
code=Kód code=Kód

View File

@ -618,6 +618,7 @@ joined_on=Beigetreten am %s
repositories=Repositories repositories=Repositories
activity=Öffentliche Aktivität activity=Öffentliche Aktivität
followers=Follower followers=Follower
show_more=Mehr anzeigen
starred=Favoriten starred=Favoriten
watched=Beobachtete Repositories watched=Beobachtete Repositories
code=Quelltext code=Quelltext

View File

@ -580,6 +580,7 @@ joined_on=Εγγράφηκε την %s
repositories=Αποθετήρια repositories=Αποθετήρια
activity=Δημόσια Δραστηριότητα activity=Δημόσια Δραστηριότητα
followers=Ακόλουθοι followers=Ακόλουθοι
show_more=Εμφάνιση Περισσότερων
starred=Αγαπημένα Αποθετήρια starred=Αγαπημένα Αποθετήρια
watched=Ακολουθούμενα Αποθετήρια watched=Ακολουθούμενα Αποθετήρια
code=Κώδικας code=Κώδικας

View File

@ -649,6 +649,7 @@ joined_on = Joined on %s
repositories = Repositories repositories = Repositories
activity = Public Activity activity = Public Activity
followers = Followers followers = Followers
show_more = Show More
starred = Starred Repositories starred = Starred Repositories
watched = Watched Repositories watched = Watched Repositories
code = Code code = Code

View File

@ -577,6 +577,7 @@ joined_on=Se unió el %s
repositories=Repositorios repositories=Repositorios
activity=Actividad pública activity=Actividad pública
followers=Seguidores followers=Seguidores
show_more=Ver más
starred=Repositorios Favoritos starred=Repositorios Favoritos
watched=Repositorios seguidos watched=Repositorios seguidos
code=Código code=Código

View File

@ -463,6 +463,7 @@ change_avatar=تغییر آواتار…
repositories=مخازن repositories=مخازن
activity=فعالیت های عمومی activity=فعالیت های عمومی
followers=دنبال کنندگان followers=دنبال کنندگان
show_more=نمایش بیشتر
starred=مخان ستاره دار starred=مخان ستاره دار
watched=مخازنی که دنبال می‌شوند watched=مخازنی که دنبال می‌شوند
projects=پروژه‌ها projects=پروژه‌ها

View File

@ -649,6 +649,7 @@ joined_on=Inscrit le %s
repositories=Dépôts repositories=Dépôts
activity=Activité publique activity=Activité publique
followers=abonnés followers=abonnés
show_more=Voir plus
starred=Dépôts favoris starred=Dépôts favoris
watched=Dépôts surveillés watched=Dépôts surveillés
code=Code code=Code

View File

@ -649,6 +649,7 @@ joined_on=Cláraigh ar %s
repositories=Stórais repositories=Stórais
activity=Gníomhaíocht Phoiblí activity=Gníomhaíocht Phoiblí
followers=Leantóirí followers=Leantóirí
show_more=Taispeáin Tuilleadh
starred=Stórais Réaltaithe starred=Stórais Réaltaithe
watched=Stórais Breathnaithe watched=Stórais Breathnaithe
code=Cód code=Cód
@ -1945,6 +1946,8 @@ pulls.delete.title=Scrios an t-iarratas tarraingthe seo?
pulls.delete.text=An bhfuil tú cinnte gur mhaith leat an t-iarratas tarraingthe seo a scriosadh? (Bainfidh sé seo an t-inneachar go léir go buan. Smaoinigh ar é a dhúnadh ina ionad sin, má tá sé i gceist agat é a choinneáil i gcartlann) pulls.delete.text=An bhfuil tú cinnte gur mhaith leat an t-iarratas tarraingthe seo a scriosadh? (Bainfidh sé seo an t-inneachar go léir go buan. Smaoinigh ar é a dhúnadh ina ionad sin, má tá sé i gceist agat é a choinneáil i gcartlann)
pulls.recently_pushed_new_branches=Bhrúigh tú ar bhrainse <strong>%[1]s</strong> %[2]s pulls.recently_pushed_new_branches=Bhrúigh tú ar bhrainse <strong>%[1]s</strong> %[2]s
pulls.upstream_diverging_prompt_behind_1=Tá an brainse seo %[1]d tiomantas taobh thiar de %[2]s
pulls.upstream_diverging_prompt_behind_n=Tá an brainse seo %[1]d geallta taobh thiar de %[2]s
pulls.upstream_diverging_prompt_base_newer=Tá athruithe nua ar an mbunbhrainse %s pulls.upstream_diverging_prompt_base_newer=Tá athruithe nua ar an mbunbhrainse %s
pulls.upstream_diverging_merge=Forc sionc pulls.upstream_diverging_merge=Forc sionc
@ -3719,6 +3722,7 @@ runners.status.active=Gníomhach
runners.status.offline=As líne runners.status.offline=As líne
runners.version=Leagan runners.version=Leagan
runners.reset_registration_token=Athshocraigh comhartha clár runners.reset_registration_token=Athshocraigh comhartha clár
runners.reset_registration_token_confirm=Ar mhaith leat an comhartha reatha a neamhbhailiú agus ceann nua a ghiniúint?
runners.reset_registration_token_success=D'éirigh le hathshocrú comhartha clárúcháin an dara háit runners.reset_registration_token_success=D'éirigh le hathshocrú comhartha clárúcháin an dara háit
runs.all_workflows=Gach Sreafaí Oibre runs.all_workflows=Gach Sreafaí Oibre
@ -3770,6 +3774,8 @@ variables.creation.success=Tá an athróg "%s" curtha leis.
variables.update.failed=Theip ar athróg a chur in eagar. variables.update.failed=Theip ar athróg a chur in eagar.
variables.update.success=Tá an t-athróg curtha in eagar. variables.update.success=Tá an t-athróg curtha in eagar.
logs.always_auto_scroll=Logchomhaid scrollaithe uathoibríoch i gcónaí
logs.always_expand_running=Leathnaigh logs reatha i gcónaí
[projects] [projects]
deleted.display_name=Tionscadal scriosta deleted.display_name=Tionscadal scriosta

View File

@ -488,6 +488,7 @@ change_avatar=Modifica il tuo avatar…
repositories=Repository repositories=Repository
activity=Attività pubblica activity=Attività pubblica
followers=Seguaci followers=Seguaci
show_more=Mostra Altro
starred=Repositories votate starred=Repositories votate
watched=Repository Osservate watched=Repository Osservate
projects=Progetti projects=Progetti

View File

@ -649,6 +649,7 @@ joined_on=%sに登録
repositories=リポジトリ repositories=リポジトリ
activity=公開アクティビティ activity=公開アクティビティ
followers=フォロワー followers=フォロワー
show_more=さらに表示
starred=スター付きリポジトリ starred=スター付きリポジトリ
watched=ウォッチ中リポジトリ watched=ウォッチ中リポジトリ
code=コード code=コード

View File

@ -583,6 +583,7 @@ joined_on=Pievienojās %s
repositories=Repozitoriji repositories=Repozitoriji
activity=Publiskā aktivitāte activity=Publiskā aktivitāte
followers=Sekotāji followers=Sekotāji
show_more=Rādīt vairāk
starred=Atzīmēti repozitoriji starred=Atzīmēti repozitoriji
watched=Vērotie repozitoriji watched=Vērotie repozitoriji
code=Kods code=Kods

View File

@ -487,6 +487,7 @@ change_avatar=Wijzig je profielfoto…
repositories=repositories repositories=repositories
activity=Openbare activiteit activity=Openbare activiteit
followers=Volgers followers=Volgers
show_more=Meer weergeven
starred=Repositories met ster starred=Repositories met ster
watched=Gevolgde repositories watched=Gevolgde repositories
projects=Projecten projects=Projecten

View File

@ -582,6 +582,7 @@ joined_on=Inscreveu-se em %s
repositories=Repositórios repositories=Repositórios
activity=Atividade pública activity=Atividade pública
followers=Seguidores followers=Seguidores
show_more=Mostrar mais
starred=Repositórios favoritos starred=Repositórios favoritos
watched=Repositórios observados watched=Repositórios observados
code=Código code=Código

View File

@ -649,6 +649,7 @@ joined_on=Inscreveu-se em %s
repositories=Repositórios repositories=Repositórios
activity=Trabalho público activity=Trabalho público
followers=Seguidores followers=Seguidores
show_more=Mostrar mais
starred=Repositórios favoritos starred=Repositórios favoritos
watched=Repositórios sob vigilância watched=Repositórios sob vigilância
code=Código code=Código
@ -1921,8 +1922,8 @@ pulls.close=Encerrar pedido de integração
pulls.closed_at=`fechou este pedido de integração <a id="%[1]s" href="#%[1]s">%[2]s</a>` pulls.closed_at=`fechou este pedido de integração <a id="%[1]s" href="#%[1]s">%[2]s</a>`
pulls.reopened_at=`reabriu este pedido de integração <a id="%[1]s" href="#%[1]s">%[2]s</a>` pulls.reopened_at=`reabriu este pedido de integração <a id="%[1]s" href="#%[1]s">%[2]s</a>`
pulls.cmd_instruction_hint=`Ver <a class="show-instruction">instruções para a linha de comandos</a>.` pulls.cmd_instruction_hint=`Ver <a class="show-instruction">instruções para a linha de comandos</a>.`
pulls.cmd_instruction_checkout_title=Conferir pulls.cmd_instruction_checkout_title=Checkout
pulls.cmd_instruction_checkout_desc=No seu repositório, irá criar um novo ramo para que possa testar as modificações. pulls.cmd_instruction_checkout_desc=A partir do seu repositório, crie um novo ramo e teste nele as modificações.
pulls.cmd_instruction_merge_title=Integrar pulls.cmd_instruction_merge_title=Integrar
pulls.cmd_instruction_merge_desc=Integrar as modificações e enviar para o Gitea. pulls.cmd_instruction_merge_desc=Integrar as modificações e enviar para o Gitea.
pulls.cmd_instruction_merge_warning=Aviso: Esta operação não pode executar pedidos de integração porque "auto-identificar integração manual" não estava habilitado pulls.cmd_instruction_merge_warning=Aviso: Esta operação não pode executar pedidos de integração porque "auto-identificar integração manual" não estava habilitado
@ -1945,6 +1946,8 @@ pulls.delete.title=Eliminar este pedido de integração?
pulls.delete.text=Tem a certeza que quer eliminar este pedido de integração? Isso irá remover todo o conteúdo permanentemente. Como alternativa considere fechá-lo, se pretender mantê-lo em arquivo. pulls.delete.text=Tem a certeza que quer eliminar este pedido de integração? Isso irá remover todo o conteúdo permanentemente. Como alternativa considere fechá-lo, se pretender mantê-lo em arquivo.
pulls.recently_pushed_new_branches=Enviou para o ramo <strong>%[1]s</strong> %[2]s pulls.recently_pushed_new_branches=Enviou para o ramo <strong>%[1]s</strong> %[2]s
pulls.upstream_diverging_prompt_behind_1=Este ramo está %[1]d cometimento atrás de %[2]s
pulls.upstream_diverging_prompt_behind_n=Este ramo está %[1]d cometimentos atrás de %[2]s
pulls.upstream_diverging_prompt_base_newer=O ramo base %s tem novas modificações pulls.upstream_diverging_prompt_base_newer=O ramo base %s tem novas modificações
pulls.upstream_diverging_merge=Sincronizar derivação pulls.upstream_diverging_merge=Sincronizar derivação

View File

@ -578,6 +578,7 @@ joined_on=Присоединил(ся/ась) %s
repositories=Репозитории repositories=Репозитории
activity=Активность activity=Активность
followers=Подписчики followers=Подписчики
show_more=Показать больше
starred=Избранные репозитории starred=Избранные репозитории
watched=Отслеживаемые репозитории watched=Отслеживаемые репозитории
code=Код code=Код

View File

@ -452,6 +452,7 @@ change_avatar=ඔබගේ අවතාරය වෙනස් කරන්න…
repositories=කෝෂ්ඨ repositories=කෝෂ්ඨ
activity=ප්‍රසිද්ධ ක්‍රියාකාරකම activity=ප්‍රසිද්ධ ක්‍රියාකාරකම
followers=අනුගාමිකයන් followers=අනුගාමිකයන්
show_more=තව පෙන්වන්න
starred=තරු ගබඩාව starred=තරු ගබඩාව
watched=නරඹන ලද ගබඩාවලදී watched=නරඹන ලද ගබඩාවලදී
projects=ව්‍යාපෘති projects=ව්‍යාපෘති

View File

@ -631,6 +631,7 @@ joined_on=%s tarihinde katıldı
repositories=Depolar repositories=Depolar
activity=Genel Aktivite activity=Genel Aktivite
followers=Takipçiler followers=Takipçiler
show_more=Daha Fazla Göster
starred=Yıldızlanmış depolar starred=Yıldızlanmış depolar
watched=İzlenen Depolar watched=İzlenen Depolar
code=Kod code=Kod

View File

@ -466,6 +466,7 @@ change_avatar=Змінити свій аватар…
repositories=Репозиторії repositories=Репозиторії
activity=Публічна активність activity=Публічна активність
followers=Читачі followers=Читачі
show_more=Показати більше
starred=Обрані Репозиторії starred=Обрані Репозиторії
watched=Відстежувані репозиторії watched=Відстежувані репозиторії
projects=Проєкт projects=Проєкт

View File

@ -649,6 +649,7 @@ joined_on=加入于 %s
repositories=仓库列表 repositories=仓库列表
activity=公开活动 activity=公开活动
followers=关注者 followers=关注者
show_more=显示更多
starred=已点赞 starred=已点赞
watched=已关注仓库 watched=已关注仓库
code=代码 code=代码

View File

@ -647,6 +647,7 @@ joined_on=加入於 %s
repositories=儲存庫 repositories=儲存庫
activity=公開動態 activity=公開動態
followers=追蹤者 followers=追蹤者
show_more=顯示更多
starred=已加星號 starred=已加星號
watched=關注的儲存庫 watched=關注的儲存庫
code=程式碼 code=程式碼

170
package-lock.json generated
View File

@ -87,7 +87,7 @@
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-import-resolver-typescript": "3.7.0", "eslint-import-resolver-typescript": "3.7.0",
"eslint-plugin-array-func": "4.0.0", "eslint-plugin-array-func": "4.0.0",
"eslint-plugin-github": "5.1.4", "eslint-plugin-github": "5.0.2",
"eslint-plugin-import-x": "4.6.1", "eslint-plugin-import-x": "4.6.1",
"eslint-plugin-no-jquery": "3.1.0", "eslint-plugin-no-jquery": "3.1.0",
"eslint-plugin-no-use-extend-native": "0.5.0", "eslint-plugin-no-use-extend-native": "0.5.0",
@ -7953,166 +7953,35 @@
} }
}, },
"node_modules/eslint-plugin-github": { "node_modules/eslint-plugin-github": {
"version": "5.1.4", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-github/-/eslint-plugin-github-5.1.4.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-github/-/eslint-plugin-github-5.0.2.tgz",
"integrity": "sha512-j5IgIxsDoch06zJzeqPvenfzRXDKI9Z8YwfUg1pm2ay1q44tMSFwvEu6l0uEIrTpA3v8QdPyLr98LqDl1TIhSA==", "integrity": "sha512-nMdzWJQ5CimjQDY6SFeJ0KIXuNFf0dgDWEd4eP3UWfuTuP/dXcZJDg7MQRvAFt743T1zUi4+/HdOihfu8xJkLA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint/compat": "^1.2.3",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@github/browserslist-config": "^1.0.0", "@github/browserslist-config": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"aria-query": "^5.3.0", "aria-query": "^5.3.0",
"eslint-config-prettier": ">=8.0.0", "eslint-config-prettier": ">=8.0.0",
"eslint-plugin-escompat": "^3.11.3", "eslint-plugin-escompat": "^3.3.3",
"eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-filenames": "^1.3.2", "eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-i18n-text": "^1.0.1", "eslint-plugin-i18n-text": "^1.0.1",
"eslint-plugin-import": "^2.25.2", "eslint-plugin-import": "^2.25.2",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-no-only-tests": "^3.0.0", "eslint-plugin-no-only-tests": "^3.0.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.0.0",
"eslint-rule-documentation": ">=1.0.0", "eslint-rule-documentation": ">=1.0.0",
"globals": "^15.12.0",
"jsx-ast-utils": "^3.3.2", "jsx-ast-utils": "^3.3.2",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"svg-element-attributes": "^1.3.1", "svg-element-attributes": "^1.3.1"
"typescript-eslint": "^8.14.0"
}, },
"bin": { "bin": {
"eslint-ignore-errors": "bin/eslint-ignore-errors.js" "eslint-ignore-errors": "bin/eslint-ignore-errors.js"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8 || ^9" "eslint": "^8.0.1"
}
},
"node_modules/eslint-plugin-github/node_modules/@eslint/compat": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.2.4.tgz",
"integrity": "sha512-S8ZdQj/N69YAtuqFt7653jwcvuUj131+6qGLUyDqfDg1OIoBQ66OCuXC473YQfO2AaxITTutiRQiDwoo7ZLYyg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"eslint": "^9.10.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/eslint-plugin-github/node_modules/@eslint/eslintrc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz",
"integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-github/node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-github/node_modules/@eslint/js": {
"version": "9.17.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz",
"integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint-plugin-github/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint-plugin-github/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-github/node_modules/globals": {
"version": "15.14.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz",
"integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-github/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-plugin-github/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
} }
}, },
"node_modules/eslint-plugin-i18n-text": { "node_modules/eslint-plugin-i18n-text": {
@ -14063,29 +13932,6 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/typescript-eslint": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.1.tgz",
"integrity": "sha512-Mlaw6yxuaDEPQvb/2Qwu3/TfgeBHy9iTJ3mTwe7OvpPmF6KPQjVOfGyEJpPv6Ez2C34OODChhXrzYw/9phI0MQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.18.1",
"@typescript-eslint/parser": "8.18.1",
"@typescript-eslint/utils": "8.18.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.8.0"
}
},
"node_modules/typo-js": { "node_modules/typo-js": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.2.5.tgz", "resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.2.5.tgz",

View File

@ -86,7 +86,7 @@
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-import-resolver-typescript": "3.7.0", "eslint-import-resolver-typescript": "3.7.0",
"eslint-plugin-array-func": "4.0.0", "eslint-plugin-array-func": "4.0.0",
"eslint-plugin-github": "5.1.4", "eslint-plugin-github": "5.0.2",
"eslint-plugin-import-x": "4.6.1", "eslint-plugin-import-x": "4.6.1",
"eslint-plugin-no-jquery": "3.1.0", "eslint-plugin-no-jquery": "3.1.0",
"eslint-plugin-no-use-extend-native": "0.5.0", "eslint-plugin-no-use-extend-native": "0.5.0",

View File

@ -69,7 +69,7 @@ func (s *Service) Register(
labels := req.Msg.Labels labels := req.Msg.Labels
// create new runner // create new runner
name, _ := util.SplitStringAtByteN(req.Msg.Name, 255) name := util.EllipsisDisplayString(req.Msg.Name, 255)
runner := &actions_model.ActionRunner{ runner := &actions_model.ActionRunner{
UUID: gouuid.New().String(), UUID: gouuid.New().String(),
Name: name, Name: name,

View File

@ -37,8 +37,6 @@ import (
"code.gitea.io/gitea/routers/api/packages/vagrant" "code.gitea.io/gitea/routers/api/packages/vagrant"
"code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"github.com/go-chi/chi/v5"
) )
func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) {
@ -140,39 +138,10 @@ func CommonRoutes() *web.Router {
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/arch", func() { r.Group("/arch", func() {
r.Methods("HEAD,GET", "/repository.key", arch.GetRepositoryKey) r.Methods("HEAD,GET", "/repository.key", arch.GetRepositoryKey)
r.PathGroup("*", func(g *web.RouterPathGroup) {
reqPutRepository := web.NewPathProcessor("PUT", "/<repository:*>") g.MatchPath("PUT", "/<repository:*>", reqPackageAccess(perm.AccessModeWrite), arch.UploadPackageFile)
reqGetRepoArchFile := web.NewPathProcessor("HEAD,GET", "/<repository:*>/<architecture>/<filename>") g.MatchPath("HEAD,GET", "/<repository:*>/<architecture>/<filename>", arch.GetPackageOrRepositoryFile)
reqDeleteRepoNameVerArch := web.NewPathProcessor("DELETE", "/<repository:*>/<name>/<version>/<architecture>") g.MatchPath("DELETE", "/<repository:*>/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), arch.DeletePackageVersion)
r.Any("*", func(ctx *context.Context) {
chiCtx := chi.RouteContext(ctx.Req.Context())
path := ctx.PathParam("*")
if reqPutRepository.ProcessRequestPath(chiCtx, path) {
reqPackageAccess(perm.AccessModeWrite)(ctx)
if ctx.Written() {
return
}
arch.UploadPackageFile(ctx)
return
}
if reqGetRepoArchFile.ProcessRequestPath(chiCtx, path) {
arch.GetPackageOrRepositoryFile(ctx)
return
}
if reqDeleteRepoNameVerArch.ProcessRequestPath(chiCtx, path) {
reqPackageAccess(perm.AccessModeWrite)(ctx)
if ctx.Written() {
return
}
arch.DeletePackageVersion(ctx)
return
}
ctx.Status(http.StatusNotFound)
}) })
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/cargo", func() { r.Group("/cargo", func() {

View File

@ -733,7 +733,7 @@ func CreateIssue(ctx *context.APIContext) {
} }
if form.Closed { if form.Closed {
if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", true); err != nil { if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
if issues_model.IsErrDependenciesLeft(err) { if issues_model.IsErrDependenciesLeft(err) {
ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies")
return return
@ -912,27 +912,11 @@ func EditIssue(ctx *context.APIContext) {
} }
} }
var isClosed bool state := api.StateType(*form.State)
switch state := api.StateType(*form.State); state { closeOrReopenIssue(ctx, issue, state)
case api.StateOpen: if ctx.Written() {
isClosed = false
case api.StateClosed:
isClosed = true
default:
ctx.Error(http.StatusPreconditionFailed, "UnknownIssueStateError", fmt.Sprintf("unknown state: %s", state))
return return
} }
if issue.IsClosed != isClosed {
if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil {
if issues_model.IsErrDependenciesLeft(err) {
ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies")
return
}
ctx.Error(http.StatusInternalServerError, "ChangeStatus", err)
return
}
}
} }
// Refetch from database to assign some automatic values // Refetch from database to assign some automatic values
@ -1055,3 +1039,26 @@ func UpdateIssueDeadline(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: deadlineUnix.AsTimePtr()}) ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: deadlineUnix.AsTimePtr()})
} }
func closeOrReopenIssue(ctx *context.APIContext, issue *issues_model.Issue, state api.StateType) {
if state != api.StateOpen && state != api.StateClosed {
ctx.Error(http.StatusPreconditionFailed, "UnknownIssueStateError", fmt.Sprintf("unknown state: %s", state))
return
}
if state == api.StateClosed && !issue.IsClosed {
if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
if issues_model.IsErrDependenciesLeft(err) {
ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue or pull request because it still has open dependencies")
return
}
ctx.Error(http.StatusInternalServerError, "CloseIssue", err)
return
}
} else if state == api.StateOpen && issue.IsClosed {
if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil {
ctx.Error(http.StatusInternalServerError, "ReopenIssue", err)
return
}
}
}

View File

@ -335,6 +335,9 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption)
labelIDs = append(labelIDs, int64(rv.Float())) labelIDs = append(labelIDs, int64(rv.Float()))
case reflect.String: case reflect.String:
labelNames = append(labelNames, rv.String()) labelNames = append(labelNames, rv.String())
default:
ctx.Error(http.StatusBadRequest, "InvalidLabel", "a label must be an integer or a string")
return nil, nil, fmt.Errorf("invalid label")
} }
} }
if len(labelIDs) > 0 && len(labelNames) > 0 { if len(labelIDs) > 0 && len(labelNames) > 0 {
@ -342,11 +345,20 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption)
return nil, nil, fmt.Errorf("invalid labels") return nil, nil, fmt.Errorf("invalid labels")
} }
if len(labelNames) > 0 { if len(labelNames) > 0 {
labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, labelNames) repoLabelIDs, err := issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, labelNames)
if err != nil { if err != nil {
ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err) ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err)
return nil, nil, err return nil, nil, err
} }
labelIDs = append(labelIDs, repoLabelIDs...)
if ctx.Repo.Owner.IsOrganization() {
orgLabelIDs, err := issues_model.GetLabelIDsInOrgByNames(ctx, ctx.Repo.Owner.ID, labelNames)
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetLabelIDsInOrgByNames", err)
return nil, nil, err
}
labelIDs = append(labelIDs, orgLabelIDs...)
}
} }
labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs, "id", "repo_id", "org_id", "name", "exclusive") labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs, "id", "repo_id", "org_id", "name", "exclusive")

View File

@ -728,27 +728,11 @@ func EditPullRequest(ctx *context.APIContext) {
return return
} }
var isClosed bool state := api.StateType(*form.State)
switch state := api.StateType(*form.State); state { closeOrReopenIssue(ctx, issue, state)
case api.StateOpen: if ctx.Written() {
isClosed = false
case api.StateClosed:
isClosed = true
default:
ctx.Error(http.StatusPreconditionFailed, "UnknownPRStateError", fmt.Sprintf("unknown state: %s", state))
return return
} }
if issue.IsClosed != isClosed {
if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil {
if issues_model.IsErrDependenciesLeft(err) {
ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies")
return
}
ctx.Error(http.StatusInternalServerError, "ChangeStatus", err)
return
}
}
} }
// change pull target branch // change pull target branch

View File

@ -689,7 +689,7 @@ func Activate(ctx *context.Context) {
} }
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated // TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
user := user_model.VerifyUserActiveCode(ctx, code) user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
if user == nil { // if code is wrong if user == nil { // if code is wrong
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
return return
@ -734,7 +734,7 @@ func ActivatePost(ctx *context.Context) {
} }
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated // TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
user := user_model.VerifyUserActiveCode(ctx, code) user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
if user == nil { // if code is wrong if user == nil { // if code is wrong
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
return return

View File

@ -113,7 +113,7 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto
} }
// Fail early, don't frustrate the user // Fail early, don't frustrate the user
u := user_model.VerifyUserActiveCode(ctx, code) u := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, code)
if u == nil { if u == nil {
ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true)
return nil, nil return nil, nil

View File

@ -9,6 +9,10 @@ import (
"strings" "strings"
"time" "time"
"code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
) )
@ -41,7 +45,8 @@ func FetchActionTest(ctx *context.Context) {
ctx.JSONRedirect("") ctx.JSONRedirect("")
} }
func Tmpl(ctx *context.Context) { func prepareMockData(ctx *context.Context) {
if ctx.Req.URL.Path == "/devtest/gitea-ui" {
now := time.Now() now := time.Now()
ctx.Data["TimeNow"] = now ctx.Data["TimeNow"] = now
ctx.Data["TimePast5s"] = now.Add(-5 * time.Second) ctx.Data["TimePast5s"] = now.Add(-5 * time.Second)
@ -50,7 +55,75 @@ func Tmpl(ctx *context.Context) {
ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute) ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute)
ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second) ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second)
ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second) ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second)
}
if ctx.Req.URL.Path == "/devtest/commit-sign-badge" {
var commits []*asymkey.SignCommit
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}})
mockUser := mockUsers[0]
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{},
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
TrustStatus: "trusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "untrusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "other(unmatch)",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Warning: true,
Reason: "gpg.error",
SigningEmail: "test@example.com",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
ctx.Data["MockCommits"] = commits
}
}
func Tmpl(ctx *context.Context) {
prepareMockData(ctx)
if ctx.Req.Method == "POST" { if ctx.Req.Method == "POST" {
_ = ctx.Req.ParseForm() _ = ctx.Req.ParseForm()
ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"<br>"+ ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"<br>"+
@ -60,6 +133,5 @@ func Tmpl(ctx *context.Context) {
) )
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
} }
ctx.HTML(http.StatusOK, templates.TplName("devtest"+path.Clean("/"+ctx.PathParam("sub")))) ctx.HTML(http.StatusOK, templates.TplName("devtest"+path.Clean("/"+ctx.PathParam("sub"))))
} }

View File

@ -664,7 +664,7 @@ func PrepareCompareDiff(
} }
if len(title) > 255 { if len(title) > 255 {
var trailer string var trailer string
title, trailer = util.SplitStringAtByteN(title, 255) title, trailer = util.EllipsisDisplayStringX(title, 255)
if len(trailer) > 0 { if len(trailer) > 0 {
if ctx.Data["content"] != nil { if ctx.Data["content"] != nil {
ctx.Data["content"] = fmt.Sprintf("%s\n\n%s", trailer, ctx.Data["content"]) ctx.Data["content"] = fmt.Sprintf("%s\n\n%s", trailer, ctx.Data["content"])

View File

@ -154,10 +154,9 @@ func NewComment(ctx *context.Context) {
if pr != nil { if pr != nil {
ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index)) ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index))
} else { } else {
isClosed := form.Status == "close" if form.Status == "close" && !issue.IsClosed {
if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
log.Error("ChangeStatus: %v", err) log.Error("CloseIssue: %v", err)
if issues_model.IsErrDependenciesLeft(err) { if issues_model.IsErrDependenciesLeft(err) {
if issue.IsPull { if issue.IsPull {
ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked"))
@ -168,12 +167,16 @@ func NewComment(ctx *context.Context) {
} }
} else { } else {
if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil { if err := stopTimerIfAvailable(ctx, ctx.Doer, issue); err != nil {
ctx.ServerError("CreateOrStopIssueStopwatch", err) ctx.ServerError("stopTimerIfAvailable", err)
return return
} }
log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed)
} }
} else if form.Status == "reopen" && issue.IsClosed {
if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil {
log.Error("ReopenIssue: %v", err)
}
}
} }
} }

View File

@ -418,14 +418,11 @@ func UpdateIssueStatus(ctx *context.Context) {
return return
} }
var isClosed bool action := ctx.FormString("action")
switch action := ctx.FormString("action"); action { if action != "open" && action != "close" {
case "open":
isClosed = false
case "close":
isClosed = true
default:
log.Warn("Unrecognized action: %s", action) log.Warn("Unrecognized action: %s", action)
ctx.JSONOK()
return
} }
if _, err := issues.LoadRepositories(ctx); err != nil { if _, err := issues.LoadRepositories(ctx); err != nil {
@ -441,15 +438,20 @@ func UpdateIssueStatus(ctx *context.Context) {
if issue.IsPull && issue.PullRequest.HasMerged { if issue.IsPull && issue.PullRequest.HasMerged {
continue continue
} }
if issue.IsClosed != isClosed { if action == "close" && !issue.IsClosed {
if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil {
if issues_model.IsErrDependenciesLeft(err) { if issues_model.IsErrDependenciesLeft(err) {
ctx.JSON(http.StatusPreconditionFailed, map[string]any{ ctx.JSON(http.StatusPreconditionFailed, map[string]any{
"error": ctx.Tr("repo.issues.dependency.issue_batch_close_blocked", issue.Index), "error": ctx.Tr("repo.issues.dependency.issue_batch_close_blocked", issue.Index),
}) })
return return
} }
ctx.ServerError("ChangeStatus", err) ctx.ServerError("CloseIssue", err)
return
}
} else if action == "open" && issue.IsClosed {
if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil {
ctx.ServerError("ReopenIssue", err)
return return
} }
} }

View File

@ -61,11 +61,20 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) {
orgs, err := db.Find[organization.Organization](ctx, organization.FindOrgOptions{ orgs, err := db.Find[organization.Organization](ctx, organization.FindOrgOptions{
UserID: ctx.ContextUser.ID, UserID: ctx.ContextUser.ID,
IncludePrivate: showPrivate, IncludePrivate: showPrivate,
ListOptions: db.ListOptions{
Page: 1,
// query one more results (without a separate counting) to see whether we need to add the "show more orgs" link
PageSize: setting.UI.User.OrgPagingNum + 1,
},
}) })
if err != nil { if err != nil {
ctx.ServerError("FindOrgs", err) ctx.ServerError("FindOrgs", err)
return return
} }
if len(orgs) > setting.UI.User.OrgPagingNum {
orgs = orgs[:setting.UI.User.OrgPagingNum]
ctx.Data["ShowMoreOrgs"] = true
}
ctx.Data["Orgs"] = orgs ctx.Data["Orgs"] = orgs
ctx.Data["HasOrgsVisible"] = organization.HasOrgsVisible(ctx, orgs, ctx.Doer) ctx.Data["HasOrgsVisible"] = organization.HasOrgsVisible(ctx, orgs, ctx.Doer)

View File

@ -12,6 +12,7 @@ import (
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -256,6 +257,21 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
ctx.Data["ProfileReadme"] = profileContent ctx.Data["ProfileReadme"] = profileContent
} }
} }
case "organizations":
orgs, count, err := db.FindAndCount[organization.Organization](ctx, organization.FindOrgOptions{
UserID: ctx.ContextUser.ID,
IncludePrivate: showPrivate,
ListOptions: db.ListOptions{
Page: page,
PageSize: pagingNum,
},
})
if err != nil {
ctx.ServerError("GetUserOrganizations", err)
return
}
ctx.Data["Cards"] = orgs
total = int(count)
default: // default to "repositories" default: // default to "repositories"
repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{ ListOptions: db.ListOptions{
@ -294,31 +310,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
} }
pager := context.NewPagination(total, pagingNum, page, 5) pager := context.NewPagination(total, pagingNum, page, 5)
pager.SetDefaultParams(ctx) pager.AddParamFromRequest(ctx.Req)
pager.AddParamString("tab", tab)
if tab != "followers" && tab != "following" && tab != "activity" && tab != "projects" {
pager.AddParamString("language", language)
}
if tab == "activity" {
if ctx.Data["Date"] != nil {
pager.AddParamString("date", fmt.Sprint(ctx.Data["Date"]))
}
}
if archived.Has() {
pager.AddParamString("archived", fmt.Sprint(archived.Value()))
}
if fork.Has() {
pager.AddParamString("fork", fmt.Sprint(fork.Value()))
}
if mirror.Has() {
pager.AddParamString("mirror", fmt.Sprint(mirror.Value()))
}
if template.Has() {
pager.AddParamString("template", fmt.Sprint(template.Value()))
}
if private.Has() {
pager.AddParamString("private", fmt.Sprint(private.Value()))
}
ctx.Data["Page"] = pager ctx.Data["Page"] = pager
} }

View File

@ -109,7 +109,7 @@ func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_mode
IsPrivate: issue.Repo.IsPrivate, IsPrivate: issue.Repo.IsPrivate,
} }
truncatedContent, truncatedRight := util.SplitStringAtByteN(comment.Content, 200) truncatedContent, truncatedRight := util.EllipsisDisplayStringX(comment.Content, 200)
if truncatedRight != "" { if truncatedRight != "" {
// in case the content is in a Latin family language, we remove the last broken word. // in case the content is in a Latin family language, we remove the last broken word.
lastSpaceIdx := strings.LastIndex(truncatedContent, " ") lastSpaceIdx := strings.LastIndex(truncatedContent, " ")

View File

@ -188,15 +188,19 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m
continue continue
} }
} }
isClosed := ref.Action == references.XRefActionCloses
if isClosed && len(ref.TimeLog) > 0 { refIssue.Repo = refRepo
if ref.Action == references.XRefActionCloses && !refIssue.IsClosed {
if len(ref.TimeLog) > 0 {
if err := issueAddTime(ctx, refIssue, doer, c.Timestamp, ref.TimeLog); err != nil { if err := issueAddTime(ctx, refIssue, doer, c.Timestamp, ref.TimeLog); err != nil {
return err return err
} }
} }
if isClosed != refIssue.IsClosed { if err := CloseIssue(ctx, refIssue, doer, c.Sha1); err != nil {
refIssue.Repo = refRepo return err
if err := ChangeStatus(ctx, refIssue, doer, c.Sha1, isClosed); err != nil { }
} else if ref.Action == references.XRefActionReopens && refIssue.IsClosed {
if err := ReopenIssue(ctx, refIssue, doer, c.Sha1); err != nil {
return err return err
} }
} }

View File

@ -59,6 +59,10 @@ func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *is
} }
} }
if issue.MilestoneID == 0 {
issue.Milestone = nil
}
return nil return nil
} }

View File

@ -23,6 +23,7 @@ func TestChangeMilestoneAssign(t *testing.T) {
oldMilestoneID := issue.MilestoneID oldMilestoneID := issue.MilestoneID
issue.MilestoneID = 2 issue.MilestoneID = 2
assert.NoError(t, issue.LoadMilestone(db.DefaultContext))
assert.NoError(t, ChangeMilestoneAssign(db.DefaultContext, issue, doer, oldMilestoneID)) assert.NoError(t, ChangeMilestoneAssign(db.DefaultContext, issue, doer, oldMilestoneID))
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
IssueID: issue.ID, IssueID: issue.ID,
@ -31,4 +32,11 @@ func TestChangeMilestoneAssign(t *testing.T) {
OldMilestoneID: oldMilestoneID, OldMilestoneID: oldMilestoneID,
}) })
unittest.CheckConsistencyFor(t, &issues_model.Milestone{}, &issues_model.Issue{}) unittest.CheckConsistencyFor(t, &issues_model.Milestone{}, &issues_model.Issue{})
assert.NotNil(t, issue.Milestone)
oldMilestoneID = issue.MilestoneID
issue.MilestoneID = 0
assert.NoError(t, ChangeMilestoneAssign(db.DefaultContext, issue, doer, oldMilestoneID))
assert.EqualValues(t, 0, issue.MilestoneID)
assert.Nil(t, issue.Milestone)
} }

View File

@ -6,34 +6,54 @@ package issue
import ( import (
"context" "context"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
) )
// ChangeStatus changes issue status to open or closed. // CloseIssue close an issue.
// closed means the target status func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
// Fix me: you should check whether the current issue status is same to the target status before call this function dbCtx, committer, err := db.TxContext(ctx)
// as in function changeIssueStatus we will return WasClosedError, even the issue status and target status are both open
func ChangeStatus(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string, closed bool) error {
comment, err := issues_model.ChangeIssueStatus(ctx, issue, doer, closed)
if err != nil { if err != nil {
if issues_model.IsErrDependenciesLeft(err) && closed { return err
if err := issues_model.FinishIssueStopwatchIfPossible(ctx, doer, issue); err != nil { }
defer committer.Close()
comment, err := issues_model.CloseIssue(dbCtx, issue, doer)
if err != nil {
if issues_model.IsErrDependenciesLeft(err) {
if err := issues_model.FinishIssueStopwatchIfPossible(dbCtx, doer, issue); err != nil {
log.Error("Unable to stop stopwatch for issue[%d]#%d: %v", issue.ID, issue.Index, err) log.Error("Unable to stop stopwatch for issue[%d]#%d: %v", issue.ID, issue.Index, err)
} }
} }
return err return err
} }
if closed { if err := issues_model.FinishIssueStopwatchIfPossible(dbCtx, doer, issue); err != nil {
if err := issues_model.FinishIssueStopwatchIfPossible(ctx, doer, issue); err != nil {
return err return err
} }
}
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, closed) if err := committer.Commit(); err != nil {
return err
}
committer.Close()
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true)
return nil
}
// ReopenIssue reopen an issue.
// FIXME: If some issues dependent this one are closed, should we also reopen them?
func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
comment, err := issues_model.ReopenIssue(ctx, issue, doer)
if err != nil {
return err
}
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false)
return nil return nil
} }

View File

@ -93,7 +93,8 @@ func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
// No mail service configured // No mail service configured
return return
} }
sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account") opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}
sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account")
} }
// SendResetPasswordMail sends a password reset mail to the user // SendResetPasswordMail sends a password reset mail to the user
@ -103,7 +104,8 @@ func SendResetPasswordMail(u *user_model.User) {
return return
} }
locale := translation.NewLocale(u.Language) locale := translation.NewLocale(u.Language)
sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account") opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}
sendUserMail(u.Language, u, mailAuthResetPassword, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.reset_password"), "recover account")
} }
// SendActivateEmailMail sends confirmation email to confirm new email address // SendActivateEmailMail sends confirmation email to confirm new email address
@ -113,11 +115,12 @@ func SendActivateEmailMail(u *user_model.User, email string) {
return return
} }
locale := translation.NewLocale(u.Language) locale := translation.NewLocale(u.Language)
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email}
data := map[string]any{ data := map[string]any{
"locale": locale, "locale": locale,
"DisplayName": u.DisplayName(), "DisplayName": u.DisplayName(),
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
"Code": u.GenerateEmailActivateCode(email), "Code": user_model.GenerateUserTimeLimitCode(opts, u),
"Email": email, "Email": email,
"Language": locale.Language(), "Language": locale.Language(),
} }

View File

@ -10,9 +10,9 @@ import (
"strings" "strings"
"time" "time"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/jaytaylor/html2text" "github.com/jaytaylor/html2text"
gomail "github.com/wneessen/go-mail" gomail "github.com/wneessen/go-mail"
@ -54,7 +54,7 @@ func (m *Message) ToMessage() *gomail.Msg {
plainBody, err := html2text.FromString(m.Body) plainBody, err := html2text.FromString(m.Body)
if err != nil || setting.MailService.SendAsPlainText { if err != nil || setting.MailService.SendAsPlainText {
if strings.Contains(base.TruncateString(m.Body, 100), "<html>") { if strings.Contains(util.TruncateRunes(m.Body, 100), "<html>") {
log.Warn("Mail contains HTML but configured to send as plain text.") log.Warn("Mail contains HTML but configured to send as plain text.")
} }
msg.SetBodyString("text/plain", plainBody) msg.SetBodyString("text/plain", plainBody)

View File

@ -19,7 +19,6 @@ import (
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
base_module "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/label"
@ -409,7 +408,7 @@ func (g *GiteaLocalUploader) CreateIssues(issues ...*base.Issue) error {
RepoID: g.repo.ID, RepoID: g.repo.ID,
Repo: g.repo, Repo: g.repo,
Index: issue.Number, Index: issue.Number,
Title: base_module.TruncateString(issue.Title, 255), Title: util.TruncateRunes(issue.Title, 255),
Content: issue.Content, Content: issue.Content,
Ref: issue.Ref, Ref: issue.Ref,
IsClosed: issue.State == "closed", IsClosed: issue.State == "closed",

View File

@ -263,14 +263,17 @@ func handleCloseCrossReferences(ctx context.Context, pr *issues_model.PullReques
if err = ref.Issue.LoadRepo(ctx); err != nil { if err = ref.Issue.LoadRepo(ctx); err != nil {
return err return err
} }
isClosed := ref.RefAction == references.XRefActionCloses if ref.RefAction == references.XRefActionCloses && !ref.Issue.IsClosed {
if isClosed != ref.Issue.IsClosed { if err = issue_service.CloseIssue(ctx, ref.Issue, doer, pr.MergedCommitID); err != nil {
if err = issue_service.ChangeStatus(ctx, ref.Issue, doer, pr.MergedCommitID, isClosed); err != nil {
// Allow ErrDependenciesLeft // Allow ErrDependenciesLeft
if !issues_model.IsErrDependenciesLeft(err) { if !issues_model.IsErrDependenciesLeft(err) {
return err return err
} }
} }
} else if ref.RefAction == references.XRefActionReopens && ref.Issue.IsClosed {
if err = issue_service.ReopenIssue(ctx, ref.Issue, doer, pr.MergedCommitID); err != nil {
return err
}
} }
} }
return nil return nil

View File

@ -64,7 +64,8 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
} }
// user should be a collaborator or a member of the organization for base repo // user should be a collaborator or a member of the organization for base repo
if !issue.Poster.IsAdmin { canCreate := issue.Poster.IsAdmin || pr.Flow == issues_model.PullRequestFlowAGit
if !canCreate {
canCreate, err := repo_model.IsOwnerMemberCollaborator(ctx, repo, issue.Poster.ID) canCreate, err := repo_model.IsOwnerMemberCollaborator(ctx, repo, issue.Poster.ID)
if err != nil { if err != nil {
return err return err
@ -706,7 +707,7 @@ func CloseBranchPulls(ctx context.Context, doer *user_model.User, repoID int64,
var errs errlist var errs errlist
for _, pr := range prs { for _, pr := range prs {
if err = issue_service.ChangeStatus(ctx, pr.Issue, doer, "", true); err != nil && !issues_model.IsErrPullWasClosed(err) && !issues_model.IsErrDependenciesLeft(err) { if err = issue_service.CloseIssue(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrPullWasClosed(err) && !issues_model.IsErrDependenciesLeft(err) {
errs = append(errs, err) errs = append(errs, err)
} }
} }
@ -740,7 +741,7 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re
if pr.BaseRepoID == repo.ID { if pr.BaseRepoID == repo.ID {
continue continue
} }
if err = issue_service.ChangeStatus(ctx, pr.Issue, doer, "", true); err != nil && !issues_model.IsErrPullWasClosed(err) { if err = issue_service.CloseIssue(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrPullWasClosed(err) {
errs = append(errs, err) errs = append(errs, err)
} }
} }

View File

@ -179,7 +179,7 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU
return err return err
} }
rel.Title, _ = util.SplitStringAtByteN(rel.Title, 255) rel.Title = util.EllipsisDisplayString(rel.Title, 255)
rel.LowerTagName = strings.ToLower(rel.TagName) rel.LowerTagName = strings.ToLower(rel.TagName)
if err = db.Insert(gitRepo.Ctx, rel); err != nil { if err = db.Insert(gitRepo.Ctx, rel); err != nil {
return err return err

View File

@ -0,0 +1,13 @@
{{template "devtest/devtest-header"}}
<div class="page-content devtest ui container">
<div>
<h1>Commit Sign Badges</h1>
{{range $commit := .MockCommits}}
<div class="flex-text-block tw-my-2">
{{template "repo/commit_sign_badge" dict "Commit" $commit "CommitBaseLink" "/devtest/commit" "CommitSignVerification" $commit.Verification}}
{{template "repo/commit_sign_badge" dict "CommitSignVerification" $commit.Verification}}
</div>
{{end}}
</div>
</div>
{{template "devtest/devtest-footer"}}

View File

@ -1,23 +1,9 @@
{{template "base/head" .}} {{template "base/head" .}}
{{$commitLinkBase := print $.RepoLink (Iif $.PageIsWiki "/wiki" "") "/commit"}}
<div role="main" aria-label="{{.Title}}" class="page-content repository diff"> <div role="main" aria-label="{{.Title}}" class="page-content repository diff">
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container fluid padded"> <div class="ui container fluid padded">
{{$class := ""}} <div class="ui top attached header clearing segment tw-relative commit-header">
{{if .Commit.Signature}}
{{$class = (print $class " isSigned")}}
{{if .Verification.Verified}}
{{if eq .Verification.TrustStatus "trusted"}}
{{$class = (print $class " isVerified")}}
{{else if eq .Verification.TrustStatus "untrusted"}}
{{$class = (print $class " isVerifiedUntrusted")}}
{{else}}
{{$class = (print $class " isVerifiedUnmatched")}}
{{end}}
{{else if .Verification.Warning}}
{{$class = (print $class " isWarning")}}
{{end}}
{{end}}
<div class="ui top attached header clearing segment tw-relative commit-header {{$class}}">
<div class="tw-flex tw-mb-4 tw-gap-1"> <div class="tw-flex tw-mb-4 tw-gap-1">
<h3 class="tw-mb-0 tw-flex-1"><span class="commit-summary" title="{{.Commit.Summary}}">{{ctx.RenderUtils.RenderCommitMessage .Commit.Message ($.Repository.ComposeMetas ctx)}}</span>{{template "repo/commit_statuses" dict "Status" .CommitStatus "Statuses" .CommitStatuses}}</h3> <h3 class="tw-mb-0 tw-flex-1"><span class="commit-summary" title="{{.Commit.Summary}}">{{ctx.RenderUtils.RenderCommitMessage .Commit.Message ($.Repository.ComposeMetas ctx)}}</span>{{template "repo/commit_statuses" dict "Status" .CommitStatus "Statuses" .CommitStatuses}}</h3>
{{if not $.PageIsWiki}} {{if not $.PageIsWiki}}
@ -142,125 +128,59 @@
{{end}} {{end}}
{{template "repo/commit_load_branches_and_tags" .}} {{template "repo/commit_load_branches_and_tags" .}}
</div> </div>
<div class="ui{{if not .Commit.Signature}} bottom{{end}} attached segment tw-flex tw-items-center tw-justify-between tw-py-1 commit-header-row tw-flex-wrap {{$class}}">
<div class="tw-flex tw-items-center author"> <div class="ui bottom attached segment flex-text-block tw-flex-wrap">
<div class="flex-text-inline">
{{if .Author}} {{if .Author}}
{{ctx.AvatarUtils.Avatar .Author 28 "tw-mr-2"}} {{ctx.AvatarUtils.Avatar .Author 20}}
{{if .Author.FullName}} {{if .Author.FullName}}
<a href="{{.Author.HomeLink}}"><strong>{{.Author.FullName}}</strong></a> <a href="{{.Author.HomeLink}}"><strong>{{.Author.FullName}}</strong></a>
{{else}} {{else}}
<a href="{{.Author.HomeLink}}"><strong>{{.Commit.Author.Name}}</strong></a> <a href="{{.Author.HomeLink}}"><strong>{{.Commit.Author.Name}}</strong></a>
{{end}} {{end}}
{{else}} {{else}}
{{ctx.AvatarUtils.AvatarByEmail .Commit.Author.Email .Commit.Author.Email 28 "tw-mr-2"}} {{ctx.AvatarUtils.AvatarByEmail .Commit.Author.Email .Commit.Author.Email 20}}
<strong>{{.Commit.Author.Name}}</strong> <strong>{{.Commit.Author.Name}}</strong>
{{end}} {{end}}
<span class="text grey tw-ml-2" id="authored-time">{{DateUtils.TimeSince .Commit.Author.When}}</span> </div>
<span class="text grey">{{DateUtils.TimeSince .Commit.Author.When}}</span>
<div class="flex-text-inline">
{{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}} {{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}}
<span class="text grey tw-mx-2">{{ctx.Locale.Tr "repo.diff.committed_by"}}</span> <span class="text grey">{{ctx.Locale.Tr "repo.diff.committed_by"}}</span>
{{if ne .Verification.CommittingUser.ID 0}} {{if ne .Verification.CommittingUser.ID 0}}
{{ctx.AvatarUtils.Avatar .Verification.CommittingUser 28 "tw-mx-2"}} {{ctx.AvatarUtils.Avatar .Verification.CommittingUser 20}}
<a href="{{.Verification.CommittingUser.HomeLink}}"><strong>{{.Commit.Committer.Name}}</strong></a> <a href="{{.Verification.CommittingUser.HomeLink}}"><strong>{{.Commit.Committer.Name}}</strong></a>
{{else}} {{else}}
{{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Name 28 "tw-mr-2"}} {{ctx.AvatarUtils.AvatarByEmail .Commit.Committer.Email .Commit.Committer.Name 20}}
<strong>{{.Commit.Committer.Name}}</strong> <strong>{{.Commit.Committer.Name}}</strong>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
<div class="tw-flex tw-items-center">
{{if .Verification}}
{{template "repo/commit_sign_badge" dict "CommitSignVerification" .Verification}}
{{end}}
<div class="tw-flex-1"></div>
<div class="flex-text-inline tw-gap-5">
{{if .Parents}} {{if .Parents}}
<div> <div class="flex-text-inline">
<span>{{ctx.Locale.Tr "repo.diff.parent"}}</span> <span>{{ctx.Locale.Tr "repo.diff.parent"}}</span>
{{range .Parents}} {{range .Parents}}
{{if $.PageIsWiki}} <a class="ui label commit-id-short" href="{{$commitLinkBase}}/{{PathEscape .}}">{{ShortSha .}}</a>
<a class="ui primary sha label" href="{{$.RepoLink}}/wiki/commit/{{PathEscape .}}">{{ShortSha .}}</a>
{{else}}
<a class="ui primary sha label" href="{{$.RepoLink}}/commit/{{PathEscape .}}">{{ShortSha .}}</a>
{{end}}
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
<div class="item"> <div class="flex-text-inline">
<span>{{ctx.Locale.Tr "repo.diff.commit"}}</span> <span>{{ctx.Locale.Tr "repo.diff.commit"}}</span>
<span class="ui primary sha label">{{ShortSha .CommitID}}</span> <a class="ui label commit-id-short" href="{{$commitLinkBase}}/{{PathEscape .CommitID}}">{{ShortSha .CommitID}}</a>
</div> </div>
</div> </div>
</div> </div>
{{if .Commit.Signature}}
<div class="ui bottom attached message tw-text-left tw-flex tw-items-center tw-justify-between commit-header-row tw-flex-wrap tw-mb-0 {{$class}}">
<div class="tw-flex tw-items-center">
{{if .Verification.Verified}}
{{if ne .Verification.SigningUser.ID 0}}
{{svg "gitea-lock" 16 "tw-mr-2"}}
{{if eq .Verification.TrustStatus "trusted"}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.signed_by"}}:</span>
{{else if eq .Verification.TrustStatus "untrusted"}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.signed_by_untrusted_user"}}:</span>
{{else}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.signed_by_untrusted_user_unmatched"}}:</span>
{{end}}
{{ctx.AvatarUtils.Avatar .Verification.SigningUser 28 "tw-mr-2"}}
<a href="{{.Verification.SigningUser.HomeLink}}"><strong>{{.Verification.SigningUser.GetDisplayName}}</strong></a>
{{else}}
<span title="{{ctx.Locale.Tr "gpg.default_key"}}">{{svg "gitea-lock-cog" 16 "tw-mr-2"}}</span>
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.signed_by"}}:</span>
{{ctx.AvatarUtils.AvatarByEmail .Verification.SigningEmail "" 28 "tw-mr-2"}}
<strong>{{.Verification.SigningUser.GetDisplayName}}</strong>
{{end}}
{{else}}
{{svg "gitea-unlock" 16 "tw-mr-2"}}
<span class="ui text">{{ctx.Locale.Tr .Verification.Reason}}</span>
{{end}}
</div>
<div class="tw-flex tw-items-center">
{{if .Verification.Verified}}
{{if ne .Verification.SigningUser.ID 0}}
{{svg "octicon-verified" 16 "tw-mr-2"}}
{{if .Verification.SigningSSHKey}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}:</span>
{{.Verification.SigningSSHKey.Fingerprint}}
{{else}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.gpg_key_id"}}:</span>
{{.Verification.SigningKey.PaddedKeyID}}
{{end}}
{{else}}
{{svg "octicon-unverified" 16 "tw-mr-2"}}
{{if .Verification.SigningSSHKey}}
<span class="ui text tw-mr-2" data-tooltip-content="{{ctx.Locale.Tr "gpg.default_key"}}">{{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}:</span>
{{.Verification.SigningSSHKey.Fingerprint}}
{{else}}
<span class="ui text tw-mr-2" data-tooltip-content="{{ctx.Locale.Tr "gpg.default_key"}}">{{ctx.Locale.Tr "repo.commits.gpg_key_id"}}:</span>
{{.Verification.SigningKey.PaddedKeyID}}
{{end}}
{{end}}
{{else if .Verification.Warning}}
{{svg "octicon-unverified" 16 "tw-mr-2"}}
{{if .Verification.SigningSSHKey}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}:</span>
{{.Verification.SigningSSHKey.Fingerprint}}
{{else}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.gpg_key_id"}}:</span>
{{.Verification.SigningKey.PaddedKeyID}}
{{end}}
{{else}}
{{if .Verification.SigningKey}}
{{if ne .Verification.SigningKey.KeyID ""}}
{{svg "octicon-verified" 16 "tw-mr-2"}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.gpg_key_id"}}:</span>
{{.Verification.SigningKey.PaddedKeyID}}
{{end}}
{{end}}
{{if .Verification.SigningSSHKey}}
{{if ne .Verification.SigningSSHKey.Fingerprint ""}}
{{svg "octicon-verified" 16 "tw-mr-2"}}
<span class="ui text tw-mr-2">{{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}:</span>
{{.Verification.SigningSSHKey.Fingerprint}}
{{end}}
{{end}}
{{end}}
</div>
</div>
{{end}}
{{if .NoteRendered}} {{if .NoteRendered}}
<div class="ui top attached header segment git-notes"> <div class="ui top attached header segment git-notes">
{{svg "octicon-note" 16 "tw-mr-2"}} {{svg "octicon-note" 16 "tw-mr-2"}}
@ -276,12 +196,13 @@
{{else}} {{else}}
<strong>{{.NoteCommit.Author.Name}}</strong> <strong>{{.NoteCommit.Author.Name}}</strong>
{{end}} {{end}}
<span class="text grey" id="note-authored-time">{{DateUtils.TimeSince .NoteCommit.Author.When}}</span> <span class="text grey">{{DateUtils.TimeSince .NoteCommit.Author.When}}</span>
</div> </div>
<div class="ui bottom attached info segment git-notes"> <div class="ui bottom attached info segment git-notes">
<pre class="commit-body">{{.NoteRendered | SanitizeHTML}}</pre> <pre class="commit-body">{{.NoteRendered | SanitizeHTML}}</pre>
</div> </div>
{{end}} {{end}}
{{template "repo/diff/box" .}} {{template "repo/diff/box" .}}
</div> </div>
</div> </div>

View File

@ -0,0 +1,78 @@
{{/* Template attributes:
* Commit
* CommitBaseLink
* CommitSignVerification
If you'd like to modify this template, you could test it on the devtest page.
ATTENTION: this template could be re-rendered many times (on the graph and commit list page),
so this template should be kept as small as possbile, DO NOT put large components like modal/dialog into it.
*/}}
{{- $commit := $.Commit -}}
{{- $commitBaseLink := $.CommitBaseLink -}}
{{- $verification := $.CommitSignVerification -}}
{{- $extraClass := "" -}}
{{- $verified := false -}}
{{- $signingUser := NIL -}}
{{- $signingEmail := "" -}}
{{- $msgReasonPrefix := "" -}}
{{- $msgReason := "" -}}
{{- $msgSigningKey := "" -}}
{{- if $verification -}}
{{- $signingUser = $verification.SigningUser -}}
{{- $signingEmail = $verification.SigningEmail -}}
{{- $extraClass = print $extraClass " commit-is-signed" -}}
{{- if $verification.Verified -}}
{{- /* reason is "{name} / {key-id}" */ -}}
{{- $msgReason = $verification.Reason -}}
{{- $verified = true -}}
{{- if eq $verification.TrustStatus "trusted" -}}
{{- $extraClass = print $extraClass " sign-trusted" -}}
{{- else if eq $verification.TrustStatus "untrusted" -}}
{{- $extraClass = print $extraClass " sign-untrusted" -}}
{{- $msgReasonPrefix = ctx.Locale.Tr "repo.commits.signed_by_untrusted_user" -}}
{{- else -}}
{{- $extraClass = print $extraClass " sign-unmatched" -}}
{{- $msgReasonPrefix = ctx.Locale.Tr "repo.commits.signed_by_untrusted_user_unmatched" -}}
{{- end -}}
{{- else -}}
{{- if $verification.Warning -}}
{{- $extraClass = print $extraClass " sign-warning" -}}
{{- end -}}
{{- $msgReason = ctx.Locale.Tr $verification.Reason -}}{{- /* dirty part: it is the translation key ..... */ -}}
{{- end -}}
{{- if $msgReasonPrefix -}}
{{- $msgReason = print $msgReasonPrefix ": " $msgReason -}}
{{- end -}}
{{- if $verification.SigningSSHKey -}}
{{- $msgSigningKey = print (ctx.Locale.Tr "repo.commits.ssh_key_fingerprint") ": " $verification.SigningSSHKey.Fingerprint -}}
{{- else if $verification.SigningKey -}}
{{- $msgSigningKey = print (ctx.Locale.Tr "repo.commits.gpg_key_id") ": " $verification.SigningKey.PaddedKeyID -}}
{{- end -}}
{{- end -}}
{{- if $commit -}}
<a {{if $commitBaseLink}}href="{{$commitBaseLink}}/{{$commit.ID}}"{{end}} class="ui label commit-id-short {{$extraClass}}" rel="nofollow">
{{- ShortSha $commit.ID.String -}}
{{- end -}}
<span class="ui label commit-sign-badge {{$extraClass}}">
{{- if $verified -}}
{{- if and $signingUser $signingUser.ID -}}
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-lock"}}</span>
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.Avatar $signingUser 16}}</span>
{{- else -}}
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-lock-cog"}}</span>
<span data-tooltip-content="{{$msgSigningKey}}">{{ctx.AvatarUtils.AvatarByEmail $signingEmail "" 16}}</span>
{{- end -}}
{{- else -}}
<span data-tooltip-content="{{$msgReason}}">{{svg "gitea-unlock"}}</span>
{{- end -}}
</span>
{{- if $commit -}}
</a>
{{- end -}}
{{- /* This template should be kept as small as possbile, DO NOT put large components like modal/dialog into it. */ -}}

View File

@ -28,33 +28,15 @@
</div> </div>
</td> </td>
<td class="sha"> <td class="sha">
{{$class := "ui sha label"}} {{$commitBaseLink := ""}}
{{if .Signature}}
{{$class = (print $class " isSigned")}}
{{if .Verification.Verified}}
{{if eq .Verification.TrustStatus "trusted"}}
{{$class = (print $class " isVerified")}}
{{else if eq .Verification.TrustStatus "untrusted"}}
{{$class = (print $class " isVerifiedUntrusted")}}
{{else}}
{{$class = (print $class " isVerifiedUnmatched")}}
{{end}}
{{else if .Verification.Warning}}
{{$class = (print $class " isWarning")}}
{{end}}
{{end}}
{{$commitShaLink := ""}}
{{if $.PageIsWiki}} {{if $.PageIsWiki}}
{{$commitShaLink = (printf "%s/wiki/commit/%s" $commitRepoLink (PathEscape .ID.String))}} {{$commitBaseLink = printf "%s/wiki/commit" $commitRepoLink}}
{{else if $.PageIsPullCommits}} {{else if $.PageIsPullCommits}}
{{$commitShaLink = (printf "%s/pulls/%d/commits/%s" $commitRepoLink $.Issue.Index (PathEscape .ID.String))}} {{$commitBaseLink = printf "%s/pulls/%d/commits" $commitRepoLink $.Issue.Index}}
{{else if $.Reponame}} {{else if $.Reponame}}
{{$commitShaLink = (printf "%s/commit/%s" $commitRepoLink (PathEscape .ID.String))}} {{$commitBaseLink = printf "%s/commit" $commitRepoLink}}
{{end}} {{end}}
<a {{if $commitShaLink}}href="{{$commitShaLink}}"{{end}} class="{{$class}}"> {{template "repo/commit_sign_badge" dict "Commit" . "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}}
<span class="shortsha">{{ShortSha .ID.String}}</span>
{{if .Signature}}{{template "repo/shabox_badge" dict "root" $ "verification" .Verification}}{{end}}
</a>
</td> </td>
<td class="message"> <td class="message">
<span class="message-wrapper"> <span class="message-wrapper">

View File

@ -3,7 +3,7 @@
{{range .comment.Commits}} {{range .comment.Commits}}
{{$tag := printf "%s-%d" $.comment.HashTag $index}} {{$tag := printf "%s-%d" $.comment.HashTag $index}}
{{$index = Eval $index "+" 1}} {{$index = Eval $index "+" 1}}
<div class="singular-commit" id="{{$tag}}"> <div class="flex-text-block" id="{{$tag}}">{{/*singular-commit*/}}
<span class="badge badge-commit">{{svg "octicon-git-commit"}}</span> <span class="badge badge-commit">{{svg "octicon-git-commit"}}</span>
{{if .User}} {{if .User}}
<a class="avatar" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}}</a> <a class="avatar" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}}</a>
@ -11,7 +11,8 @@
{{ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20}} {{ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20}}
{{end}} {{end}}
{{$commitLink:= printf "%s/commit/%s" $.comment.Issue.PullRequest.BaseRepo.Link (PathEscape .ID.String)}} {{$commitBaseLink := printf "%s/commit" $.comment.Issue.PullRequest.BaseRepo.Link}}
{{$commitLink:= printf "%s/%s" $commitBaseLink (PathEscape .ID.String)}}
<span class="tw-flex-1 tw-font-mono gt-ellipsis" title="{{.Summary}}"> <span class="tw-flex-1 tw-font-mono gt-ellipsis" title="{{.Summary}}">
{{- ctx.RenderUtils.RenderCommitMessageLinkSubject .Message $commitLink ($.comment.Issue.PullRequest.BaseRepo.ComposeMetas ctx) -}} {{- ctx.RenderUtils.RenderCommitMessageLinkSubject .Message $commitLink ($.comment.Issue.PullRequest.BaseRepo.ComposeMetas ctx) -}}
@ -21,29 +22,9 @@
<button class="ui button ellipsis-button show-panel toggle" data-panel="[data-singular-commit-body-for='{{$tag}}']">...</button> <button class="ui button ellipsis-button show-panel toggle" data-panel="[data-singular-commit-body-for='{{$tag}}']">...</button>
{{end}} {{end}}
<span class="shabox tw-flex tw-items-center"> <span class="tw-flex tw-items-center tw-gap-2">
{{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}} {{template "repo/commit_statuses" dict "Status" .Status "Statuses" .Statuses}}
{{$class := "ui sha label"}} {{template "repo/commit_sign_badge" dict "Commit" . "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}}
{{if .Signature}}
{{$class = (print $class " isSigned")}}
{{if .Verification.Verified}}
{{if eq .Verification.TrustStatus "trusted"}}
{{$class = (print $class " isVerified")}}
{{else if eq .Verification.TrustStatus "untrusted"}}
{{$class = (print $class " isVerifiedUntrusted")}}
{{else}}
{{$class = (print $class " isVerifiedUnmatched")}}
{{end}}
{{else if .Verification.Warning}}
{{$class = (print $class " isWarning")}}
{{end}}
{{end}}
<a href="{{$commitLink}}" rel="nofollow" class="tw-ml-2 {{$class}}">
<span class="shortsha">{{ShortSha .ID.String}}</span>
{{if .Signature}}
{{template "repo/shabox_badge" dict "root" $.root "verification" .Verification}}
{{end}}
</a>
</span> </span>
</div> </div>
{{if IsMultilineCommitMessage .Message}} {{if IsMultilineCommitMessage .Message}}

View File

@ -1,5 +1,7 @@
{{$file := .file}} {{$file := .file}}
{{$blobExcerptLink := print (or ctx.RootData.CommitRepoLink ctx.RootData.RepoLink) (Iif $.root.PageIsWiki "/wiki" "") "/blob_excerpt/" (PathEscape $.root.AfterCommitID) "?"}} {{$repoLink := or ctx.RootData.CommitRepoLink ctx.RootData.RepoLink}}
{{$afterCommitID := or $.root.AfterCommitID "no-after-commit-id"}}{{/* this tmpl is also used by the PR Conversation page, so the "AfterCommitID" may not exist */}}
{{$blobExcerptLink := print $repoLink (Iif $.root.PageIsWiki "/wiki" "") "/blob_excerpt/" (PathEscape $afterCommitID) "?"}}
<colgroup> <colgroup>
<col width="50"> <col width="50">
<col width="50"> <col width="50">

View File

@ -5,33 +5,13 @@
{{if $commit.OnlyRelation}} {{if $commit.OnlyRelation}}
<span></span> <span></span>
{{else}} {{else}}
<span class="sha" id="{{$commit.ShortRev}}"> {{template "repo/commit_sign_badge" dict "Commit" $commit.Commit "CommitBaseLink" (print $.RepoLink "/commit") "CommitSignVerification" $commit.Verification}}
{{$class := "ui sha label"}}
{{if $commit.Commit.Signature}} <span class="message tw-inline-block gt-ellipsis">
{{$class = (print $class " isSigned")}}
{{if $commit.Verification.Verified}}
{{if eq $commit.Verification.TrustStatus "trusted"}}
{{$class = (print $class " isVerified")}}
{{else if eq $commit.Verification.TrustStatus "untrusted"}}
{{$class = (print $class " isVerifiedUntrusted")}}
{{else}}
{{$class = (print $class " isVerifiedUnmatched")}}
{{end}}
{{else if $commit.Verification.Warning}}
{{$class = (print $class " isWarning")}}
{{end}}
{{end}}
<a href="{{$.RepoLink}}/commit/{{$commit.Rev|PathEscape}}" rel="nofollow" class="{{$class}}">
<span class="shortsha">{{ShortSha $commit.Commit.ID.String}}</span>
{{- if $commit.Commit.Signature -}}
{{template "repo/shabox_badge" dict "root" $ "verification" $commit.Verification}}
{{- end -}}
</a>
</span>
<span class="message tw-inline-block gt-ellipsis tw-mr-2">
<span>{{ctx.RenderUtils.RenderCommitMessage $commit.Subject ($.Repository.ComposeMetas ctx)}}</span> <span>{{ctx.RenderUtils.RenderCommitMessage $commit.Subject ($.Repository.ComposeMetas ctx)}}</span>
</span> </span>
<span class="commit-refs tw-flex tw-items-center tw-mr-1">
<span class="commit-refs flex-text-inline">
{{range $commit.Refs}} {{range $commit.Refs}}
{{$refGroup := .RefGroup}} {{$refGroup := .RefGroup}}
{{if eq $refGroup "pull"}} {{if eq $refGroup "pull"}}
@ -56,7 +36,8 @@
{{end}} {{end}}
{{end}} {{end}}
</span> </span>
<span class="author tw-flex tw-items-center tw-mr-2 tw-gap-1">
<span class="author flex-text-inline">
{{$userName := $commit.Commit.Author.Name}} {{$userName := $commit.Commit.Author.Name}}
{{if $commit.User}} {{if $commit.User}}
{{if and $commit.User.FullName DefaultShowFullName}} {{if and $commit.User.FullName DefaultShowFullName}}
@ -69,7 +50,8 @@
{{$userName}} {{$userName}}
{{end}} {{end}}
</span> </span>
<span class="time tw-flex tw-items-center">{{DateUtils.FullTime $commit.Date}}</span>
<span class="time flex-text-inline">{{DateUtils.FullTime $commit.Date}}</span>
{{end}} {{end}}
</li> </li>
{{end}} {{end}}

View File

@ -1,3 +1,4 @@
<div class="latest-commit">
{{if not .LatestCommit}} {{if not .LatestCommit}}
{{else}} {{else}}
@ -14,13 +15,11 @@
<span class="author-wrapper" title="{{.LatestCommit.Author.Name}}"><strong>{{.LatestCommit.Author.Name}}</strong></span> <span class="author-wrapper" title="{{.LatestCommit.Author.Name}}"><strong>{{.LatestCommit.Author.Name}}</strong></span>
{{end}} {{end}}
{{end}} {{end}}
<a rel="nofollow" class="ui sha label {{if .LatestCommit.Signature}} isSigned {{if .LatestCommitVerification.Verified}} isVerified{{if eq .LatestCommitVerification.TrustStatus "trusted"}}{{else if eq .LatestCommitVerification.TrustStatus "untrusted"}}Untrusted{{else}}Unmatched{{end}}{{else if .LatestCommitVerification.Warning}} isWarning{{end}}{{end}}" href="{{.RepoLink}}/commit/{{PathEscape .LatestCommit.ID.String}}">
<span class="shortsha">{{ShortSha .LatestCommit.ID.String}}</span> {{template "repo/commit_sign_badge" dict "Commit" .LatestCommit "CommitBaseLink" (print .RepoLink "/commit") "CommitSignVerification" .LatestCommitVerification}}
{{if .LatestCommit.Signature}}
{{template "repo/shabox_badge" dict "root" $ "verification" .LatestCommitVerification}}
{{end}}
</a>
{{template "repo/commit_statuses" dict "Status" .LatestCommitStatus "Statuses" .LatestCommitStatuses}} {{template "repo/commit_statuses" dict "Status" .LatestCommitStatus "Statuses" .LatestCommitStatuses}}
{{$commitLink:= printf "%s/commit/%s" .RepoLink (PathEscape .LatestCommit.ID.String)}} {{$commitLink:= printf "%s/commit/%s" .RepoLink (PathEscape .LatestCommit.ID.String)}}
<span class="grey commit-summary" title="{{.LatestCommit.Summary}}"><span class="message-wrapper">{{ctx.RenderUtils.RenderCommitMessageLinkSubject .LatestCommit.Message $commitLink ($.Repository.ComposeMetas ctx)}}</span> <span class="grey commit-summary" title="{{.LatestCommit.Summary}}"><span class="message-wrapper">{{ctx.RenderUtils.RenderCommitMessageLinkSubject .LatestCommit.Message $commitLink ($.Repository.ComposeMetas ctx)}}</span>
{{if IsMultilineCommitMessage .LatestCommit.Message}} {{if IsMultilineCommitMessage .LatestCommit.Message}}
@ -29,3 +28,4 @@
{{end}} {{end}}
</span> </span>
{{end}} {{end}}
</div>

View File

@ -1,15 +0,0 @@
<div class="ui detail icon button">
{{if .verification.Verified}}
<div title="{{if eq .verification.TrustStatus "trusted"}}{{else if eq .verification.TrustStatus "untrusted"}}{{ctx.Locale.Tr "repo.commits.signed_by_untrusted_user"}}: {{else}}{{ctx.Locale.Tr "repo.commits.signed_by_untrusted_user_unmatched"}}: {{end}}{{.verification.Reason}}">
{{if ne .verification.SigningUser.ID 0}}
{{svg "gitea-lock"}}
{{ctx.AvatarUtils.Avatar .verification.SigningUser 16 "signature"}}
{{else}}
<span title="{{ctx.Locale.Tr "gpg.default_key"}}">{{svg "gitea-lock-cog"}}</span>
{{ctx.AvatarUtils.AvatarByEmail .verification.SigningEmail "" 16 "signature"}}
{{end}}
</div>
{{else}}
<span title="{{ctx.Locale.Tr .verification.Reason}}">{{svg "gitea-unlock"}}</span>
{{end}}
</div>

View File

@ -12,9 +12,7 @@
{{if not .ReadmeInList}} {{if not .ReadmeInList}}
<div id="repo-file-commit-box" class="ui segment list-header tw-mb-4 tw-flex tw-justify-between"> <div id="repo-file-commit-box" class="ui segment list-header tw-mb-4 tw-flex tw-justify-between">
<div class="latest-commit">
{{template "repo/latest_commit" .}} {{template "repo/latest_commit" .}}
</div>
{{if .LatestCommit}} {{if .LatestCommit}}
{{if .LatestCommit.Committer}} {{if .LatestCommit.Committer}}
<div class="text grey age"> <div class="text grey age">

View File

@ -1,7 +1,7 @@
{{/* use grid layout, still use the old ID because there are many other CSS styles depending on this ID */}} {{/* use grid layout, still use the old ID because there are many other CSS styles depending on this ID */}}
<div id="repo-files-table" {{if .HasFilesWithoutLatestCommit}}hx-indicator="#repo-files-table .repo-file-cell.message" hx-trigger="load" hx-swap="morph" hx-post="{{.LastCommitLoaderURL}}"{{end}}> <div id="repo-files-table" {{if .HasFilesWithoutLatestCommit}}hx-indicator="#repo-files-table .repo-file-cell.message" hx-trigger="load" hx-swap="morph" hx-post="{{.LastCommitLoaderURL}}"{{end}}>
<div class="repo-file-line repo-file-last-commit"> <div class="repo-file-line repo-file-last-commit">
<div class="latest-commit">{{template "repo/latest_commit" .}}</div> {{template "repo/latest_commit" .}}
<div>{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}</div> <div>{{if and .LatestCommit .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}</div>
</div> </div>
{{if .HasParentPath}} {{if .HasParentPath}}

View File

@ -92,6 +92,9 @@
</li> </li>
{{end}} {{end}}
{{end}} {{end}}
{{if .ShowMoreOrgs}}
<li><a class="tw-align-center" href="{{.ContextUser.HomeLink}}?tab=organizations" data-tooltip-content="{{ctx.Locale.Tr "user.show_more"}}">{{svg "octicon-kebab-horizontal" 28 "icon tw-p-1"}}</a></li>
{{end}}
</ul> </ul>
</li> </li>
{{end}} {{end}}

View File

@ -27,6 +27,8 @@
{{template "repo/user_cards" .}} {{template "repo/user_cards" .}}
{{else if eq .TabName "overview"}} {{else if eq .TabName "overview"}}
<div id="readme_profile" class="markup">{{.ProfileReadme}}</div> <div id="readme_profile" class="markup">{{.ProfileReadme}}</div>
{{else if eq .TabName "organizations"}}
{{template "repo/user_cards" .}}
{{else}} {{else}}
{{template "shared/repo_search" .}} {{template "shared/repo_search" .}}
{{template "explore/repo_list" .}} {{template "explore/repo_list" .}}

View File

@ -0,0 +1,410 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
)
func TestJobWithNeeds(t *testing.T) {
testCases := []struct {
treePath string
fileContent string
outcomes map[string]*mockTaskOutcome
expectedStatuses map[string]string
}{
{
treePath: ".gitea/workflows/job-with-needs.yml",
fileContent: `name: job-with-needs
on:
push:
paths:
- '.gitea/workflows/job-with-needs.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo job2
`,
outcomes: map[string]*mockTaskOutcome{
"job1": {
result: runnerv1.Result_RESULT_SUCCESS,
},
"job2": {
result: runnerv1.Result_RESULT_SUCCESS,
},
},
expectedStatuses: map[string]string{
"job1": actions_model.StatusSuccess.String(),
"job2": actions_model.StatusSuccess.String(),
},
},
{
treePath: ".gitea/workflows/job-with-needs-fail.yml",
fileContent: `name: job-with-needs-fail
on:
push:
paths:
- '.gitea/workflows/job-with-needs-fail.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo job2
`,
outcomes: map[string]*mockTaskOutcome{
"job1": {
result: runnerv1.Result_RESULT_FAILURE,
},
},
expectedStatuses: map[string]string{
"job1": actions_model.StatusFailure.String(),
"job2": actions_model.StatusSkipped.String(),
},
},
{
treePath: ".gitea/workflows/job-with-needs-fail-if.yml",
fileContent: `name: job-with-needs-fail-if
on:
push:
paths:
- '.gitea/workflows/job-with-needs-fail-if.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
if: ${{ always() }}
needs: [job1]
steps:
- run: echo job2
`,
outcomes: map[string]*mockTaskOutcome{
"job1": {
result: runnerv1.Result_RESULT_FAILURE,
},
"job2": {
result: runnerv1.Result_RESULT_SUCCESS,
},
},
expectedStatuses: map[string]string{
"job1": actions_model.StatusFailure.String(),
"job2": actions_model.StatusSuccess.String(),
},
},
}
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-jobs-with-needs", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"})
for _, tc := range testCases {
t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) {
// create the workflow file
opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent)
fileResp := createWorkflowFile(t, token, user2.Name, apiRepo.Name, tc.treePath, opts)
// fetch and execute task
for i := 0; i < len(tc.outcomes); i++ {
task := runner.fetchTask(t)
jobName := getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, task.Id)
outcome := tc.outcomes[jobName]
assert.NotNil(t, outcome)
runner.execTask(t, task, outcome)
}
// check result
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", user2.Name, apiRepo.Name)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var actionTaskRespAfter api.ActionTaskResponse
DecodeJSON(t, resp, &actionTaskRespAfter)
for _, apiTask := range actionTaskRespAfter.Entries {
if apiTask.HeadSHA != fileResp.Commit.SHA {
continue
}
status := apiTask.Status
assert.Equal(t, status, tc.expectedStatuses[apiTask.Name])
}
})
}
httpContext := NewAPITestContext(t, user2.Name, apiRepo.Name, auth_model.AccessTokenScopeWriteRepository)
doAPIDeleteRepository(httpContext)(t)
})
}
func TestJobNeedsMatrix(t *testing.T) {
testCases := []struct {
treePath string
fileContent string
outcomes map[string]*mockTaskOutcome
expectedTaskNeeds map[string]*runnerv1.TaskNeed // jobID => TaskNeed
}{
{
treePath: ".gitea/workflows/jobs-outputs-with-matrix.yml",
fileContent: `name: jobs-outputs-with-matrix
on:
push:
paths:
- '.gitea/workflows/jobs-outputs-with-matrix.yml'
jobs:
job1:
runs-on: ubuntu-latest
outputs:
output_1: ${{ steps.gen_output.outputs.output_1 }}
output_2: ${{ steps.gen_output.outputs.output_2 }}
output_3: ${{ steps.gen_output.outputs.output_3 }}
strategy:
matrix:
version: [1, 2, 3]
steps:
- name: Generate output
id: gen_output
run: |
version="${{ matrix.version }}"
echo "output_${version}=${version}" >> "$GITHUB_OUTPUT"
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo '${{ toJSON(needs.job1.outputs) }}'
`,
outcomes: map[string]*mockTaskOutcome{
"job1 (1)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "",
},
},
"job1 (2)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "2",
"output_3": "",
},
},
"job1 (3)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "3",
},
},
},
expectedTaskNeeds: map[string]*runnerv1.TaskNeed{
"job1": {
Result: runnerv1.Result_RESULT_SUCCESS,
Outputs: map[string]string{
"output_1": "1",
"output_2": "2",
"output_3": "3",
},
},
},
},
{
treePath: ".gitea/workflows/jobs-outputs-with-matrix-failure.yml",
fileContent: `name: jobs-outputs-with-matrix-failure
on:
push:
paths:
- '.gitea/workflows/jobs-outputs-with-matrix-failure.yml'
jobs:
job1:
runs-on: ubuntu-latest
outputs:
output_1: ${{ steps.gen_output.outputs.output_1 }}
output_2: ${{ steps.gen_output.outputs.output_2 }}
output_3: ${{ steps.gen_output.outputs.output_3 }}
strategy:
matrix:
version: [1, 2, 3]
steps:
- name: Generate output
id: gen_output
run: |
version="${{ matrix.version }}"
echo "output_${version}=${version}" >> "$GITHUB_OUTPUT"
job2:
runs-on: ubuntu-latest
if: ${{ always() }}
needs: [job1]
steps:
- run: echo '${{ toJSON(needs.job1.outputs) }}'
`,
outcomes: map[string]*mockTaskOutcome{
"job1 (1)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "",
},
},
"job1 (2)": {
result: runnerv1.Result_RESULT_FAILURE,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "",
},
},
"job1 (3)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "3",
},
},
},
expectedTaskNeeds: map[string]*runnerv1.TaskNeed{
"job1": {
Result: runnerv1.Result_RESULT_FAILURE,
Outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "3",
},
},
},
},
}
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-jobs-outputs-with-matrix", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"})
for _, tc := range testCases {
t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) {
opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent)
createWorkflowFile(t, token, user2.Name, apiRepo.Name, tc.treePath, opts)
for i := 0; i < len(tc.outcomes); i++ {
task := runner.fetchTask(t)
jobName := getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, task.Id)
outcome := tc.outcomes[jobName]
assert.NotNil(t, outcome)
runner.execTask(t, task, outcome)
}
task := runner.fetchTask(t)
actualTaskNeeds := task.Needs
assert.Len(t, actualTaskNeeds, len(tc.expectedTaskNeeds))
for jobID, tn := range tc.expectedTaskNeeds {
actualNeed := actualTaskNeeds[jobID]
assert.Equal(t, tn.Result, actualNeed.Result)
assert.Len(t, actualNeed.Outputs, len(tn.Outputs))
for outputKey, outputValue := range tn.Outputs {
assert.Equal(t, outputValue, actualNeed.Outputs[outputKey])
}
}
})
}
httpContext := NewAPITestContext(t, user2.Name, apiRepo.Name, auth_model.AccessTokenScopeWriteRepository)
doAPIDeleteRepository(httpContext)(t)
})
}
func createActionsTestRepo(t *testing.T, authToken, repoName string, isPrivate bool) *api.Repository {
req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
Name: repoName,
Private: isPrivate,
Readme: "Default",
AutoInit: true,
DefaultBranch: "main",
}).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusCreated)
var apiRepo api.Repository
DecodeJSON(t, resp, &apiRepo)
return &apiRepo
}
func getWorkflowCreateFileOptions(u *user_model.User, branch, msg, content string) *api.CreateFileOptions {
return &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: branch,
Message: msg,
Author: api.Identity{
Name: u.Name,
Email: u.Email,
},
Committer: api.Identity{
Name: u.Name,
Email: u.Email,
},
Dates: api.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(content)),
}
}
func createWorkflowFile(t *testing.T, authToken, ownerName, repoName, treePath string, opts *api.CreateFileOptions) *api.FileResponse {
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ownerName, repoName, treePath), opts).
AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusCreated)
var fileResponse api.FileResponse
DecodeJSON(t, resp, &fileResponse)
return &fileResponse
}
// getTaskJobNameByTaskID get the job name of the task by task ID
// there is currently not an API for querying a task by ID so we have to list all the tasks
func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string, taskID int64) string {
// FIXME: we may need to query several pages
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", ownerName, repoName)).
AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
var taskRespBefore api.ActionTaskResponse
DecodeJSON(t, resp, &taskRespBefore)
for _, apiTask := range taskRespBefore.Entries {
if apiTask.ID == taskID {
return apiTask.Name
}
}
return ""
}

View File

@ -0,0 +1,159 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/test"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestDownloadTaskLogs(t *testing.T) {
now := time.Now()
testCases := []struct {
treePath string
fileContent string
outcome *mockTaskOutcome
zstdEnabled bool
}{
{
treePath: ".gitea/workflows/download-task-logs-zstd.yml",
fileContent: `name: download-task-logs-zstd
on:
push:
paths:
- '.gitea/workflows/download-task-logs-zstd.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1 with zstd enabled
`,
outcome: &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: " \U0001F433 docker create image",
},
{
Time: timestamppb.New(now.Add(2 * time.Second)),
Content: "job1 zstd enabled",
},
{
Time: timestamppb.New(now.Add(3 * time.Second)),
Content: "\U0001F3C1 Job succeeded",
},
},
},
zstdEnabled: true,
},
{
treePath: ".gitea/workflows/download-task-logs-no-zstd.yml",
fileContent: `name: download-task-logs-no-zstd
on:
push:
paths:
- '.gitea/workflows/download-task-logs-no-zstd.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1 with zstd disabled
`,
outcome: &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(4 * time.Second)),
Content: " \U0001F433 docker create image",
},
{
Time: timestamppb.New(now.Add(5 * time.Second)),
Content: "job1 zstd disabled",
},
{
Time: timestamppb.New(now.Add(6 * time.Second)),
Content: "\U0001F3C1 Job succeeded",
},
},
},
zstdEnabled: false,
},
}
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-download-task-logs", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"})
for _, tc := range testCases {
t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) {
var resetFunc func()
if tc.zstdEnabled {
resetFunc = test.MockVariableValue(&setting.Actions.LogCompression, "zstd")
assert.True(t, setting.Actions.LogCompression.IsZstd())
} else {
resetFunc = test.MockVariableValue(&setting.Actions.LogCompression, "none")
assert.False(t, setting.Actions.LogCompression.IsZstd())
}
// create the workflow file
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent)
createWorkflowFile(t, token, user2.Name, repo.Name, tc.treePath, opts)
// fetch and execute task
task := runner.fetchTask(t)
runner.execTask(t, task, tc.outcome)
// check whether the log file exists
logFileName := fmt.Sprintf("%s/%02x/%d.log", repo.FullName(), task.Id%256, task.Id)
if setting.Actions.LogCompression.IsZstd() {
logFileName += ".zst"
}
_, err := storage.Actions.Stat(logFileName)
assert.NoError(t, err)
// download task logs and check content
runIndex := task.Context.GetFields()["run_number"].GetStringValue()
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%s/jobs/0/logs", user2.Name, repo.Name, runIndex)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
logTextLines := strings.Split(strings.TrimSpace(resp.Body.String()), "\n")
assert.Len(t, logTextLines, len(tc.outcome.logRows))
for idx, lr := range tc.outcome.logRows {
assert.Equal(
t,
fmt.Sprintf("%s %s", lr.Time.AsTime().Format("2006-01-02T15:04:05.0000000Z07:00"), lr.Content),
logTextLines[idx],
)
}
resetFunc()
})
}
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
doAPIDeleteRepository(httpContext)(t)
})
}

View File

@ -0,0 +1,157 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"fmt"
"net/http"
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/setting"
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
type mockRunner struct {
client *mockRunnerClient
}
type mockRunnerClient struct {
pingServiceClient pingv1connect.PingServiceClient
runnerServiceClient runnerv1connect.RunnerServiceClient
}
func newMockRunner() *mockRunner {
client := newMockRunnerClient("", "")
return &mockRunner{client: client}
}
func newMockRunnerClient(uuid, token string) *mockRunnerClient {
baseURL := fmt.Sprintf("%sapi/actions", setting.AppURL)
opt := connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
if uuid != "" {
req.Header().Set("x-runner-uuid", uuid)
}
if token != "" {
req.Header().Set("x-runner-token", token)
}
return next(ctx, req)
}
}))
client := &mockRunnerClient{
pingServiceClient: pingv1connect.NewPingServiceClient(http.DefaultClient, baseURL, opt),
runnerServiceClient: runnerv1connect.NewRunnerServiceClient(http.DefaultClient, baseURL, opt),
}
return client
}
func (r *mockRunner) doPing(t *testing.T) {
resp, err := r.client.pingServiceClient.Ping(context.Background(), connect.NewRequest(&pingv1.PingRequest{
Data: "mock-runner",
}))
assert.NoError(t, err)
assert.Equal(t, "Hello, mock-runner!", resp.Msg.Data)
}
func (r *mockRunner) doRegister(t *testing.T, name, token string, labels []string) {
r.doPing(t)
resp, err := r.client.runnerServiceClient.Register(context.Background(), connect.NewRequest(&runnerv1.RegisterRequest{
Name: name,
Token: token,
Version: "mock-runner-version",
Labels: labels,
}))
assert.NoError(t, err)
r.client = newMockRunnerClient(resp.Msg.Runner.Uuid, resp.Msg.Runner.Token)
}
func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, runnerName string, labels []string) {
session := loginUser(t, ownerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", ownerName, repoName)).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var registrationToken struct {
Token string `json:"token"`
}
DecodeJSON(t, resp, &registrationToken)
r.doRegister(t, runnerName, registrationToken.Token, labels)
}
func (r *mockRunner) fetchTask(t *testing.T, timeout ...time.Duration) *runnerv1.Task {
fetchTimeout := 10 * time.Second
if len(timeout) > 0 {
fetchTimeout = timeout[0]
}
ddl := time.Now().Add(fetchTimeout)
var task *runnerv1.Task
for time.Now().Before(ddl) {
resp, err := r.client.runnerServiceClient.FetchTask(context.Background(), connect.NewRequest(&runnerv1.FetchTaskRequest{
TasksVersion: 0,
}))
assert.NoError(t, err)
if resp.Msg.Task != nil {
task = resp.Msg.Task
break
}
time.Sleep(time.Second)
}
assert.NotNil(t, task, "failed to fetch a task")
return task
}
type mockTaskOutcome struct {
result runnerv1.Result
outputs map[string]string
logRows []*runnerv1.LogRow
execTime time.Duration
}
func (r *mockRunner) execTask(t *testing.T, task *runnerv1.Task, outcome *mockTaskOutcome) {
for idx, lr := range outcome.logRows {
resp, err := r.client.runnerServiceClient.UpdateLog(context.Background(), connect.NewRequest(&runnerv1.UpdateLogRequest{
TaskId: task.Id,
Index: int64(idx),
Rows: []*runnerv1.LogRow{lr},
NoMore: idx == len(outcome.logRows)-1,
}))
assert.NoError(t, err)
assert.EqualValues(t, idx+1, resp.Msg.AckIndex)
}
sentOutputKeys := make([]string, 0, len(outcome.outputs))
for outputKey, outputValue := range outcome.outputs {
resp, err := r.client.runnerServiceClient.UpdateTask(context.Background(), connect.NewRequest(&runnerv1.UpdateTaskRequest{
State: &runnerv1.TaskState{
Id: task.Id,
Result: runnerv1.Result_RESULT_UNSPECIFIED,
},
Outputs: map[string]string{outputKey: outputValue},
}))
assert.NoError(t, err)
sentOutputKeys = append(sentOutputKeys, outputKey)
assert.ElementsMatch(t, sentOutputKeys, resp.Msg.SentOutputs)
}
time.Sleep(outcome.execTime)
resp, err := r.client.runnerServiceClient.UpdateTask(context.Background(), connect.NewRequest(&runnerv1.UpdateTaskRequest{
State: &runnerv1.TaskState{
Id: task.Id,
Result: outcome.result,
StoppedAt: timestamppb.Now(),
},
}))
assert.NoError(t, err)
assert.Equal(t, outcome.result, resp.Msg.State.Result)
}

View File

@ -117,27 +117,33 @@ func TestAPIAddIssueLabels(t *testing.T) {
func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) { func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures()) assert.NoError(t, unittest.LoadFixtures())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 6, RepoID: repo.ID})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
repoLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 10, RepoID: repo.ID})
orgLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 4, OrgID: owner.ID})
session := loginUser(t, owner.Name) user1Session := loginUser(t, "user1")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) token := getTokenForLoggedInUser(t, user1Session, auth_model.AccessTokenScopeWriteIssue)
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels",
repo.OwnerName, repo.Name, issue.Index) // add the org label and the repo label to the issue
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner.Name, repo.Name, issue.Index)
req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{
Labels: []any{"label1", "label2"}, Labels: []any{repoLabel.Name, orgLabel.Name},
}).AddTokenAuth(token) }).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
var apiLabels []*api.Label var apiLabels []*api.Label
DecodeJSON(t, resp, &apiLabels) DecodeJSON(t, resp, &apiLabels)
assert.Len(t, apiLabels, unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issue.ID})) assert.Len(t, apiLabels, unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issue.ID}))
var apiLabelNames []string var apiLabelNames []string
for _, label := range apiLabels { for _, label := range apiLabels {
apiLabelNames = append(apiLabelNames, label.Name) apiLabelNames = append(apiLabelNames, label.Name)
} }
assert.ElementsMatch(t, apiLabelNames, []string{"label1", "label2"}) assert.ElementsMatch(t, apiLabelNames, []string{repoLabel.Name, orgLabel.Name})
// delete labels
req = NewRequest(t, "DELETE", urlStr).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
} }
func TestAPIReplaceIssueLabels(t *testing.T) { func TestAPIReplaceIssueLabels(t *testing.T) {

View File

@ -274,7 +274,8 @@ func TestOrgTeamEmailInviteRedirectsNewUserWithActivation(t *testing.T) {
user, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist") user, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist")
assert.NoError(t, err) assert.NoError(t, err)
activateURL := fmt.Sprintf("/user/activate?code=%s", user.GenerateEmailActivateCode("doesnotexist@example.com")) activationCode := user_model.GenerateUserTimeLimitCode(&user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, user)
activateURL := fmt.Sprintf("/user/activate?code=%s", activationCode)
req = NewRequestWithValues(t, "POST", activateURL, map[string]string{ req = NewRequestWithValues(t, "POST", activateURL, map[string]string{
"password": "examplePassword!1", "password": "examplePassword!1",
}) })

View File

@ -12,6 +12,7 @@ import (
"strings" "strings"
"testing" "testing"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -226,3 +227,21 @@ func TestPullCreatePrFromBaseToFork(t *testing.T) {
assert.Regexp(t, "^/user1/repo1/pulls/[0-9]*$", url) assert.Regexp(t, "^/user1/repo1/pulls/[0-9]*$", url)
}) })
} }
func TestCreateAgitPullWithReadPermission(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
dstPath := t.TempDir()
u.Path = "user2/repo1.git"
u.User = url.UserPassword("user4", userPassword)
t.Run("Clone", doGitClone(dstPath, u))
t.Run("add commit", doGitAddSomeCommits(dstPath, "master"))
t.Run("do agit pull create", func(t *testing.T) {
err := git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/master", "-o").AddDynamicArguments("topic=" + "test-topic").Run(&git.RunOpts{Dir: dstPath})
assert.NoError(t, err)
})
})
}

Some files were not shown because too many files have changed in this diff Show More