Compare commits

...

16 Commits

Author SHA1 Message Date
Lunny Xiao
1971a41a54
Merge 7aaa2d6de4d780f9b1366978f036ea5e7f00dd63 into 8cbec63cc70f9ebbe1558123d95cbe63b2f31782 2025-07-03 10:37:51 +08:00
Lunny Xiao
8cbec63cc7
Don't send trigger for a pending review's comment create/update/delete (#34928)
Fix #18846 
Fix #34924

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-03 10:35:45 +08:00
RickyMa
6455c8202b
Support getting last commit message using contents-ext API (#34904)
Fix #34870
Fix #34929

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-03 09:45:42 +08:00
GiteaBot
97fc87af89 [skip ci] Updated translations via Crowdin 2025-07-03 00:37:58 +00:00
Lunny Xiao
7aaa2d6de4
remove nolint 2025-07-02 17:11:50 -07:00
Lunny Xiao
6f79a1a0cf Merge branch 'main' into lunny/improve_notification 2025-07-02 17:07:54 -07:00
Lunny Xiao
11bd0eaad4
improvements 2025-07-02 17:06:57 -07:00
silverwind
6fe5c4c4d9
Exclude devtest.ts from tailwindcss (#34935)
Fix this leftover from the typescript migration.
2025-07-02 18:00:16 -04:00
Lunny Xiao
7a1cc340b8 Merge branch 'main' into lunny/improve_notification 2025-06-21 09:37:39 -07:00
Lunny Xiao
fd17f057e5
improvements 2025-06-21 09:37:27 -07:00
Lunny Xiao
60227c5cdd Merge branch 'main' into lunny/improve_notification 2025-06-20 17:43:49 -07:00
Lunny Xiao
49e9ab0039
Fix lint 2025-06-20 17:43:36 -07:00
Lunny Xiao
01a0b8af2d
Add tests and fix lint 2025-06-20 17:35:10 -07:00
Lunny Xiao
4163c6dc68 Fix bug 2025-06-20 15:54:41 -07:00
Lunny Xiao
803a3a4426 fix 2025-06-20 15:54:41 -07:00
Lunny Xiao
6b055ddb9f Add release notification and fix repository transfer/commit notification 2025-06-20 15:54:41 -07:00
41 changed files with 979 additions and 316 deletions

View File

@ -6,15 +6,18 @@ package activities
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"net/url" "net/url"
"strconv" "strconv"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
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/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"xorm.io/builder" "xorm.io/builder"
@ -46,6 +49,8 @@ const (
NotificationSourceCommit NotificationSourceCommit
// NotificationSourceRepository is a notification for a repository // NotificationSourceRepository is a notification for a repository
NotificationSourceRepository NotificationSourceRepository
// NotificationSourceRelease is a notification for a release
NotificationSourceRelease
) )
// Notification represents a notification // Notification represents a notification
@ -60,6 +65,7 @@ type Notification struct {
IssueID int64 `xorm:"NOT NULL"` IssueID int64 `xorm:"NOT NULL"`
CommitID string CommitID string
CommentID int64 CommentID int64
ReleaseID int64
UpdatedBy int64 `xorm:"NOT NULL"` UpdatedBy int64 `xorm:"NOT NULL"`
@ -67,6 +73,8 @@ type Notification struct {
Repository *repo_model.Repository `xorm:"-"` Repository *repo_model.Repository `xorm:"-"`
Comment *issues_model.Comment `xorm:"-"` Comment *issues_model.Comment `xorm:"-"`
User *user_model.User `xorm:"-"` User *user_model.User `xorm:"-"`
Release *repo_model.Release `xorm:"-"`
Commit *git.Commit `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"` UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
@ -104,6 +112,10 @@ func (n *Notification) TableIndices() []*schemas.Index {
commitIDIndex.AddColumn("commit_id") commitIDIndex.AddColumn("commit_id")
indices = append(indices, commitIDIndex) indices = append(indices, commitIDIndex)
releaseIDIndex := schemas.NewIndex("idx_notification_release_id", schemas.IndexType)
releaseIDIndex.AddColumn("release_id")
indices = append(indices, releaseIDIndex)
updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType) updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType)
updatedByIndex.AddColumn("updated_by") updatedByIndex.AddColumn("updated_by")
indices = append(indices, updatedByIndex) indices = append(indices, updatedByIndex)
@ -116,36 +128,55 @@ func init() {
} }
// CreateRepoTransferNotification creates notification for the user a repository was transferred to // CreateRepoTransferNotification creates notification for the user a repository was transferred to
func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error { func CreateRepoTransferNotification(ctx context.Context, doerID, repoID, receiverID int64) error {
return db.WithTx(ctx, func(ctx context.Context) error { notify := &Notification{
var notify []*Notification UserID: receiverID,
RepoID: repoID,
Status: NotificationStatusUnread,
UpdatedBy: doerID,
Source: NotificationSourceRepository,
}
return db.Insert(ctx, notify)
}
if newOwner.IsOrganization() { func CreateCommitNotifications(ctx context.Context, doerID, repoID int64, commitID string, receiverID int64) error {
users, err := organization.GetUsersWhoCanCreateOrgRepo(ctx, newOwner.ID) notification := &Notification{
if err != nil || len(users) == 0 { Source: NotificationSourceCommit,
UserID: receiverID,
RepoID: repoID,
CommitID: commitID,
Status: NotificationStatusUnread,
UpdatedBy: doerID,
}
return db.Insert(ctx, notification)
}
func CreateOrUpdateReleaseNotifications(ctx context.Context, doerID, repoID, releaseID, receiverID int64) error {
notification := new(Notification)
if _, err := db.GetEngine(ctx).
Where("user_id = ?", receiverID).
And("repo_id = ?", repoID).
And("release_id = ?", releaseID).
Get(notification); err != nil {
return err return err
} }
for i := range users { if notification.ID > 0 {
notify = append(notify, &Notification{ notification.Status = NotificationStatusUnread
UserID: i, notification.UpdatedBy = doerID
RepoID: repo.ID, _, err := db.GetEngine(ctx).ID(notification.ID).Cols("status", "updated_by").Update(notification)
Status: NotificationStatusUnread, return err
UpdatedBy: doer.ID,
Source: NotificationSourceRepository,
})
}
} else {
notify = []*Notification{{
UserID: newOwner.ID,
RepoID: repo.ID,
Status: NotificationStatusUnread,
UpdatedBy: doer.ID,
Source: NotificationSourceRepository,
}}
} }
return db.Insert(ctx, notify) notification = &Notification{
}) Source: NotificationSourceRelease,
RepoID: repoID,
UserID: receiverID,
Status: NotificationStatusUnread,
ReleaseID: releaseID,
UpdatedBy: doerID,
}
return db.Insert(ctx, notification)
} }
func createIssueNotification(ctx context.Context, userID int64, issue *issues_model.Issue, commentID, updatedByID int64) error { func createIssueNotification(ctx context.Context, userID int64, issue *issues_model.Issue, commentID, updatedByID int64) error {
@ -213,6 +244,12 @@ func (n *Notification) LoadAttributes(ctx context.Context) (err error) {
if err = n.loadComment(ctx); err != nil { if err = n.loadComment(ctx); err != nil {
return err return err
} }
if err = n.loadCommit(ctx); err != nil {
return err
}
if err = n.loadRelease(ctx); err != nil {
return err
}
return err return err
} }
@ -253,6 +290,41 @@ func (n *Notification) loadComment(ctx context.Context) (err error) {
return nil return nil
} }
func (n *Notification) loadCommit(ctx context.Context) (err error) {
if n.Source != NotificationSourceCommit || n.CommitID == "" || n.Commit != nil {
return nil
}
if n.Repository == nil {
_ = n.loadRepo(ctx)
if n.Repository == nil {
return fmt.Errorf("repository not found for notification %d", n.ID)
}
}
repo, err := gitrepo.OpenRepository(ctx, n.Repository)
if err != nil {
return fmt.Errorf("OpenRepository [%d]: %w", n.Repository.ID, err)
}
defer repo.Close()
n.Commit, err = repo.GetCommit(n.CommitID)
if err != nil {
return fmt.Errorf("Notification[%d]: Failed to get repo for commit %s: %v", n.ID, n.CommitID, err)
}
return nil
}
func (n *Notification) loadRelease(ctx context.Context) (err error) {
if n.Release == nil && n.ReleaseID != 0 {
n.Release, err = repo_model.GetReleaseByID(ctx, n.ReleaseID)
if err != nil {
return fmt.Errorf("GetReleaseByID [%d]: %w", n.ReleaseID, err)
}
}
return nil
}
func (n *Notification) loadUser(ctx context.Context) (err error) { func (n *Notification) loadUser(ctx context.Context) (err error) {
if n.User == nil { if n.User == nil {
n.User, err = user_model.GetUserByID(ctx, n.UserID) n.User, err = user_model.GetUserByID(ctx, n.UserID)
@ -285,6 +357,8 @@ func (n *Notification) HTMLURL(ctx context.Context) string {
return n.Repository.HTMLURL() + "/commit/" + url.PathEscape(n.CommitID) return n.Repository.HTMLURL() + "/commit/" + url.PathEscape(n.CommitID)
case NotificationSourceRepository: case NotificationSourceRepository:
return n.Repository.HTMLURL() return n.Repository.HTMLURL()
case NotificationSourceRelease:
return n.Release.HTMLURL()
} }
return "" return ""
} }
@ -301,10 +375,28 @@ func (n *Notification) Link(ctx context.Context) string {
return n.Repository.Link() + "/commit/" + url.PathEscape(n.CommitID) return n.Repository.Link() + "/commit/" + url.PathEscape(n.CommitID)
case NotificationSourceRepository: case NotificationSourceRepository:
return n.Repository.Link() return n.Repository.Link()
case NotificationSourceRelease:
return n.Release.Link()
} }
return "" return ""
} }
func (n *Notification) IconHTML(ctx context.Context) template.HTML {
switch n.Source {
case NotificationSourceIssue, NotificationSourcePullRequest:
// n.Issue should be loaded before calling this method
return n.Issue.IconHTML(ctx)
case NotificationSourceCommit:
return svg.RenderHTML("octicon-commit", 16, "text grey")
case NotificationSourceRepository:
return svg.RenderHTML("octicon-repo", 16, "text grey")
case NotificationSourceRelease:
return svg.RenderHTML("octicon-tag", 16, "text grey")
default:
return ""
}
}
// APIURL formats a URL-string to the notification // APIURL formats a URL-string to the notification
func (n *Notification) APIURL() string { func (n *Notification) APIURL() string {
return setting.AppURL + "api/v1/notifications/threads/" + strconv.FormatInt(n.ID, 10) return setting.AppURL + "api/v1/notifications/threads/" + strconv.FormatInt(n.ID, 10)
@ -373,6 +465,28 @@ func SetRepoReadBy(ctx context.Context, userID, repoID int64) error {
return err return err
} }
// SetReleaseReadBy sets issue to be read by given user.
func SetReleaseReadBy(ctx context.Context, releaseID, userID int64) error {
_, err := db.GetEngine(ctx).Where(builder.Eq{
"user_id": userID,
"status": NotificationStatusUnread,
"source": NotificationSourceRelease,
"release_id": releaseID,
}).Cols("status").Update(&Notification{Status: NotificationStatusRead})
return err
}
// SetCommitReadBy sets issue to be read by given user.
func SetCommitReadBy(ctx context.Context, repoID, userID int64, commitID string) error {
_, err := db.GetEngine(ctx).Where(builder.Eq{
"user_id": userID,
"status": NotificationStatusUnread,
"source": NotificationSourceCommit,
"commit_id": commitID,
}).Cols("status").Update(&Notification{Status: NotificationStatusRead})
return err
}
// SetNotificationStatus change the notification status // SetNotificationStatus change the notification status
func SetNotificationStatus(ctx context.Context, notificationID int64, user *user_model.User, status NotificationStatus) (*Notification, error) { func SetNotificationStatus(ctx context.Context, notificationID int64, user *user_model.User, status NotificationStatus) (*Notification, error) {
notification, err := GetNotificationByID(ctx, notificationID) notification, err := GetNotificationByID(ctx, notificationID)
@ -385,8 +499,7 @@ func SetNotificationStatus(ctx context.Context, notificationID int64, user *user
} }
notification.Status = status notification.Status = status
_, err = db.GetEngine(ctx).ID(notificationID).Cols("status").Update(notification)
_, err = db.GetEngine(ctx).ID(notificationID).Update(notification)
return notification, err return notification, err
} }

