mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
419 lines
13 KiB
Go
419 lines
13 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package activities
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"github.com/hanzoai/git/models/db"
|
|
issues_model "github.com/hanzoai/git/models/issues"
|
|
"github.com/hanzoai/git/models/organization"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/timeutil"
|
|
|
|
"github.com/hanzoai/builder"
|
|
"github.com/hanzoai/xorm/schemas"
|
|
)
|
|
|
|
type (
|
|
// NotificationStatus is the status of the notification (read or unread)
|
|
NotificationStatus uint8
|
|
// NotificationSource is the source of the notification (issue, PR, commit, etc)
|
|
NotificationSource uint8
|
|
)
|
|
|
|
const (
|
|
// NotificationStatusUnread represents an unread notification
|
|
NotificationStatusUnread NotificationStatus = iota + 1
|
|
// NotificationStatusRead represents a read notification
|
|
NotificationStatusRead
|
|
// NotificationStatusPinned represents a pinned notification
|
|
NotificationStatusPinned
|
|
)
|
|
|
|
const (
|
|
// NotificationSourceIssue is a notification of an issue
|
|
NotificationSourceIssue NotificationSource = iota + 1
|
|
// NotificationSourcePullRequest is a notification of a pull request
|
|
NotificationSourcePullRequest
|
|
// NotificationSourceCommit is a notification of a commit
|
|
NotificationSourceCommit
|
|
// NotificationSourceRepository is a notification for a repository
|
|
NotificationSourceRepository
|
|
)
|
|
|
|
// Notification represents a notification
|
|
type Notification struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
UserID int64 `xorm:"NOT NULL"`
|
|
RepoID int64 `xorm:"NOT NULL"`
|
|
|
|
Status NotificationStatus `xorm:"SMALLINT NOT NULL"`
|
|
Source NotificationSource `xorm:"SMALLINT NOT NULL"`
|
|
|
|
IssueID int64 `xorm:"NOT NULL"`
|
|
CommitID string
|
|
CommentID int64
|
|
|
|
UpdatedBy int64 `xorm:"NOT NULL"`
|
|
|
|
Issue *issues_model.Issue `xorm:"-"`
|
|
Repository *repo_model.Repository `xorm:"-"`
|
|
Comment *issues_model.Comment `xorm:"-"`
|
|
User *user_model.User `xorm:"-"`
|
|
|
|
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
|
|
}
|
|
|
|
// TableIndices implements xorm's TableIndices interface
|
|
func (n *Notification) 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)
|
|
|
|
updatedByIndex := schemas.NewIndex("idx_notification_updated_by", schemas.IndexType)
|
|
updatedByIndex.AddColumn("updated_by")
|
|
indices = append(indices, updatedByIndex)
|
|
|
|
return indices
|
|
}
|
|
|
|
func init() {
|
|
db.RegisterModel(new(Notification))
|
|
}
|
|
|
|
// 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 {
|
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
|
var notify []*Notification
|
|
|
|
if newOwner.IsOrganization() {
|
|
users, err := organization.GetUsersWhoCanCreateOrgRepo(ctx, newOwner.ID)
|
|
if err != nil || len(users) == 0 {
|
|
return err
|
|
}
|
|
for i := range users {
|
|
notify = append(notify, &Notification{
|
|
UserID: i,
|
|
RepoID: repo.ID,
|
|
Status: NotificationStatusUnread,
|
|
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)
|
|
})
|
|
}
|
|
|
|
func createIssueNotification(ctx context.Context, userID int64, issue *issues_model.Issue, commentID, updatedByID int64) error {
|
|
notification := &Notification{
|
|
UserID: userID,
|
|
RepoID: issue.RepoID,
|
|
Status: NotificationStatusUnread,
|
|
IssueID: issue.ID,
|
|
CommentID: commentID,
|
|
UpdatedBy: updatedByID,
|
|
}
|
|
|
|
if issue.IsPull {
|
|
notification.Source = NotificationSourcePullRequest
|
|
} else {
|
|
notification.Source = NotificationSourceIssue
|
|
}
|
|
|
|
return db.Insert(ctx, notification)
|
|
}
|
|
|
|
func updateIssueNotification(ctx context.Context, userID, issueID, commentID, updatedByID int64) error {
|
|
notification, err := GetIssueNotification(ctx, userID, issueID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// NOTICE: Only update comment id when the before notification on this issue is read, otherwise you may miss some old comments.
|
|
// But we need update update_by so that the notification will be reorder
|
|
var cols []string
|
|
if notification.Status == NotificationStatusRead {
|
|
notification.Status = NotificationStatusUnread
|
|
notification.CommentID = commentID
|
|
cols = []string{"status", "update_by", "comment_id"}
|
|
} else {
|
|
notification.UpdatedBy = updatedByID
|
|
cols = []string{"update_by"}
|
|
}
|
|
|
|
_, err = db.GetEngine(ctx).ID(notification.ID).Cols(cols...).Update(notification)
|
|
return err
|
|
}
|
|
|
|
// GetIssueNotification return the notification about an issue
|
|
func GetIssueNotification(ctx context.Context, userID, issueID int64) (*Notification, error) {
|
|
notification := new(Notification)
|
|
_, err := db.GetEngine(ctx).
|
|
Where("user_id = ?", userID).
|
|
And("issue_id = ?", issueID).
|
|
Get(notification)
|
|
return notification, err
|
|
}
|
|
|
|
// LoadAttributes load Repo Issue User and Comment if not loaded
|
|
func (n *Notification) LoadAttributes(ctx context.Context) (err error) {
|
|
if err = n.loadRepo(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err = n.loadIssue(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err = n.loadUser(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err = n.loadComment(ctx); err != nil {
|
|
return err
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (n *Notification) loadRepo(ctx context.Context) (err error) {
|
|
if n.Repository == nil {
|
|
n.Repository, err = repo_model.GetRepositoryByID(ctx, n.RepoID)
|
|
if err != nil {
|
|
return fmt.Errorf("getRepositoryByID [%d]: %w", n.RepoID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *Notification) loadIssue(ctx context.Context) (err error) {
|
|
if n.Issue == nil && n.IssueID != 0 {
|
|
n.Issue, err = issues_model.GetIssueByID(ctx, n.IssueID)
|
|
if err != nil {
|
|
return fmt.Errorf("getIssueByID [%d]: %w", n.IssueID, err)
|
|
}
|
|
return n.Issue.LoadAttributes(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *Notification) loadComment(ctx context.Context) (err error) {
|
|
if n.Comment == nil && n.CommentID != 0 {
|
|
n.Comment, err = issues_model.GetCommentByID(ctx, n.CommentID)
|
|
if err != nil {
|
|
if issues_model.IsErrCommentNotExist(err) {
|
|
return issues_model.ErrCommentNotExist{
|
|
ID: n.CommentID,
|
|
IssueID: n.IssueID,
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *Notification) loadUser(ctx context.Context) (err error) {
|
|
if n.User == nil {
|
|
n.User, err = user_model.GetUserByID(ctx, n.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("getUserByID [%d]: %w", n.UserID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetRepo returns the repo of the notification
|
|
func (n *Notification) GetRepo(ctx context.Context) (*repo_model.Repository, error) {
|
|
return n.Repository, n.loadRepo(ctx)
|
|
}
|
|
|
|
// GetIssue returns the issue of the notification
|
|
func (n *Notification) GetIssue(ctx context.Context) (*issues_model.Issue, error) {
|
|
return n.Issue, n.loadIssue(ctx)
|
|
}
|
|
|
|
// HTMLURL formats a URL-string to the notification
|
|
func (n *Notification) HTMLURL(ctx context.Context) string {
|
|
switch n.Source {
|
|
case NotificationSourceIssue, NotificationSourcePullRequest:
|
|
if n.Comment != nil {
|
|
return n.Comment.HTMLURL(ctx)
|
|
}
|
|
return n.Issue.HTMLURL(ctx)
|
|
case NotificationSourceCommit:
|
|
return n.Repository.HTMLURL(ctx) + "/commit/" + url.PathEscape(n.CommitID)
|
|
case NotificationSourceRepository:
|
|
return n.Repository.HTMLURL(ctx)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Link formats a relative URL-string to the notification
|
|
func (n *Notification) Link(ctx context.Context) string {
|
|
switch n.Source {
|
|
case NotificationSourceIssue, NotificationSourcePullRequest:
|
|
if n.Comment != nil {
|
|
return n.Comment.Link(ctx)
|
|
}
|
|
return n.Issue.Link()
|
|
case NotificationSourceCommit:
|
|
return n.Repository.Link() + "/commit/" + url.PathEscape(n.CommitID)
|
|
case NotificationSourceRepository:
|
|
return n.Repository.Link()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// APIURL formats a URL-string to the notification
|
|
func (n *Notification) APIURL() string {
|
|
return setting.AppURL + "v1/notifications/threads/" + strconv.FormatInt(n.ID, 10)
|
|
}
|
|
|
|
func notificationExists(notifications []*Notification, issueID, userID int64) bool {
|
|
for _, notification := range notifications {
|
|
if notification.IssueID == issueID && notification.UserID == userID {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// UserIDCount is a simple coalition of UserID and Count
|
|
type UserIDCount struct {
|
|
UserID int64
|
|
Count int64
|
|
}
|
|
|
|
// GetUIDsAndNotificationCounts returns the unread counts for every user between the two provided times.
|
|
// It must return all user IDs which appear during the period, including count=0 for users who have read all.
|
|
func GetUIDsAndNotificationCounts(ctx context.Context, since, until timeutil.TimeStamp) ([]UserIDCount, error) {
|
|
sql := `SELECT user_id, sum(case when status= ? then 1 else 0 end) AS count FROM notification ` +
|
|
`WHERE user_id IN (SELECT user_id FROM notification WHERE updated_unix >= ? AND ` +
|
|
`updated_unix < ?) GROUP BY user_id`
|
|
var res []UserIDCount
|
|
return res, db.GetEngine(ctx).SQL(sql, NotificationStatusUnread, since, until).Find(&res)
|
|
}
|
|
|
|
// SetIssueReadBy sets issue to be read by given user.
|
|
func SetIssueReadBy(ctx context.Context, issueID, userID int64) error {
|
|
if err := issues_model.UpdateIssueUserByRead(ctx, userID, issueID); err != nil {
|
|
return err
|
|
}
|
|
|
|
return setIssueNotificationStatusReadIfUnread(ctx, userID, issueID)
|
|
}
|
|
|
|
func setIssueNotificationStatusReadIfUnread(ctx context.Context, userID, issueID int64) error {
|
|
notification, err := GetIssueNotification(ctx, userID, issueID)
|
|
// ignore if not exists
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
if notification.Status != NotificationStatusUnread {
|
|
return nil
|
|
}
|
|
|
|
notification.Status = NotificationStatusRead
|
|
|
|
_, err = db.GetEngine(ctx).ID(notification.ID).Cols("status").Update(notification)
|
|
return err
|
|
}
|
|
|
|
// SetRepoReadBy sets repo to be visited by given user.
|
|
func SetRepoReadBy(ctx context.Context, userID, repoID int64) error {
|
|
_, err := db.GetEngine(ctx).Where(builder.Eq{
|
|
"user_id": userID,
|
|
"status": NotificationStatusUnread,
|
|
"source": NotificationSourceRepository,
|
|
"repo_id": repoID,
|
|
}).Cols("status").Update(&Notification{Status: NotificationStatusRead})
|
|
return err
|
|
}
|
|
|
|
// SetNotificationStatus change the notification status
|
|
func SetNotificationStatus(ctx context.Context, notificationID int64, user *user_model.User, status NotificationStatus) (*Notification, error) {
|
|
notification, err := GetNotificationByID(ctx, notificationID)
|
|
if err != nil {
|
|
return notification, err
|
|
}
|
|
|
|
if notification.UserID != user.ID {
|
|
return nil, fmt.Errorf("Can't change notification of another user: %d, %d", notification.UserID, user.ID)
|
|
}
|
|
|
|
notification.Status = status
|
|
|
|
_, err = db.GetEngine(ctx).ID(notificationID).Cols("status").Update(notification)
|
|
return notification, err
|
|
}
|
|
|
|
// GetNotificationByID return notification by ID
|
|
func GetNotificationByID(ctx context.Context, notificationID int64) (*Notification, error) {
|
|
notification := new(Notification)
|
|
ok, err := db.GetEngine(ctx).
|
|
Where("id = ?", notificationID).
|
|
Get(notification)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !ok {
|
|
return nil, db.ErrNotExist{Resource: "notification", ID: notificationID}
|
|
}
|
|
|
|
return notification, nil
|
|
}
|
|
|
|
// UpdateNotificationStatuses updates the statuses of all of a user's notifications that are of the currentStatus type to the desiredStatus
|
|
func UpdateNotificationStatuses(ctx context.Context, user *user_model.User, currentStatus, desiredStatus NotificationStatus) error {
|
|
n := &Notification{Status: desiredStatus, UpdatedBy: user.ID}
|
|
_, err := db.GetEngine(ctx).
|
|
Where("user_id = ? AND status = ?", user.ID, currentStatus).
|
|
Cols("status", "updated_by", "updated_unix").
|
|
Update(n)
|
|
return err
|
|
}
|