View File

@ -13,6 +13,8 @@ import (
"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/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -25,6 +27,7 @@ type FindNotificationOptions struct {
UserID int64 UserID int64
RepoID int64 RepoID int64
IssueID int64 IssueID int64
ReleaseID int64
Status []NotificationStatus Status []NotificationStatus
Source []NotificationSource Source []NotificationSource
UpdatedAfterUnix int64 UpdatedAfterUnix int64
@ -43,6 +46,9 @@ func (opts FindNotificationOptions) ToConds() builder.Cond {
if opts.IssueID != 0 { if opts.IssueID != 0 {
cond = cond.And(builder.Eq{"notification.issue_id": opts.IssueID}) cond = cond.And(builder.Eq{"notification.issue_id": opts.IssueID})
} }
if opts.ReleaseID != 0 {
cond = cond.And(builder.Eq{"notification.release_id": opts.ReleaseID})
}
if len(opts.Status) > 0 { if len(opts.Status) > 0 {
if len(opts.Status) == 1 { if len(opts.Status) == 1 {
cond = cond.And(builder.Eq{"notification.status": opts.Status[0]}) cond = cond.And(builder.Eq{"notification.status": opts.Status[0]})
@ -70,17 +76,9 @@ func (opts FindNotificationOptions) ToOrders() string {
// for each watcher, or updates it if already exists // for each watcher, or updates it if already exists
// receiverID > 0 just send to receiver, else send to all watcher // receiverID > 0 just send to receiver, else send to all watcher
func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error { func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error {
ctx, committer, err := db.TxContext(ctx) return db.WithTx(ctx, func(ctx context.Context) error {
if err != nil { return createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID)
return err })
}
defer committer.Close()
if err := createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID); err != nil {
return err
}
return committer.Commit()
} }
func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error { func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) error {
@ -174,18 +172,31 @@ type NotificationList []*Notification
// LoadAttributes load Repo Issue User and Comment if not loaded // LoadAttributes load Repo Issue User and Comment if not loaded
func (nl NotificationList) LoadAttributes(ctx context.Context) error { func (nl NotificationList) LoadAttributes(ctx context.Context) error {
if _, _, err := nl.LoadRepos(ctx); err != nil { repos, _, err := nl.LoadRepos(ctx)
if err != nil {
return err
}
if err := repos.LoadAttributes(ctx); err != nil {
return err return err
} }
if _, err := nl.LoadIssues(ctx); err != nil { if _, err := nl.LoadIssues(ctx); err != nil {
return err return err
} }
if err = nl.LoadIssuePullRequests(ctx); err != nil {
return err
}
if _, err := nl.LoadUsers(ctx); err != nil { if _, err := nl.LoadUsers(ctx); err != nil {
return err return err
} }
if _, err := nl.LoadComments(ctx); err != nil { if _, err := nl.LoadComments(ctx); err != nil {
return err return err
} }
if _, err = nl.LoadCommits(ctx); err != nil {
return err
}
if _, err := nl.LoadReleases(ctx); err != nil {
return err
}
return nil return nil
} }
@ -458,6 +469,89 @@ func (nl NotificationList) LoadComments(ctx context.Context) ([]int, error) {
return failures, nil return failures, nil
} }
func (nl NotificationList) getPendingReleaseIDs() []int64 {
ids := make(container.Set[int64], len(nl))
for _, notification := range nl {
if notification.Release != nil {
continue
}
if notification.ReleaseID > 0 {
ids.Add(notification.ReleaseID)
}
}
return ids.Values()
}
func (nl NotificationList) LoadReleases(ctx context.Context) ([]int, error) {
if len(nl) == 0 {
return []int{}, nil
}
releaseIDs := nl.getPendingReleaseIDs()
releases := make(map[int64]*repo_model.Release, len(releaseIDs))
if err := db.GetEngine(ctx).In("id", releaseIDs).Find(&releases); err != nil {
return nil, err
}
failures := []int{}
for i, notification := range nl {
if notification.ReleaseID > 0 && notification.Release == nil && releases[notification.ReleaseID] != nil {
notification.Release = releases[notification.ReleaseID]
if notification.Release == nil {
log.Error("Notification[%d]: ReleaseID[%d] failed to load", notification.ID, notification.ReleaseID)
failures = append(failures, i)
continue
}
notification.Release.Repo = notification.Repository
}
}
return failures, nil
}
func (nl NotificationList) LoadCommits(ctx context.Context) ([]int, error) {
if len(nl) == 0 {
return []int{}, nil
}
_, _, err := nl.LoadRepos(ctx)
if err != nil {
return nil, err
}
failures := []int{}
repos := make(map[int64]*git.Repository, len(nl))
for i, n := range nl {
if n.Source != NotificationSourceCommit || n.CommitID == "" {
continue
}
repo, ok := repos[n.RepoID]
if !ok {
repo, err = gitrepo.OpenRepository(ctx, n.Repository)
if err != nil {
log.Error("Notification[%d]: Failed to get repo for commit %s: %v", n.ID, n.CommitID, err)
failures = append(failures, i)
continue
}
repos[n.RepoID] = repo
}
n.Commit, err = repo.GetCommit(n.CommitID)
if err != nil {
log.Error("Notification[%d]: Failed to get repo for commit %s: %v", n.ID, n.CommitID, err)
failures = append(failures, i)
continue
}
}
for _, repo := range repos {
if err := repo.Close(); err != nil {
log.Error("Failed to close repository: %v", err)
}
}
return failures, nil
}
// LoadIssuePullRequests loads all issues' pull requests if possible // LoadIssuePullRequests loads all issues' pull requests if possible
func (nl NotificationList) LoadIssuePullRequests(ctx context.Context) error { func (nl NotificationList) LoadIssuePullRequests(ctx context.Context) error {
issues := make(map[int64]*issues_model.Issue, len(nl)) issues := make(map[int64]*issues_model.Issue, len(nl))

View File

@ -715,7 +715,8 @@ func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository
return nil return nil
} }
func (c *Comment) loadReview(ctx context.Context) (err error) { // LoadReview loads the associated review
func (c *Comment) LoadReview(ctx context.Context) (err error) {
if c.ReviewID == 0 { if c.ReviewID == 0 {
return nil return nil
} }
@ -732,11 +733,6 @@ func (c *Comment) loadReview(ctx context.Context) (err error) {
return nil return nil
} }
// LoadReview loads the associated review
func (c *Comment) LoadReview(ctx context.Context) error {
return c.loadReview(ctx)
}
// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. // DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes.
func (c *Comment) DiffSide() string { func (c *Comment) DiffSide() string {
if c.Line < 0 { if c.Line < 0 {
@ -856,7 +852,7 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
} }
if comment.ReviewID != 0 { if comment.ReviewID != 0 {
if comment.Review == nil { if comment.Review == nil {
if err := comment.loadReview(ctx); err != nil { if err := comment.LoadReview(ctx); err != nil {
return err return err
} }
} }

View File

@ -21,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@ -442,6 +443,30 @@ func (issue *Issue) PatchURL() string {
return "" return ""
} }
/* the logic should be kept the same as getIssueIcon/getIssueColor in TS code */
func (issue *Issue) IconHTML(ctx context.Context) template.HTML {
if issue.IsPull {
if issue.PullRequest == nil { // pull request should be loaded before calling this function
return template.HTML("No PullRequest")
}
if issue.IsClosed {
if issue.PullRequest.HasMerged {
return svg.RenderHTML("octicon-git-merge", 16, "text purple")
}
return svg.RenderHTML("octicon-git-pull-request-closed", 16, "text red")
}
if issue.PullRequest.IsWorkInProgress(ctx) {
return svg.RenderHTML("octicon-git-pull-request-draft", 16, "text grey")
}
return svg.RenderHTML("octicon-git-pull-request", 16, "text green")
}
if issue.IsClosed {
return svg.RenderHTML("octicon-issue-closed", 16, "text red")
}
return svg.RenderHTML("octicon-issue-opened", 16, "text green")
}
// State returns string representation of issue status. // State returns string representation of issue status.
func (issue *Issue) State() api.StateType { func (issue *Issue) State() api.StateType {
if issue.IsClosed { if issue.IsClosed {

View File

@ -24,6 +24,7 @@ import (
"code.gitea.io/gitea/models/migrations/v1_22" "code.gitea.io/gitea/models/migrations/v1_22"
"code.gitea.io/gitea/models/migrations/v1_23" "code.gitea.io/gitea/models/migrations/v1_23"
"code.gitea.io/gitea/models/migrations/v1_24" "code.gitea.io/gitea/models/migrations/v1_24"
"code.gitea.io/gitea/models/migrations/v1_25"
"code.gitea.io/gitea/models/migrations/v1_6" "code.gitea.io/gitea/models/migrations/v1_6"
"code.gitea.io/gitea/models/migrations/v1_7" "code.gitea.io/gitea/models/migrations/v1_7"
"code.gitea.io/gitea/models/migrations/v1_8" "code.gitea.io/gitea/models/migrations/v1_8"
@ -382,6 +383,10 @@ func prepareMigrationTasks() []*migration {
newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode), newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode),
newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable),
newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor), newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor),
// Gitea 1.24.0-rc0 ends at migration ID number 320 (database version 321)
newMigration(321, "Add Index to action_task table", v1_25.AddReleaseNotification),
} }
return preparedMigrations return preparedMigrations
} }

View File

@ -0,0 +1,82 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_25
import (
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
// NotificationV321 represents a notification
type NotificationV321 struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL"`
RepoID int64 `xorm:"NOT NULL"`
Status uint8 `xorm:"SMALLINT NOT NULL"`
Source uint8 `xorm:"SMALLINT NOT NULL"`
IssueID int64 `xorm:"NOT NULL"`
CommitID string
CommentID int64
ReleaseID int64
UpdatedBy int64 `xorm:"NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
}
func (n *NotificationV321) TableName() string {
return "notification"
}
// TableIndices implements xorm's TableIndices interface
func (n *NotificationV321) TableIndices() []*schemas.Index {
indices := make([]*schemas.Index, 0, 8)
usuuIndex := schemas.NewIndex("u_s_uu", schemas.IndexType)
usuuIndex.AddColumn("user_id", "status", "updated_unix")
indices = append(indices, usuuIndex)
// Add the individual indices that were previously defined in struct tags
userIDIndex := schemas.NewIndex("idx_notification_user_id", schemas.IndexType)
userIDIndex.AddColumn("user_id")
indices = append(indices, userIDIndex)
repoIDIndex := schemas.NewIndex("idx_notification_repo_id", schemas.IndexType)
repoIDIndex.AddColumn("repo_id")
indices = append(indices, repoIDIndex)
statusIndex := schemas.NewIndex("idx_notification_status", schemas.IndexType)
statusIndex.AddColumn("status")
indices = append(indices, statusIndex)
sourceIndex := schemas.NewIndex("idx_notification_source", schemas.IndexType)
sourceIndex.AddColumn("source")
indices = append(indices, sourceIndex)
issueIDIndex := schemas.NewIndex("idx_notification_issue_id", schemas.IndexType)
issueIDIndex.AddColumn("issue_id")
indices = append(indices, issueIDIndex)
commitIDIndex := schemas.NewIndex("idx_notification_commit_id", schemas.IndexType)
commitIDIndex.AddColumn("commit_id")
indices = append(indices, commitIDIndex)
releaseIDIndex := schemas.NewIndex("idx_notification_release_id", schemas.IndexType)
releaseIDIndex.AddColumn("release_id")
indices = append(indices, releaseIDIndex)
updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType)
updatedByIndex.AddColumn("updated_by")
indices = append(indices, updatedByIndex)
return indices
}
func AddReleaseNotification(x *xorm.Engine) error {
return x.Sync(new(NotificationV321))
}

View File

@ -93,6 +93,22 @@ func init() {
db.RegisterModel(new(Release)) db.RegisterModel(new(Release))
} }
func (r *Release) LoadPublisher(ctx context.Context) error {
if r.Publisher != nil {
return nil
}
var err error
r.Publisher, err = user_model.GetPossibleUserByID(ctx, r.PublisherID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
r.Publisher = user_model.NewGhostUser()
} else {
return err
}
}
return nil
}
// LoadAttributes load repo and publisher attributes for a release // LoadAttributes load repo and publisher attributes for a release
func (r *Release) LoadAttributes(ctx context.Context) error { func (r *Release) LoadAttributes(ctx context.Context) error {
var err error var err error
@ -102,16 +118,9 @@ func (r *Release) LoadAttributes(ctx context.Context) error {
return err return err
} }
} }
if r.Publisher == nil { if err := r.LoadPublisher(ctx); err != nil {
r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
r.Publisher = user_model.NewGhostUser()
} else {
return err return err
} }
}
}
return GetReleaseAttachments(ctx, r) return GetReleaseAttachments(ctx, r)
} }

View File

@ -6,6 +6,7 @@ package user
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@ -81,3 +82,20 @@ func GetUsersByIDs(ctx context.Context, ids []int64) (UserList, error) {
Find(&ous) Find(&ous)
return ous, err return ous, err
} }
// GetUsersByUsernames returns all resolved users from a list of user names.
func GetUsersByUsernames(ctx context.Context, userNames []string) (UserList, error) {
ous := make([]*User, 0, len(userNames))
if len(userNames) == 0 {
return ous, nil
}
for i, name := range userNames {
userNames[i] = strings.ToLower(name)
}
err := db.GetEngine(ctx).
Where("`type` = ?", UserTypeIndividual).
In("lower_name", userNames).
Find(&ous)
return ous, err
}

View File

@ -25,7 +25,7 @@ type NotificationSubject struct {
LatestCommentURL string `json:"latest_comment_url"` LatestCommentURL string `json:"latest_comment_url"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
LatestCommentHTMLURL string `json:"latest_comment_html_url"` LatestCommentHTMLURL string `json:"latest_comment_html_url"`
Type NotifySubjectType `json:"type" binding:"In(Issue,Pull,Commit,Repository)"` Type NotifySubjectType `json:"type" binding:"In(Issue,Pull,Commit,Repository,Release)"`
State StateType `json:"state"` State StateType `json:"state"`
} }
@ -46,4 +46,6 @@ const (
NotifySubjectCommit NotifySubjectType = "Commit" NotifySubjectCommit NotifySubjectType = "Commit"
// NotifySubjectRepository an repository is subject of an notification // NotifySubjectRepository an repository is subject of an notification
NotifySubjectRepository NotifySubjectType = "Repository" NotifySubjectRepository NotifySubjectType = "Repository"
// NotifySubjectRelease an release is subject of an notification
NotifySubjectRelease NotifySubjectType = "Release"
) )

View File

@ -119,11 +119,14 @@ type ContentsResponse struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
SHA string `json:"sha"` SHA string `json:"sha"`
LastCommitSHA string `json:"last_commit_sha"`
LastCommitSHA *string `json:"last_commit_sha,omitempty"`
// swagger:strfmt date-time // swagger:strfmt date-time
LastCommitterDate time.Time `json:"last_committer_date"` LastCommitterDate *time.Time `json:"last_committer_date,omitempty"`
// swagger:strfmt date-time // swagger:strfmt date-time
LastAuthorDate time.Time `json:"last_author_date"` LastAuthorDate *time.Time `json:"last_author_date,omitempty"`
LastCommitMessage *string `json:"last_commit_message,omitempty"`
// `type` will be `file`, `dir`, `symlink`, or `submodule` // `type` will be `file`, `dir`, `symlink`, or `submodule`
Type string `json:"type"` Type string `json:"type"`
Size int64 `json:"size"` Size int64 `json:"size"`
@ -141,8 +144,8 @@ type ContentsResponse struct {
SubmoduleGitURL *string `json:"submodule_git_url"` SubmoduleGitURL *string `json:"submodule_git_url"`
Links *FileLinksResponse `json:"_links"` Links *FileLinksResponse `json:"_links"`
LfsOid *string `json:"lfs_oid"` LfsOid *string `json:"lfs_oid,omitempty"`
LfsSize *int64 `json:"lfs_size"` LfsSize *int64 `json:"lfs_size,omitempty"`
} }
// FileCommitResponse contains information generated from a Git commit for a repo's file. // FileCommitResponse contains information generated from a Git commit for a repo's file.

View File

@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Basculer
pulls.cmd_instruction_checkout_desc=Depuis votre dépôt, basculer sur une nouvelle branche et tester des modifications. pulls.cmd_instruction_checkout_desc=Depuis votre dépôt, basculer sur une nouvelle branche et tester des modifications.
pulls.cmd_instruction_merge_title=Fusionner pulls.cmd_instruction_merge_title=Fusionner
pulls.cmd_instruction_merge_desc=Fusionner les modifications et mettre à jour sur Gitea. pulls.cmd_instruction_merge_desc=Fusionner les modifications et mettre à jour sur Gitea.
pulls.cmd_instruction_merge_warning=Attention : cette opération ne peut pas fusionner la demande dajout car la « détection automatique de fusion manuelle » na pas été activée
pulls.clear_merge_message=Effacer le message de fusion pulls.clear_merge_message=Effacer le message de fusion
pulls.clear_merge_message_hint=Effacer le message de fusion ne supprimera que le message de la révision, mais pas les pieds de révision générés tels que "Co-Authored-By:". pulls.clear_merge_message_hint=Effacer le message de fusion ne supprimera que le message de la révision, mais pas les pieds de révision générés tels que "Co-Authored-By:".
@ -2768,6 +2769,8 @@ branch.new_branch_from=`Créer une nouvelle branche à partir de "%s"`
branch.renamed=La branche %s à été renommée en %s. branch.renamed=La branche %s à été renommée en %s.
branch.rename_default_or_protected_branch_error=Seuls les administrateurs peuvent renommer les branches par défaut ou protégées. branch.rename_default_or_protected_branch_error=Seuls les administrateurs peuvent renommer les branches par défaut ou protégées.
branch.rename_protected_branch_failed=Cette branche est protégée par des règles de protection basées sur des globs. branch.rename_protected_branch_failed=Cette branche est protégée par des règles de protection basées sur des globs.
branch.commits_divergence_from=Divergence de révisions : %[1]d en retard et %[2]d en avance sur %[3]s
branch.commits_no_divergence=Identique à la branche %[1]s
tag.create_tag=Créer l'étiquette %s tag.create_tag=Créer l'étiquette %s
tag.create_tag_operation=Créer une étiquette tag.create_tag_operation=Créer une étiquette

View File

@ -2769,6 +2769,8 @@ branch.new_branch_from=`Cruthaigh brainse nua ó "%s"`
branch.renamed=Ainmníodh brainse %s go %s. branch.renamed=Ainmníodh brainse %s go %s.
branch.rename_default_or_protected_branch_error=Ní féidir ach le riarthóirí brainsí réamhshocraithe nó cosanta a athainmniú. branch.rename_default_or_protected_branch_error=Ní féidir ach le riarthóirí brainsí réamhshocraithe nó cosanta a athainmniú.
branch.rename_protected_branch_failed=Tá an brainse seo faoi chosaint ag rialacha cosanta domhanda. branch.rename_protected_branch_failed=Tá an brainse seo faoi chosaint ag rialacha cosanta domhanda.
branch.commits_divergence_from=Déanann sé dialltacht a thiomnú: %[1]d taobh thiar agus %[2]d chun tosaigh ar %[3]s
branch.commits_no_divergence=Mar an gcéanna le brainse %[1]s
tag.create_tag=Cruthaigh clib %s tag.create_tag=Cruthaigh clib %s
tag.create_tag_operation=Cruthaigh clib tag.create_tag_operation=Cruthaigh clib

View File

@ -2769,6 +2769,8 @@ branch.new_branch_from=`Criar um novo ramo a partir do ramo "%s"`
branch.renamed=O ramo %s foi renomeado para %s. branch.renamed=O ramo %s foi renomeado para %s.
branch.rename_default_or_protected_branch_error=Só os administradores é que podem renomear o ramo principal ou ramos protegidos. branch.rename_default_or_protected_branch_error=Só os administradores é que podem renomear o ramo principal ou ramos protegidos.
branch.rename_protected_branch_failed=Este ramo está protegido por regras de salvaguarda baseadas em padrões glob. branch.rename_protected_branch_failed=Este ramo está protegido por regras de salvaguarda baseadas em padrões glob.
branch.commits_divergence_from=Divergência nos cometimentos: %[1]d atrás e %[2]d à frente de %[3]s
branch.commits_no_divergence=Idêntico ao ramo %[1]s
tag.create_tag=Criar etiqueta %s tag.create_tag=Criar etiqueta %s
tag.create_tag_operation=Criar etiqueta tag.create_tag_operation=Criar etiqueta

View File

@ -71,6 +71,8 @@ func subjectToSource(value []string) (result []activities_model.NotificationSour
result = append(result, activities_model.NotificationSourceCommit) result = append(result, activities_model.NotificationSourceCommit)
case "repository": case "repository":
result = append(result, activities_model.NotificationSourceRepository) result = append(result, activities_model.NotificationSourceRepository)
case "release":
result = append(result, activities_model.NotificationSourceRelease)
} }
} }
return result return result

View File

@ -80,7 +80,7 @@ func ListRepoNotifications(ctx *context.APIContext) {
// collectionFormat: multi // collectionFormat: multi
// items: // items:
// type: string // type: string
// enum: [issue,pull,commit,repository] // enum: [issue,pull,commit,repository,release]
// - name: since // - name: since
// in: query // in: query
// description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
@ -214,14 +214,20 @@ func ReadRepoNotifications(ctx *context.APIContext) {
changed := make([]*structs.NotificationThread, 0, len(nl)) changed := make([]*structs.NotificationThread, 0, len(nl))
if err := activities_model.NotificationList(nl).LoadAttributes(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
for _, n := range nl { for _, n := range nl {
notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus) notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
_ = notif.LoadAttributes(ctx) n.Status = notif.Status
changed = append(changed, convert.ToNotificationThread(ctx, notif)) n.UpdatedUnix = notif.UpdatedUnix
changed = append(changed, convert.ToNotificationThread(ctx, n))
} }
ctx.JSON(http.StatusResetContent, changed) ctx.JSON(http.StatusResetContent, changed)
} }

View File

@ -42,7 +42,7 @@ func ListNotifications(ctx *context.APIContext) {
// collectionFormat: multi // collectionFormat: multi
// items: // items:
// type: string // type: string
// enum: [issue,pull,commit,repository] // enum: [issue,pull,commit,repository,release]
// - name: since // - name: since
// in: query // in: query
// description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
@ -161,14 +161,20 @@ func ReadNotifications(ctx *context.APIContext) {
changed := make([]*structs.NotificationThread, 0, len(nl)) changed := make([]*structs.NotificationThread, 0, len(nl))
if err := activities_model.NotificationList(nl).LoadAttributes(ctx); err != nil {
ctx.APIErrorInternal(err)
return
}
for _, n := range nl { for _, n := range nl {
notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus) notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
_ = notif.LoadAttributes(ctx) n.Status = notif.Status
changed = append(changed, convert.ToNotificationThread(ctx, notif)) n.UpdatedUnix = notif.UpdatedUnix
changed = append(changed, convert.ToNotificationThread(ctx, n))
} }
ctx.JSON(http.StatusResetContent, changed) ctx.JSON(http.StatusResetContent, changed)

View File

@ -812,7 +812,8 @@ func GetContentsExt(ctx *context.APIContext) {
// required: true // required: true
// - name: filepath // - name: filepath
// in: path // in: path
// description: path of the dir, file, symlink or submodule in the repo // description: path of the dir, file, symlink or submodule in the repo. Swagger requires path parameter to be "required",
// you can leave it empty or pass a single dot (".") to get the root directory.
// type: string // type: string
// required: true // required: true
// - name: ref // - name: ref
@ -823,7 +824,8 @@ func GetContentsExt(ctx *context.APIContext) {
// - name: includes // - name: includes
// in: query // in: query
// description: By default this API's response only contains file's metadata. Use comma-separated "includes" options to retrieve more fields. // description: By default this API's response only contains file's metadata. Use comma-separated "includes" options to retrieve more fields.
// Option "file_content" will try to retrieve the file content, option "lfs_metadata" will try to retrieve LFS metadata. // Option "file_content" will try to retrieve the file content, "lfs_metadata" will try to retrieve LFS metadata,
// "commit_metadata" will try to retrieve commit metadata, and "commit_message" will try to retrieve commit message.
// type: string // type: string
// required: false // required: false
// responses: // responses:
@ -832,6 +834,9 @@ func GetContentsExt(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if treePath := ctx.PathParam("*"); treePath == "." || treePath == "/" {
ctx.SetPathParam("*", "") // workaround for swagger, it requires path parameter to be "required", but we need to list root directory
}
opts := files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*")} opts := files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*")}
for includeOpt := range strings.SplitSeq(ctx.FormString("includes"), ",") { for includeOpt := range strings.SplitSeq(ctx.FormString("includes"), ",") {
if includeOpt == "" { if includeOpt == "" {
@ -842,6 +847,10 @@ func GetContentsExt(ctx *context.APIContext) {
opts.IncludeSingleFileContent = true opts.IncludeSingleFileContent = true
case "lfs_metadata": case "lfs_metadata":
opts.IncludeLfsMetadata = true opts.IncludeLfsMetadata = true
case "commit_metadata":
opts.IncludeCommitMetadata = true
case "commit_message":
opts.IncludeCommitMessage = true
default: default:
ctx.APIError(http.StatusBadRequest, fmt.Sprintf("unknown include option %q", includeOpt)) ctx.APIError(http.StatusBadRequest, fmt.Sprintf("unknown include option %q", includeOpt))
return return
@ -883,7 +892,11 @@ func GetContents(ctx *context.APIContext) {
// "$ref": "#/responses/ContentsResponse" // "$ref": "#/responses/ContentsResponse"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*"), IncludeSingleFileContent: true}) ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{
TreePath: ctx.PathParam("*"),
IncludeSingleFileContent: true,
IncludeCommitMetadata: true,
})
if ctx.Written() { if ctx.Written() {
return return
} }

View File

@ -12,6 +12,7 @@ import (
"path" "path"
"strings" "strings"
activities_model "code.gitea.io/gitea/models/activities"
asymkey_model "code.gitea.io/gitea/models/asymkey" asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
@ -301,6 +302,14 @@ func Diff(ctx *context.Context) {
commitID = commit.ID.String() commitID = commit.ID.String()
} }
if ctx.IsSigned {
err = activities_model.SetCommitReadBy(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID, commitID)
if err != nil {
ctx.ServerError("SetReleaseReadBy", err)
return
}
}
fileOnly := ctx.FormBool("file-only") fileOnly := ctx.FormBool("file-only")
maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles
files := ctx.FormStrings("files") files := ctx.FormStrings("files")

View File

@ -11,6 +11,7 @@ import (
"strconv" "strconv"
"strings" "strings"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/models/renderhelper"
@ -298,6 +299,14 @@ func SingleRelease(ctx *context.Context) {
release.Title = release.TagName release.Title = release.TagName
} }
if ctx.IsSigned && !release.IsTag {
err = activities_model.SetReleaseReadBy(ctx, release.ID, ctx.Doer.ID)
if err != nil {
ctx.ServerError("SetReleaseReadBy", err)
return
}
}
ctx.Data["PageIsSingleTag"] = release.IsTag ctx.Data["PageIsSingleTag"] = release.IsTag
ctx.Data["SingleReleaseTagName"] = release.TagName ctx.Data["SingleReleaseTagName"] = release.TagName
if release.IsTag { if release.IsTag {

View File

@ -137,6 +137,22 @@ func getNotifications(ctx *context.Context) {
notifications = notifications.Without(failures) notifications = notifications.Without(failures)
failCount += len(failures) failCount += len(failures)
failures, err = notifications.LoadCommits(ctx)
if err != nil {
ctx.ServerError("LoadCommits", err)
return
}
notifications = notifications.Without(failures)
failCount += len(failures)
failures, err = notifications.LoadReleases(ctx)
if err != nil {
ctx.ServerError("LoadReleases", err)
return
}
notifications = notifications.Without(failures)
failCount += len(failures)
if failCount > 0 { if failCount > 0 {
ctx.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount)) ctx.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount))
} }

View File

@ -6,6 +6,7 @@ package convert
import ( import (
"context" "context"
"net/url" "net/url"
"strings"
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
@ -71,7 +72,7 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
url := n.Repository.HTMLURL() + "/commit/" + url.PathEscape(n.CommitID) url := n.Repository.HTMLURL() + "/commit/" + url.PathEscape(n.CommitID)
result.Subject = &api.NotificationSubject{ result.Subject = &api.NotificationSubject{
Type: api.NotifySubjectCommit, Type: api.NotifySubjectCommit,
Title: n.CommitID, Title: strings.TrimSpace(n.Commit.CommitMessage),
URL: url, URL: url,
HTMLURL: url, HTMLURL: url,
} }
@ -83,6 +84,13 @@ func ToNotificationThread(ctx context.Context, n *activities_model.Notification)
URL: n.Repository.Link(), URL: n.Repository.Link(),
HTMLURL: n.Repository.HTMLURL(), HTMLURL: n.Repository.HTMLURL(),
} }
case activities_model.NotificationSourceRelease:
result.Subject = &api.NotificationSubject{
Type: api.NotifySubjectRelease,
Title: n.Release.Title,
URL: n.Release.Link(),
HTMLURL: n.Release.HTMLURL(),
}
} }
return result return result

View File

@ -58,10 +58,6 @@ func renderRepoIssueIconTitle(ctx context.Context, opts markup.RenderIssueIconTi
} }
} }
htmlIcon, err := webCtx.RenderToHTML("shared/issueicon", issue) return htmlutil.HTMLFormat(`<a href="%s">%s %s %s</a>`, opts.LinkHref,
if err != nil { issue.IconHTML(ctx), issue.Title, textIssueIndex), nil
return "", err
}
return htmlutil.HTMLFormat(`<a href="%s">%s %s %s</a>`, opts.LinkHref, htmlIcon, issue.Title, textIssueIndex), nil
} }

View File

@ -46,10 +46,25 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
} }
} }
func shouldSendCommentChangeNotification(ctx context.Context, comment *issues_model.Comment) bool {
if err := comment.LoadReview(ctx); err != nil {
log.Error("LoadReview: %v", err)
return false
} else if comment.Review != nil && comment.Review.Type == issues_model.ReviewTypePending {
// Pending review comments updating should not triggered
return false
}
return true
}
// CreateIssueComment notifies issue comment related message to notifiers // CreateIssueComment notifies issue comment related message to notifiers
func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User,
) { ) {
if !shouldSendCommentChangeNotification(ctx, comment) {
return
}
for _, notifier := range notifiers { for _, notifier := range notifiers {
notifier.CreateIssueComment(ctx, doer, repo, issue, comment, mentions) notifier.CreateIssueComment(ctx, doer, repo, issue, comment, mentions)
} }
@ -156,6 +171,10 @@ func PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issue
// UpdateComment notifies update comment to notifiers // UpdateComment notifies update comment to notifiers
func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) {
if !shouldSendCommentChangeNotification(ctx, c) {
return
}
for _, notifier := range notifiers { for _, notifier := range notifiers {
notifier.UpdateComment(ctx, doer, c, oldContent) notifier.UpdateComment(ctx, doer, c, oldContent)
} }
@ -163,6 +182,10 @@ func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.C
// DeleteComment notifies delete comment to notifiers // DeleteComment notifies delete comment to notifiers
func DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) { func DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) {
if !shouldSendCommentChangeNotification(ctx, c) {
return
}
for _, notifier := range notifiers { for _, notifier := range notifiers {
notifier.DeleteComment(ctx, doer, c) notifier.DeleteComment(ctx, doer, c)
} }

View File

@ -39,6 +39,8 @@ type GetContentsOrListOptions struct {
TreePath string TreePath string
IncludeSingleFileContent bool // include the file's content when the tree path is a file IncludeSingleFileContent bool // include the file's content when the tree path is a file
IncludeLfsMetadata bool IncludeLfsMetadata bool
IncludeCommitMetadata bool
IncludeCommitMessage bool
} }
// GetContentsOrList gets the metadata of a file's contents (*ContentsResponse) if treePath not a tree // GetContentsOrList gets the metadata of a file's contents (*ContentsResponse) if treePath not a tree
@ -132,6 +134,19 @@ func getFileContentsByEntryInternal(_ context.Context, repo *repo_model.Reposito
} }
selfURLString := selfURL.String() selfURLString := selfURL.String()
// All content types have these fields in populated
contentsResponse := &api.ContentsResponse{
Name: entry.Name(),
Path: opts.TreePath,
SHA: entry.ID.String(),
Size: entry.Size(),
URL: &selfURLString,
Links: &api.FileLinksResponse{
Self: &selfURLString,
},
}
if opts.IncludeCommitMetadata || opts.IncludeCommitMessage {
err = gitRepo.AddLastCommitCache(repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID) err = gitRepo.AddLastCommitCache(repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -142,29 +157,23 @@ func getFileContentsByEntryInternal(_ context.Context, repo *repo_model.Reposito
return nil, err return nil, err
} }
// All content types have these fields in populated if opts.IncludeCommitMetadata {
contentsResponse := &api.ContentsResponse{ contentsResponse.LastCommitSHA = util.ToPointer(lastCommit.ID.String())
Name: entry.Name(),
Path: opts.TreePath,
SHA: entry.ID.String(),
LastCommitSHA: lastCommit.ID.String(),
Size: entry.Size(),
URL: &selfURLString,
Links: &api.FileLinksResponse{
Self: &selfURLString,
},
}
// GitHub doesn't have these fields in the response, but we could follow other similar APIs to name them // GitHub doesn't have these fields in the response, but we could follow other similar APIs to name them
// https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits
if lastCommit.Committer != nil { if lastCommit.Committer != nil {
contentsResponse.LastCommitterDate = lastCommit.Committer.When contentsResponse.LastCommitterDate = util.ToPointer(lastCommit.Committer.When)
} }
if lastCommit.Author != nil { if lastCommit.Author != nil {
contentsResponse.LastAuthorDate = lastCommit.Author.When contentsResponse.LastAuthorDate = util.ToPointer(lastCommit.Author.When)
}
}
if opts.IncludeCommitMessage {
contentsResponse.LastCommitMessage = util.ToPointer(lastCommit.Message())
}
} }
// Now populate the rest of the ContentsResponse based on entry type // Now populate the rest of the ContentsResponse based on the entry type
if entry.IsRegular() || entry.IsExecutable() { if entry.IsRegular() || entry.IsExecutable() {
contentsResponse.Type = string(ContentTypeRegular) contentsResponse.Type = string(ContentTypeRegular)
// if it is listing the repo root dir, don't waste system resources on reading content // if it is listing the repo root dir, don't waste system resources on reading content

View File

@ -5,56 +5,21 @@ package files
import ( import (
"testing" "testing"
"time"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/contexttest"
_ "code.gitea.io/gitea/models/actions" _ "code.gitea.io/gitea/models/actions"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
unittest.MainTest(m) unittest.MainTest(m)
} }
func getExpectedReadmeContentsResponse() *api.ContentsResponse {
treePath := "README.md"
sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f"
encoding := "base64"
content := "IyByZXBvMQoKRGVzY3JpcHRpb24gZm9yIHJlcG8x"
selfURL := "https://try.gitea.io/api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master"
htmlURL := "https://try.gitea.io/user2/repo1/src/branch/master/" + treePath
gitURL := "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/" + sha
downloadURL := "https://try.gitea.io/user2/repo1/raw/branch/master/" + treePath
return &api.ContentsResponse{
Name: treePath,
Path: treePath,
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
LastCommitSHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)),
Type: "file",
Size: 30,
Encoding: &encoding,
Content: &content,
URL: &selfURL,
HTMLURL: &htmlURL,
GitURL: &gitURL,
DownloadURL: &downloadURL,
Links: &api.FileLinksResponse{
Self: &selfURL,
GitURL: &gitURL,
HTMLURL: &htmlURL,
},
}
}
func TestGetContents(t *testing.T) { func TestGetContents(t *testing.T) {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx, _ := contexttest.MockContext(t, "user2/repo1")
@ -63,45 +28,8 @@ func TestGetContents(t *testing.T) {
contexttest.LoadRepoCommit(t, ctx) contexttest.LoadRepoCommit(t, ctx)
contexttest.LoadUser(t, ctx, 2) contexttest.LoadUser(t, ctx, 2)
contexttest.LoadGitRepo(t, ctx) contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
repo, gitRepo := ctx.Repo.Repository, ctx.Repo.GitRepo
refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch)
require.NoError(t, err)
t.Run("GetContentsOrList(README.md)-MetaOnly", func(t *testing.T) { // GetContentsOrList's behavior is fully tested in integration tests, so we don't need to test it here.
expectedContentsResponse := getExpectedReadmeContentsResponse()
expectedContentsResponse.Encoding = nil // because will be in a list, doesn't have encoding and content
expectedContentsResponse.Content = nil
extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "README.md", IncludeSingleFileContent: false})
assert.Equal(t, expectedContentsResponse, extResp.FileContents)
assert.NoError(t, err)
})
t.Run("GetContentsOrList(README.md)", func(t *testing.T) {
expectedContentsResponse := getExpectedReadmeContentsResponse()
extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "README.md", IncludeSingleFileContent: true})
assert.Equal(t, expectedContentsResponse, extResp.FileContents)
assert.NoError(t, err)
})
t.Run("GetContentsOrList(RootDir)", func(t *testing.T) {
readmeContentsResponse := getExpectedReadmeContentsResponse()
readmeContentsResponse.Encoding = nil // because will be in a list, doesn't have encoding and content
readmeContentsResponse.Content = nil
expectedContentsListResponse := []*api.ContentsResponse{readmeContentsResponse}
// even if IncludeFileContent is true, it has no effect for directory listing
extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "", IncludeSingleFileContent: true})
assert.Equal(t, expectedContentsListResponse, extResp.DirContents)
assert.NoError(t, err)
})
t.Run("GetContentsOrList(NoSuchTreePath)", func(t *testing.T) {
extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "no-such/file.md"})
assert.Error(t, err)
assert.EqualError(t, err, "object does not exist [id: , rel_path: no-such]")
assert.Nil(t, extResp.DirContents)
assert.Nil(t, extResp.FileContents)
})
t.Run("GetBlobBySHA", func(t *testing.T) { t.Run("GetBlobBySHA", func(t *testing.T) {
sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"

View File

@ -22,7 +22,12 @@ import (
func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) { func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) {
var size int64 var size int64
for _, treePath := range treePaths { for _, treePath := range treePaths {
fileContents, _ := GetFileContents(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: treePath, IncludeSingleFileContent: true}) // ok if fails, then will be nil // ok if fails, then will be nil
fileContents, _ := GetFileContents(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{
TreePath: treePath,
IncludeSingleFileContent: true,
IncludeCommitMetadata: true,
})
if fileContents != nil && fileContents.Content != nil && *fileContents.Content != "" { if fileContents != nil && fileContents.Content != nil && *fileContents.Content != "" {
// if content isn't empty (e.g., due to the single blob being too large), add file size to response size // if content isn't empty (e.g., due to the single blob being too large), add file size to response size
size += int64(len(*fileContents.Content)) size += int64(len(*fileContents.Content))

View File

@ -5,28 +5,39 @@ package uinotification
import ( import (
"context" "context"
"slices"
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"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"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/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
) )
type ( type (
notificationService struct { notificationService struct {
notify_service.NullNotifier notify_service.NullNotifier
issueQueue *queue.WorkerPoolQueue[issueNotificationOpts] queue *queue.WorkerPoolQueue[notificationOpts]
} }
issueNotificationOpts struct { notificationOpts struct {
Source activities_model.NotificationSource
IssueID int64 IssueID int64
CommentID int64 CommentID int64
CommitID string // commit ID for commit notifications
RepoID int64
ReleaseID int64
NotificationAuthorID int64 NotificationAuthorID int64
ReceiverID int64 // 0 -- ALL Watcher ReceiverID int64 // 0 -- ALL Watcher
} }
@ -43,66 +54,79 @@ var _ notify_service.Notifier = &notificationService{}
// NewNotifier create a new notificationService notifier // NewNotifier create a new notificationService notifier
func NewNotifier() notify_service.Notifier { func NewNotifier() notify_service.Notifier {
ns := &notificationService{} ns := &notificationService{}
ns.issueQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "notification-service", handler) ns.queue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "notification-service", handler)
if ns.issueQueue == nil { if ns.queue == nil {
log.Fatal("Unable to create notification-service queue") log.Fatal("Unable to create notification-service queue")
} }
return ns return ns
} }
func handler(items ...issueNotificationOpts) []issueNotificationOpts { func handler(items ...notificationOpts) []notificationOpts {
for _, opts := range items { for _, opts := range items {
switch opts.Source {
case activities_model.NotificationSourceRepository:
if err := activities_model.CreateRepoTransferNotification(db.DefaultContext, opts.NotificationAuthorID, opts.RepoID, opts.ReceiverID); err != nil {
log.Error("CreateRepoTransferNotification: %v", err)
}
case activities_model.NotificationSourceCommit:
if err := activities_model.CreateCommitNotifications(db.DefaultContext, opts.NotificationAuthorID, opts.RepoID, opts.CommitID, opts.ReceiverID); err != nil {
log.Error("Was unable to create commit notification: %v", err)
}
case activities_model.NotificationSourceRelease:
if err := activities_model.CreateOrUpdateReleaseNotifications(db.DefaultContext, opts.NotificationAuthorID, opts.RepoID, opts.ReleaseID, opts.ReceiverID); err != nil {
log.Error("Was unable to create release notification: %v", err)
}
case activities_model.NotificationSourceIssue, activities_model.NotificationSourcePullRequest:
fallthrough
default:
if err := activities_model.CreateOrUpdateIssueNotifications(db.DefaultContext, opts.IssueID, opts.CommentID, opts.NotificationAuthorID, opts.ReceiverID); err != nil { if err := activities_model.CreateOrUpdateIssueNotifications(db.DefaultContext, opts.IssueID, opts.CommentID, opts.NotificationAuthorID, opts.ReceiverID); err != nil {
log.Error("Was unable to create issue notification: %v", err) log.Error("Was unable to create issue notification: %v", err)
} }
} }
}
return nil return nil
} }
func (ns *notificationService) Run() { func (ns *notificationService) Run() {
go graceful.GetManager().RunWithCancel(ns.issueQueue) // TODO: using "go" here doesn't seem right, just leave it as old code go graceful.GetManager().RunWithCancel(ns.queue) // TODO: using "go" here doesn't seem right, just leave it as old code
} }
func (ns *notificationService) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, func (ns *notificationService) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User,
) { ) {
opts := issueNotificationOpts{ opts := notificationOpts{
Source: util.Iif(issue.IsPull, activities_model.NotificationSourcePullRequest, activities_model.NotificationSourceIssue),
IssueID: issue.ID, IssueID: issue.ID,
RepoID: issue.RepoID,
NotificationAuthorID: doer.ID, NotificationAuthorID: doer.ID,
} }
if comment != nil { if comment != nil {
opts.CommentID = comment.ID opts.CommentID = comment.ID
} }
_ = ns.issueQueue.Push(opts) _ = ns.queue.Push(opts)
for _, mention := range mentions { for _, mention := range mentions {
opts := issueNotificationOpts{ opts.ReceiverID = mention.ID
IssueID: issue.ID, _ = ns.queue.Push(opts)
NotificationAuthorID: doer.ID,
ReceiverID: mention.ID,
}
if comment != nil {
opts.CommentID = comment.ID
}
_ = ns.issueQueue.Push(opts)
} }
} }
func (ns *notificationService) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { func (ns *notificationService) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
_ = ns.issueQueue.Push(issueNotificationOpts{ opts := notificationOpts{
Source: activities_model.NotificationSourceIssue,
RepoID: issue.RepoID,
IssueID: issue.ID, IssueID: issue.ID,
NotificationAuthorID: issue.Poster.ID, NotificationAuthorID: issue.Poster.ID,
}) }
_ = ns.queue.Push(opts)
for _, mention := range mentions { for _, mention := range mentions {
_ = ns.issueQueue.Push(issueNotificationOpts{ opts.ReceiverID = mention.ID
IssueID: issue.ID, _ = ns.queue.Push(opts)
NotificationAuthorID: issue.Poster.ID,
ReceiverID: mention.ID,
})
} }
} }
func (ns *notificationService) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { func (ns *notificationService) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) {
_ = ns.issueQueue.Push(issueNotificationOpts{ _ = ns.queue.Push(notificationOpts{
Source: util.Iif(issue.IsPull, activities_model.NotificationSourcePullRequest, activities_model.NotificationSourceIssue),
IssueID: issue.ID, IssueID: issue.ID,
NotificationAuthorID: doer.ID, NotificationAuthorID: doer.ID,
CommentID: actionComment.ID, CommentID: actionComment.ID,
@ -115,7 +139,8 @@ func (ns *notificationService) IssueChangeTitle(ctx context.Context, doer *user_
return return
} }
if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issue.PullRequest.IsWorkInProgress(ctx) { if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issue.PullRequest.IsWorkInProgress(ctx) {
_ = ns.issueQueue.Push(issueNotificationOpts{ _ = ns.queue.Push(notificationOpts{
Source: util.Iif(issue.IsPull, activities_model.NotificationSourcePullRequest, activities_model.NotificationSourceIssue),
IssueID: issue.ID, IssueID: issue.ID,
NotificationAuthorID: doer.ID, NotificationAuthorID: doer.ID,
}) })
@ -123,7 +148,8 @@ func (ns *notificationService) IssueChangeTitle(ctx context.Context, doer *user_
} }
func (ns *notificationService) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { func (ns *notificationService) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
_ = ns.issueQueue.Push(issueNotificationOpts{ _ = ns.queue.Push(notificationOpts{
Source: activities_model.NotificationSourcePullRequest,
IssueID: pr.Issue.ID, IssueID: pr.Issue.ID,
NotificationAuthorID: doer.ID, NotificationAuthorID: doer.ID,
}) })
@ -160,7 +186,8 @@ func (ns *notificationService) NewPullRequest(ctx context.Context, pr *issues_mo
toNotify.Add(mention.ID) toNotify.Add(mention.ID)
} }
for receiverID := range toNotify { for receiverID := range toNotify {
_ = ns.issueQueue.Push(issueNotificationOpts{ _ = ns.queue.Push(notificationOpts{
Source: activities_model.NotificationSourcePullRequest,
IssueID: pr.Issue.ID, IssueID: pr.Issue.ID,
NotificationAuthorID: pr.Issue.PosterID, NotificationAuthorID: pr.Issue.PosterID,
ReceiverID: receiverID, ReceiverID: receiverID,
@ -169,30 +196,25 @@ func (ns *notificationService) NewPullRequest(ctx context.Context, pr *issues_mo
} }
func (ns *notificationService) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, c *issues_model.Comment, mentions []*user_model.User) { func (ns *notificationService) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, c *issues_model.Comment, mentions []*user_model.User) {
opts := issueNotificationOpts{ opts := notificationOpts{
Source: activities_model.NotificationSourcePullRequest,
IssueID: pr.Issue.ID, IssueID: pr.Issue.ID,
NotificationAuthorID: r.Reviewer.ID, NotificationAuthorID: r.Reviewer.ID,
} }
if c != nil { if c != nil {
opts.CommentID = c.ID opts.CommentID = c.ID
} }
_ = ns.issueQueue.Push(opts) _ = ns.queue.Push(opts)
for _, mention := range mentions { for _, mention := range mentions {
opts := issueNotificationOpts{ opts.ReceiverID = mention.ID
IssueID: pr.Issue.ID, _ = ns.queue.Push(opts)
NotificationAuthorID: r.Reviewer.ID,
ReceiverID: mention.ID,
}
if c != nil {
opts.CommentID = c.ID
}
_ = ns.issueQueue.Push(opts)
} }
} }
func (ns *notificationService) PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, c *issues_model.Comment, mentions []*user_model.User) { func (ns *notificationService) PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, c *issues_model.Comment, mentions []*user_model.User) {
for _, mention := range mentions { for _, mention := range mentions {
_ = ns.issueQueue.Push(issueNotificationOpts{ _ = ns.queue.Push(notificationOpts{
Source: activities_model.NotificationSourcePullRequest,
IssueID: pr.Issue.ID, IssueID: pr.Issue.ID,
NotificationAuthorID: c.Poster.ID, NotificationAuthorID: c.Poster.ID,
CommentID: c.ID, CommentID: c.ID,
@ -202,26 +224,29 @@ func (ns *notificationService) PullRequestCodeComment(ctx context.Context, pr *i
} }
func (ns *notificationService) PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) { func (ns *notificationService) PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) {
opts := issueNotificationOpts{ opts := notificationOpts{
Source: activities_model.NotificationSourcePullRequest,
IssueID: pr.IssueID, IssueID: pr.IssueID,
NotificationAuthorID: doer.ID, NotificationAuthorID: doer.ID,
CommentID: comment.ID, CommentID: comment.ID,
} }
_ = ns.issueQueue.Push(opts) _ = ns.queue.Push(opts)
} }
func (ns *notificationService) PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { func (ns *notificationService) PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) {
opts := issueNotificationOpts{ opts := notificationOpts{
Source: activities_model.NotificationSourcePullRequest,
IssueID: review.IssueID, IssueID: review.IssueID,
NotificationAuthorID: doer.ID, NotificationAuthorID: doer.ID,
CommentID: comment.ID, CommentID: comment.ID,
} }
_ = ns.issueQueue.Push(opts) _ = ns.queue.Push(opts)
} }
func (ns *notificationService) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { func (ns *notificationService) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) {
if !removed && doer.ID != assignee.ID { if !removed && doer.ID != assignee.ID {
opts := issueNotificationOpts{ opts := notificationOpts{
Source: activities_model.NotificationSourceIssue,
IssueID: issue.ID, IssueID: issue.ID,
NotificationAuthorID: doer.ID, NotificationAuthorID: doer.ID,
ReceiverID: assignee.ID, ReceiverID: assignee.ID,
@ -231,13 +256,14 @@ func (ns *notificationService) IssueChangeAssignee(ctx context.Context, doer *us
opts.CommentID = comment.ID opts.CommentID = comment.ID
} }
_ = ns.issueQueue.Push(opts) _ = ns.queue.Push(opts)
} }
} }
func (ns *notificationService) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { func (ns *notificationService) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) {
if isRequest { if isRequest {
opts := issueNotificationOpts{ opts := notificationOpts{
Source: activities_model.NotificationSourcePullRequest,
IssueID: issue.ID, IssueID: issue.ID,
NotificationAuthorID: doer.ID, NotificationAuthorID: doer.ID,
ReceiverID: reviewer.ID, ReceiverID: reviewer.ID,
@ -247,15 +273,117 @@ func (ns *notificationService) PullRequestReviewRequest(ctx context.Context, doe
opts.CommentID = comment.ID opts.CommentID = comment.ID
} }
_ = ns.issueQueue.Push(opts) _ = ns.queue.Push(opts)
} }
} }
func (ns *notificationService) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) { func (ns *notificationService) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) {
err := db.WithTx(ctx, func(ctx context.Context) error { opts := notificationOpts{
return activities_model.CreateRepoTransferNotification(ctx, doer, newOwner, repo) Source: activities_model.NotificationSourceRepository,
}) RepoID: repo.ID,
NotificationAuthorID: doer.ID,
}
if newOwner.IsOrganization() {
users, err := organization.GetUsersWhoCanCreateOrgRepo(ctx, newOwner.ID)
if err != nil { if err != nil {
log.Error("CreateRepoTransferNotification: %v", err) log.Error("GetUsersWhoCanCreateOrgRepo: %v", err)
return
}
for i := range users {
opts.ReceiverID = users[i].ID
_ = ns.queue.Push(opts)
}
} else {
opts.ReceiverID = newOwner.ID
_ = ns.queue.Push(opts)
}
}
func (ns *notificationService) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
if len(commits.Commits) == 0 {
return
}
for _, commit := range commits.Commits {
mentions := references.FindAllMentionsMarkdown(commit.Message)
receivers, err := user_model.GetUsersByUsernames(ctx, mentions)
if err != nil {
log.Error("GetUserIDsByNames: %v", err)
return
}
notBlocked := make([]*user_model.User, 0, len(mentions))
for _, user := range receivers {
if !user_model.IsUserBlockedBy(ctx, repo.Owner, user.ID) {
notBlocked = append(notBlocked, user)
}
}
receivers = notBlocked
for _, receiver := range receivers {
perm, err := access_model.GetUserRepoPermission(ctx, repo, receiver)
if err != nil {
log.Error("GetUserRepoPermission [%d]: %w", receiver.ID, err)
return
}
if !perm.CanRead(unit.TypeCode) {
continue
}
opts := notificationOpts{
Source: activities_model.NotificationSourceCommit,
RepoID: repo.ID,
CommitID: commit.Sha1,
NotificationAuthorID: pusher.ID,
ReceiverID: receiver.ID,
}
if err := ns.queue.Push(opts); err != nil {
log.Error("PushCommits: %v", err)
}
}
}
}
func (ns *notificationService) NewRelease(ctx context.Context, rel *repo_model.Release) {
_ = rel.LoadPublisher(ctx)
ns.UpdateRelease(ctx, rel.Publisher, rel)
}
func (ns *notificationService) UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) {
opts := notificationOpts{
Source: activities_model.NotificationSourceRelease,
RepoID: rel.RepoID,
ReleaseID: rel.ID,
NotificationAuthorID: rel.PublisherID,
}
repoWatcherIDs, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID)
if err != nil {
log.Error("GetRepoWatchersIDs: %v", err)
return
}
repo, err := repo_model.GetRepositoryByID(ctx, rel.RepoID)
if err != nil {
log.Error("GetRepositoryByID: %v", err)
return
}
if err := repo.LoadOwner(ctx); err != nil {
log.Error("LoadOwner: %v", err)
return
}
if !repo.Owner.IsOrganization() && !slices.Contains(repoWatcherIDs, repo.Owner.ID) && repo.Owner.ID != doer.ID {
repoWatcherIDs = append(repoWatcherIDs, repo.Owner.ID)
}
for _, watcherID := range repoWatcherIDs {
if watcherID == doer.ID {
// Do not notify the publisher of the release
continue
}
opts.ReceiverID = watcherID
_ = ns.queue.Push(opts)
} }
} }

View File

@ -29,7 +29,7 @@ export default {
important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles
content: [ content: [
isProduction && '!./templates/devtest/**/*', isProduction && '!./templates/devtest/**/*',
isProduction && '!./web_src/js/standalone/devtest.js', isProduction && '!./web_src/js/standalone/devtest.ts',
'!./templates/swagger/v1_json.tmpl', '!./templates/swagger/v1_json.tmpl',
'!./templates/user/auth/oidc_wellknown.tmpl', '!./templates/user/auth/oidc_wellknown.tmpl',
'!**/*_test.go', '!**/*_test.go',

View File

@ -187,7 +187,7 @@
{{end}} {{end}}
{{if .HasPullRequest}} {{if .HasPullRequest}}
<div class="ui segment flex-text-block tw-gap-4"> <div class="ui segment flex-text-block tw-gap-4">
{{template "shared/issueicon" .}} {{.IconHTML ctx}}
<div class="issue-title tw-break-anywhere"> <div class="issue-title tw-break-anywhere">
{{ctx.RenderUtils.RenderIssueTitle .PullRequest.Issue.Title $.Repository}} {{ctx.RenderUtils.RenderIssueTitle .PullRequest.Issue.Title $.Repository}}
<span class="index">#{{.PullRequest.Issue.Index}}</span> <span class="index">#{{.PullRequest.Issue.Index}}</span>

View File

@ -12,7 +12,7 @@
<div class="content tw-w-full"> <div class="content tw-w-full">
<div class="tw-flex tw-items-start tw-gap-[5px]"> <div class="tw-flex tw-items-start tw-gap-[5px]">
<div class="issue-card-icon"> <div class="issue-card-icon">
{{template "shared/issueicon" .}} {{.IconHTML ctx}}
</div> </div>
<a class="issue-card-title muted issue-title tw-break-anywhere" href="{{.Link}}">{{.Title | ctx.RenderUtils.RenderIssueSimpleTitle}}</a> <a class="issue-card-title muted issue-title tw-break-anywhere" href="{{.Link}}">{{.Title | ctx.RenderUtils.RenderIssueSimpleTitle}}</a>
{{if and $.isPinnedIssueCard $.Page.IsRepoAdmin}} {{if and $.isPinnedIssueCard $.Page.IsRepoAdmin}}

View File

@ -1,26 +0,0 @@
{{/* the logic should be kept the same as getIssueIcon/getIssueColor in JS code */}}
{{- if .IsPull -}}
{{- if not .PullRequest -}}
No PullRequest
{{- else -}}
{{- if .IsClosed -}}
{{- if .PullRequest.HasMerged -}}
{{- svg "octicon-git-merge" 16 "text purple" -}}
{{- else -}}
{{- svg "octicon-git-pull-request-closed" 16 "text red" -}}
{{- end -}}
{{- else -}}
{{- if .PullRequest.IsWorkInProgress ctx -}}
{{- svg "octicon-git-pull-request-draft" 16 "text grey" -}}
{{- else -}}
{{- svg "octicon-git-pull-request" 16 "text green" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- else -}}
{{- if .IsClosed -}}
{{- svg "octicon-issue-closed" 16 "text red" -}}
{{- else -}}
{{- svg "octicon-issue-opened" 16 "text green" -}}
{{- end -}}
{{- end -}}

View File

@ -9,7 +9,7 @@
{{if $.CanWriteIssuesOrPulls}} {{if $.CanWriteIssuesOrPulls}}
<input type="checkbox" autocomplete="off" class="issue-checkbox tw-mr-[14px]" data-issue-id={{.ID}} aria-label="{{ctx.Locale.Tr "repo.issues.action_check"}} &quot;{{.Title}}&quot;"> <input type="checkbox" autocomplete="off" class="issue-checkbox tw-mr-[14px]" data-issue-id={{.ID}} aria-label="{{ctx.Locale.Tr "repo.issues.action_check"}} &quot;{{.Title}}&quot;">
{{end}} {{end}}
{{template "shared/issueicon" .}} {{.IconHTML ctx}}
</div> </div>
</div> </div>

View File

@ -1523,7 +1523,8 @@
"issue", "issue",
"pull", "pull",
"commit", "commit",
"repository" "repository",
"release"
], ],
"type": "string" "type": "string"
}, },
@ -7547,7 +7548,7 @@
}, },
{ {
"type": "string", "type": "string",
"description": "path of the dir, file, symlink or submodule in the repo", "description": "path of the dir, file, symlink or submodule in the repo. Swagger requires path parameter to be \"required\", you can leave it empty or pass a single dot (\".\") to get the root directory.",
"name": "filepath", "name": "filepath",
"in": "path", "in": "path",
"required": true "required": true
@ -7560,7 +7561,7 @@
}, },
{ {
"type": "string", "type": "string",
"description": "By default this API's response only contains file's metadata. Use comma-separated \"includes\" options to retrieve more fields. Option \"file_content\" will try to retrieve the file content, option \"lfs_metadata\" will try to retrieve LFS metadata.", "description": "By default this API's response only contains file's metadata. Use comma-separated \"includes\" options to retrieve more fields. Option \"file_content\" will try to retrieve the file content, \"lfs_metadata\" will try to retrieve LFS metadata, \"commit_metadata\" will try to retrieve commit metadata, and \"commit_message\" will try to retrieve commit message.",
"name": "includes", "name": "includes",
"in": "query" "in": "query"
} }
@ -13305,7 +13306,8 @@
"issue", "issue",
"pull", "pull",
"commit", "commit",
"repository" "repository",
"release"
], ],
"type": "string" "type": "string"
}, },
@ -22368,6 +22370,10 @@
"format": "date-time", "format": "date-time",
"x-go-name": "LastAuthorDate" "x-go-name": "LastAuthorDate"
}, },
"last_commit_message": {
"type": "string",
"x-go-name": "LastCommitMessage"
},
"last_commit_sha": { "last_commit_sha": {
"type": "string", "type": "string",
"x-go-name": "LastCommitSHA" "x-go-name": "LastCommitSHA"

View File

@ -37,11 +37,7 @@
{{range $notification := .Notifications}} {{range $notification := .Notifications}}
<div class="notifications-item tw-flex tw-items-center tw-flex-wrap tw-gap-2 tw-p-2" id="notification_{{.ID}}" data-status="{{.Status}}"> <div class="notifications-item tw-flex tw-items-center tw-flex-wrap tw-gap-2 tw-p-2" id="notification_{{.ID}}" data-status="{{.Status}}">
<div class="notifications-icon tw-ml-2 tw-mr-1 tw-self-start tw-mt-1"> <div class="notifications-icon tw-ml-2 tw-mr-1 tw-self-start tw-mt-1">
{{if .Issue}} {{.IconHTML ctx}}
{{template "shared/issueicon" .Issue}}
{{else}}
{{svg "octicon-repo" 16 "text grey"}}
{{end}}
</div> </div>
<a class="notifications-link tw-flex tw-flex-1 tw-flex-col silenced" href="{{.Link ctx}}"> <a class="notifications-link tw-flex tw-flex-1 tw-flex-col silenced" href="{{.Link ctx}}">
<div class="notifications-top-row tw-text-13 tw-break-anywhere"> <div class="notifications-top-row tw-text-13 tw-break-anywhere">
@ -54,6 +50,10 @@
<span class="issue-title tw-break-anywhere"> <span class="issue-title tw-break-anywhere">
{{if .Issue}} {{if .Issue}}
{{.Issue.Title | ctx.RenderUtils.RenderIssueSimpleTitle}} {{.Issue.Title | ctx.RenderUtils.RenderIssueSimpleTitle}}
{{else if .Release}}
{{.Release.Title}}
{{else if .Commit}}
{{.Commit.Summary}}
{{else}} {{else}}
{{.Repository.FullName}} {{.Repository.FullName}}
{{end}} {{end}}
@ -63,6 +63,10 @@
<div class="notifications-updated tw-items-center tw-mr-2"> <div class="notifications-updated tw-items-center tw-mr-2">
{{if .Issue}} {{if .Issue}}
{{DateUtils.TimeSince .Issue.UpdatedUnix}} {{DateUtils.TimeSince .Issue.UpdatedUnix}}
{{else if .Release}}
{{DateUtils.TimeSince .Release.CreatedUnix}}
{{else if .Commit}}
{{DateUtils.TimeSince .Commit.Committer.When}}
{{else}} {{else}}
{{DateUtils.TimeSince .UpdatedUnix}} {{DateUtils.TimeSince .UpdatedUnix}}
{{end}} {{end}}

View File

@ -4,9 +4,12 @@
package integration package integration
import ( import (
"encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"testing" "testing"
"time"
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
@ -15,6 +18,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -213,3 +217,137 @@ func TestAPINotificationPUT(t *testing.T) {
assert.True(t, apiNL[0].Unread) assert.True(t, apiNL[0].Unread)
assert.False(t, apiNL[0].Pinned) assert.False(t, apiNL[0].Pinned)
} }
func TestAPICommitNotification(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
session := loginUser(t, user2.Name)
token1 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
content := "This is a test commit"
contentEncoded := base64.StdEncoding.EncodeToString([]byte(content))
// push a commit with @user2 in the commit message, it's expected to create a notification
createFileOptions := api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: "master",
NewBranchName: "master",
Message: "This is a test commit to mention @user2",
Author: api.Identity{
Name: "Anne Doe",
Email: "annedoe@example.com",
},
Committer: api.Identity{
Name: "John Doe",
Email: "johndoe@example.com",
},
Dates: api.CommitDateOptions{
Author: time.Unix(946684810, 0),
Committer: time.Unix(978307190, 0),
},
},
ContentBase64: contentEncoded,
}
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/new_commit_notification.txt", user2.Name, repo1.Name), &createFileOptions).
AddTokenAuth(token1)
MakeRequest(t, req, http.StatusCreated)
// Check notifications are as expected
token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteNotification)
req = NewRequest(t, "GET", "/api/v1/notifications?all=true").
AddTokenAuth(token2)
resp := MakeRequest(t, req, http.StatusOK)
var apiNL []api.NotificationThread
DecodeJSON(t, resp, &apiNL)
assert.Equal(t, api.NotifySubjectCommit, apiNL[0].Subject.Type)
assert.Equal(t, "This is a test commit to mention @user2", apiNL[0].Subject.Title)
assert.True(t, apiNL[0].Unread)
assert.False(t, apiNL[0].Pinned)
})
}
func TestAPIReleaseNotification(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
session1 := loginUser(t, user1.Name)
token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeWriteRepository)
// user1 create a release, it's expected to create a notification
createNewReleaseUsingAPI(t, token1, user2, repo1, "v0.0.2", "", "v0.0.2 is released", "test notification release")
// user2 login to check notifications
session2 := loginUser(t, user2.Name)
// Check notifications are as expected
token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteNotification)
req := NewRequest(t, "GET", "/api/v1/notifications?all=true").
AddTokenAuth(token2)
resp := MakeRequest(t, req, http.StatusOK)
var apiNL []api.NotificationThread
DecodeJSON(t, resp, &apiNL)
assert.Equal(t, api.NotifySubjectRelease, apiNL[0].Subject.Type)
assert.Equal(t, "v0.0.2 is released", apiNL[0].Subject.Title)
assert.True(t, apiNL[0].Unread)
assert.False(t, apiNL[0].Pinned)
})
}
func TestAPIRepoTransferNotification(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session1 := loginUser(t, user2.Name)
token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
// create repo to move
repoName := "moveME"
apiRepo := new(api.Repository)
req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
Name: repoName,
Description: "repo move around",
Private: false,
Readme: "Default",
AutoInit: true,
}).AddTokenAuth(token1)
resp := MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, apiRepo)
defer func() {
_ = repo_service.DeleteRepositoryDirectly(db.DefaultContext, apiRepo.ID)
}()
// repo user1/moveME created, now transfer it to org6
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
session2 := loginUser(t, user2.Name)
token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteRepository)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", repo.OwnerName, repo.Name), &api.TransferRepoOption{
NewOwner: "org6",
TeamIDs: nil,
}).AddTokenAuth(token2)
MakeRequest(t, req, http.StatusCreated)
// user5 login to check notifications, because user5 is a member of org6's owners team
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
session5 := loginUser(t, user5.Name)
// Check notifications are as expected
token5 := getTokenForLoggedInUser(t, session5, auth_model.AccessTokenScopeWriteNotification)
req = NewRequest(t, "GET", "/api/v1/notifications?all=true").
AddTokenAuth(token5)
resp = MakeRequest(t, req, http.StatusOK)
var apiNL []api.NotificationThread
DecodeJSON(t, resp, &apiNL)
assert.Equal(t, api.NotifySubjectRepository, apiNL[0].Subject.Type)
assert.Equal(t, "user2/moveME", apiNL[0].Subject.Title)
assert.True(t, apiNL[0].Unread)
assert.False(t, apiNL[0].Pinned)
})
}

View File

@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -52,8 +53,8 @@ func getCreateFileOptions() api.CreateFileOptions {
func normalizeFileContentResponseCommitTime(c *api.ContentsResponse) { func normalizeFileContentResponseCommitTime(c *api.ContentsResponse) {
// decoded JSON response may contain different timezone from the one parsed by git commit // decoded JSON response may contain different timezone from the one parsed by git commit
// so we need to normalize the time to UTC to make "assert.Equal" pass // so we need to normalize the time to UTC to make "assert.Equal" pass
c.LastCommitterDate = c.LastCommitterDate.UTC() c.LastCommitterDate = util.ToPointer(c.LastCommitterDate.UTC())
c.LastAuthorDate = c.LastAuthorDate.UTC() c.LastAuthorDate = util.ToPointer(c.LastAuthorDate.UTC())
} }
type apiFileResponseInfo struct { type apiFileResponseInfo struct {
@ -74,9 +75,9 @@ func getExpectedFileResponseForCreate(info apiFileResponseInfo) *api.FileRespons
Name: path.Base(info.treePath), Name: path.Base(info.treePath),
Path: info.treePath, Path: info.treePath,
SHA: sha, SHA: sha,
LastCommitSHA: info.lastCommitSHA, LastCommitSHA: util.ToPointer(info.lastCommitSHA),
LastCommitterDate: info.lastCommitterWhen, LastCommitterDate: util.ToPointer(info.lastCommitterWhen),
LastAuthorDate: info.lastAuthorWhen, LastAuthorDate: util.ToPointer(info.lastAuthorWhen),
Size: 16, Size: 16,
Type: "file", Type: "file",
Encoding: &encoding, Encoding: &encoding,

View File

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -60,9 +61,9 @@ func getExpectedFileResponseForUpdate(info apiFileResponseInfo) *api.FileRespons
Name: path.Base(info.treePath), Name: path.Base(info.treePath),
Path: info.treePath, Path: info.treePath,
SHA: sha, SHA: sha,
LastCommitSHA: info.lastCommitSHA, LastCommitSHA: util.ToPointer(info.lastCommitSHA),
LastCommitterDate: info.lastCommitterWhen, LastCommitterDate: util.ToPointer(info.lastCommitterWhen),
LastAuthorDate: info.lastAuthorWhen, LastAuthorDate: util.ToPointer(info.lastAuthorWhen),
Type: "file", Type: "file",
Size: 20, Size: 20,
Encoding: &encoding, Encoding: &encoding,

View File

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -35,9 +36,9 @@ func getExpectedContentsListResponseForContents(ref, refType, lastCommitSHA stri
Name: path.Base(treePath), Name: path.Base(treePath),
Path: treePath, Path: treePath,
SHA: sha, SHA: sha,
LastCommitSHA: lastCommitSHA, LastCommitSHA: util.ToPointer(lastCommitSHA),
LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), LastCommitterDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))),
LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), LastAuthorDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))),
Type: "file", Type: "file",
Size: 30, Size: 30,
URL: &selfURL, URL: &selfURL,
@ -65,7 +66,6 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
treePath := "" // root dir
// Get user2's token // Get user2's token
session := loginUser(t, user2.Name) session := loginUser(t, user2.Name)
@ -94,7 +94,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
// ref is default ref // ref is default ref
ref := repo1.DefaultBranch ref := repo1.DefaultBranch
refType := "branch" refType := "branch"
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents?ref=%s", user2.Name, repo1.Name, ref)
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
var contentsListResponse []*api.ContentsResponse var contentsListResponse []*api.ContentsResponse
DecodeJSON(t, resp, &contentsListResponse) DecodeJSON(t, resp, &contentsListResponse)
@ -106,7 +106,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
// No ref // No ref
refType = "branch" refType = "branch"
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath) req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo1.Name)
resp = MakeRequest(t, req, http.StatusOK) resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &contentsListResponse) DecodeJSON(t, resp, &contentsListResponse)
assert.NotNil(t, contentsListResponse) assert.NotNil(t, contentsListResponse)
@ -117,7 +117,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
// ref is the branch we created above in setup // ref is the branch we created above in setup
ref = newBranch ref = newBranch
refType = "branch" refType = "branch"
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents?ref=%s", user2.Name, repo1.Name, ref)
resp = MakeRequest(t, req, http.StatusOK) resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &contentsListResponse) DecodeJSON(t, resp, &contentsListResponse)
assert.NotNil(t, contentsListResponse) assert.NotNil(t, contentsListResponse)
@ -131,7 +131,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
// ref is the new tag we created above in setup // ref is the new tag we created above in setup
ref = newTag ref = newTag
refType = "tag" refType = "tag"
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref)
resp = MakeRequest(t, req, http.StatusOK) resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &contentsListResponse) DecodeJSON(t, resp, &contentsListResponse)
assert.NotNil(t, contentsListResponse) assert.NotNil(t, contentsListResponse)
@ -145,7 +145,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
// ref is a commit // ref is a commit
ref = commitID ref = commitID
refType = "commit" refType = "commit"
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref)
resp = MakeRequest(t, req, http.StatusOK) resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &contentsListResponse) DecodeJSON(t, resp, &contentsListResponse)
assert.NotNil(t, contentsListResponse) assert.NotNil(t, contentsListResponse)
@ -154,21 +154,21 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) {
// Test file contents a file with a bad ref // Test file contents a file with a bad ref
ref = "badref" ref = "badref"
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
// Test accessing private ref with user token that does not have access - should fail // Test accessing private ref with user token that does not have access - should fail
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath). req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo16.Name).
AddTokenAuth(token4) AddTokenAuth(token4)
MakeRequest(t, req, http.StatusNotFound) MakeRequest(t, req, http.StatusNotFound)
// Test access private ref of owner of token // Test access private ref of owner of token
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name). req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo16.Name).
AddTokenAuth(token2) AddTokenAuth(token2)
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
// Test access of org org3 private repo file by owner user2 // Test access of org org3 private repo file by owner user2
req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath). req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", org3.Name, repo3.Name).
AddTokenAuth(token2) AddTokenAuth(token2)
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
} }

View File

@ -35,9 +35,9 @@ func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string)
Name: treePath, Name: treePath,
Path: treePath, Path: treePath,
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
LastCommitSHA: lastCommitSHA, LastCommitSHA: util.ToPointer(lastCommitSHA),
LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), LastCommitterDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))),
LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), LastAuthorDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))),
Type: "file", Type: "file",
Size: 30, Size: 30,
Encoding: util.ToPointer("base64"), Encoding: util.ToPointer("base64"),
@ -97,11 +97,16 @@ func testAPIGetContents(t *testing.T, u *url.URL) {
require.NoError(t, err) require.NoError(t, err)
/*** END SETUP ***/ /*** END SETUP ***/
// not found
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/no-such/file.md", user2.Name, repo1.Name)
resp := MakeRequest(t, req, http.StatusNotFound)
assert.Contains(t, resp.Body.String(), "object does not exist [id: , rel_path: no-such]")
// ref is default ref // ref is default ref
ref := repo1.DefaultBranch ref := repo1.DefaultBranch
refType := "branch" refType := "branch"
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
resp := MakeRequest(t, req, http.StatusOK) resp = MakeRequest(t, req, http.StatusOK)
var contentsResponse api.ContentsResponse var contentsResponse api.ContentsResponse
DecodeJSON(t, resp, &contentsResponse) DecodeJSON(t, resp, &contentsResponse)
lastCommit, _ := gitRepo.GetCommitByPath("README.md") lastCommit, _ := gitRepo.GetCommitByPath("README.md")
@ -206,14 +211,30 @@ func testAPIGetContentsExt(t *testing.T) {
session := loginUser(t, "user2") session := loginUser(t, "user2")
token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
t.Run("DirContents", func(t *testing.T) { t.Run("DirContents", func(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check") req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext?ref=sub-home-md-img-check")
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
var contentsResponse api.ContentsExtResponse var contentsResponse api.ContentsExtResponse
DecodeJSON(t, resp, &contentsResponse) DecodeJSON(t, resp, &contentsResponse)
assert.Nil(t, contentsResponse.FileContents) assert.Nil(t, contentsResponse.FileContents)
assert.NotNil(t, contentsResponse.DirContents)
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/.?ref=sub-home-md-img-check")
resp = MakeRequest(t, req, http.StatusOK)
contentsResponse = api.ContentsExtResponse{}
DecodeJSON(t, resp, &contentsResponse)
assert.Nil(t, contentsResponse.FileContents)
assert.NotNil(t, contentsResponse.DirContents)
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check")
resp = MakeRequest(t, req, http.StatusOK)
contentsResponse = api.ContentsExtResponse{}
DecodeJSON(t, resp, &contentsResponse)
assert.Nil(t, contentsResponse.FileContents)
assert.Equal(t, "README.md", contentsResponse.DirContents[0].Name) assert.Equal(t, "README.md", contentsResponse.DirContents[0].Name)
assert.Nil(t, contentsResponse.DirContents[0].Encoding) assert.Nil(t, contentsResponse.DirContents[0].Encoding)
assert.Nil(t, contentsResponse.DirContents[0].Content) assert.Nil(t, contentsResponse.DirContents[0].Content)
assert.Nil(t, contentsResponse.DirContents[0].LastCommitSHA)
assert.Nil(t, contentsResponse.DirContents[0].LastCommitMessage)
// "includes=file_content" shouldn't affect directory listing // "includes=file_content" shouldn't affect directory listing
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check&includes=file_content") req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check&includes=file_content")
@ -240,7 +261,7 @@ func testAPIGetContentsExt(t *testing.T) {
assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid) assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid)
}) })
t.Run("FileContents", func(t *testing.T) { t.Run("FileContents", func(t *testing.T) {
// by default, no file content is returned // by default, no file content or commit info is returned
req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check") req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check")
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
var contentsResponse api.ContentsExtResponse var contentsResponse api.ContentsExtResponse
@ -249,9 +270,11 @@ func testAPIGetContentsExt(t *testing.T) {
assert.Equal(t, "README.md", contentsResponse.FileContents.Name) assert.Equal(t, "README.md", contentsResponse.FileContents.Name)
assert.Nil(t, contentsResponse.FileContents.Encoding) assert.Nil(t, contentsResponse.FileContents.Encoding)
assert.Nil(t, contentsResponse.FileContents.Content) assert.Nil(t, contentsResponse.FileContents.Content)
assert.Nil(t, contentsResponse.FileContents.LastCommitSHA)
assert.Nil(t, contentsResponse.FileContents.LastCommitMessage)
// file content is only returned when `includes=file_content` // file content is only returned when `includes=file_content`
req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check&includes=file_content") req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check&includes=file_content,commit_metadata,commit_message")
resp = MakeRequest(t, req, http.StatusOK) resp = MakeRequest(t, req, http.StatusOK)
contentsResponse = api.ContentsExtResponse{} contentsResponse = api.ContentsExtResponse{}
DecodeJSON(t, resp, &contentsResponse) DecodeJSON(t, resp, &contentsResponse)
@ -259,6 +282,8 @@ func testAPIGetContentsExt(t *testing.T) {
assert.Equal(t, "README.md", contentsResponse.FileContents.Name) assert.Equal(t, "README.md", contentsResponse.FileContents.Name)
assert.NotNil(t, contentsResponse.FileContents.Encoding) assert.NotNil(t, contentsResponse.FileContents.Encoding)
assert.NotNil(t, contentsResponse.FileContents.Content) assert.NotNil(t, contentsResponse.FileContents.Content)
assert.Equal(t, "4649299398e4d39a5c09eb4f534df6f1e1eb87cc", *contentsResponse.FileContents.LastCommitSHA)
assert.Equal(t, "Test how READMEs render images when found in a subfolder\n", *contentsResponse.FileContents.LastCommitMessage)
req = NewRequestf(t, "GET", "/api/v1/repos/user2/lfs/contents-ext/jpeg.jpg?includes=file_content").AddTokenAuth(token2) req = NewRequestf(t, "GET", "/api/v1/repos/user2/lfs/contents-ext/jpeg.jpg?includes=file_content").AddTokenAuth(token2)
resp = session.MakeRequest(t, req, http.StatusOK) resp = session.MakeRequest(t, req, http.StatusOK)
@ -270,6 +295,8 @@ func testAPIGetContentsExt(t *testing.T) {
assert.Equal(t, "jpeg.jpg", respFile.Name) assert.Equal(t, "jpeg.jpg", respFile.Name)
assert.NotNil(t, respFile.Encoding) assert.NotNil(t, respFile.Encoding)
assert.NotNil(t, respFile.Content) assert.NotNil(t, respFile.Content)
assert.Nil(t, contentsResponse.FileContents.LastCommitSHA)
assert.Nil(t, contentsResponse.FileContents.LastCommitMessage)
assert.Equal(t, util.ToPointer(int64(107)), respFile.LfsSize) assert.Equal(t, util.ToPointer(int64(107)), respFile.LfsSize)
assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid) assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid)
}) })

View File

@ -155,9 +155,9 @@ func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.
Name: path.Base(treePath), Name: path.Base(treePath),
Path: treePath, Path: treePath,
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
LastCommitSHA: lastCommit.ID.String(), LastCommitSHA: util.ToPointer(lastCommit.ID.String()),
LastCommitterDate: lastCommit.Committer.When, LastCommitterDate: util.ToPointer(lastCommit.Committer.When),
LastAuthorDate: lastCommit.Author.When, LastAuthorDate: util.ToPointer(lastCommit.Author.When),
Type: "file", Type: "file",
Size: 18, Size: 18,
Encoding: &encoding, Encoding: &encoding,
@ -198,7 +198,7 @@ func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.
SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
}, },
}, },
Message: "Updates README.md\n", Message: "Creates new/file.txt\n",
Tree: &api.CommitMeta{ Tree: &api.CommitMeta{
URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc", URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc", SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc",
@ -225,9 +225,9 @@ func getExpectedFileResponseForRepoFilesUpdate(commitID, filename, lastCommitSHA
Name: filename, Name: filename,
Path: filename, Path: filename,
SHA: "dbf8d00e022e05b7e5cf7e535de857de57925647", SHA: "dbf8d00e022e05b7e5cf7e535de857de57925647",
LastCommitSHA: lastCommitSHA, LastCommitSHA: util.ToPointer(lastCommitSHA),
LastCommitterDate: lastCommitterWhen, LastCommitterDate: util.ToPointer(lastCommitterWhen),
LastAuthorDate: lastAuthorWhen, LastAuthorDate: util.ToPointer(lastAuthorWhen),
Type: "file", Type: "file",
Size: 43, Size: 43,
Encoding: &encoding, Encoding: &encoding,
@ -331,7 +331,7 @@ func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA str
Name: detail.filename, Name: detail.filename,
Path: detail.filename, Path: detail.filename,
SHA: detail.sha, SHA: detail.sha,
LastCommitSHA: lastCommitSHA, LastCommitSHA: util.ToPointer(lastCommitSHA),
Type: "file", Type: "file",
Size: detail.size, Size: detail.size,
Encoding: util.ToPointer("base64"), Encoding: util.ToPointer("base64"),
@ -537,7 +537,7 @@ func TestChangeRepoFilesForUpdateWithFileRename(t *testing.T) {
lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath) lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
expectedFileResponse := getExpectedFileResponseForRepoFilesUpdateRename(commit.ID.String(), lastCommit.ID.String()) expectedFileResponse := getExpectedFileResponseForRepoFilesUpdateRename(commit.ID.String(), lastCommit.ID.String())
for _, file := range filesResponse.Files { for _, file := range filesResponse.Files {
file.LastCommitterDate, file.LastAuthorDate = time.Time{}, time.Time{} // there might be different time in one operation, so we ignore them file.LastCommitterDate, file.LastAuthorDate = nil, nil // there might be different time in one operation, so we ignore them
} }
assert.Len(t, filesResponse.Files, 4) assert.Len(t, filesResponse.Files, 4)
assert.Equal(t, expectedFileResponse.Files, filesResponse.Files) assert.Equal(t, expectedFileResponse.Files, filesResponse.Files)

View File

@ -1,6 +1,6 @@
import type {Issue} from '../types.ts'; import type {Issue} from '../types.ts';
// the getIssueIcon/getIssueColor logic should be kept the same as "templates/shared/issueicon.tmpl" // the getIssueIcon/getIssueColor logic should be kept the same as "models/activities/issue.IconHTML"
export function getIssueIcon(issue: Issue) { export function getIssueIcon(issue: Issue) {
if (issue.pull_request) { if (issue.pull_request) {