mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 22:23:42 +09:00
Comment excerpts in activity feeds previously used either the first 200 display characters of a comment or, for review comments, its first physical line. That excerpt is rendered as Markdown in the feed, so if the excerpt began with a leading blank lines or structural markdown syntax, the excerpt would render as empty or produce broken output. For example, a review comment beginning with a code fence stored only the opening fence, which rendered as an empty code block. This commit instead renders feed excerpts as prose only, dropping code, math, tables, images and HTML, which also fixes already stored excerpts. New excerpts start at the first prose line, and review comments get the same excerpt as issue comments. This produces meaningful excerpts in more cases while preserving their original Markdown. --- For a comment that contains the following: ```` ``` some code ``` hello ```` This previously rendered as: <img width="816" height="118" alt="Screenshot 2026-09-08 at 5 06 47 PM" src="https://github.com/user-attachments/assets/9d125363-72de-46bf-b47a-961245a79c5f" /> And now renders as: <img width="807" height="89" alt="Screenshot 2026-09-08 at 5 08 30 PM" src="https://github.com/user-attachments/assets/d2dcdc6d-d7a9-437f-8e9c-b845fe99fa5e" /> --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: bircni <bircni@icloud.com>
474 lines
15 KiB
Go
474 lines
15 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package feed
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path"
|
|
"strings"
|
|
|
|
activities_model "gitea.dev/models/activities"
|
|
issues_model "gitea.dev/models/issues"
|
|
repo_model "gitea.dev/models/repo"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/git"
|
|
"gitea.dev/modules/json"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/markup/markdown"
|
|
"gitea.dev/modules/repository"
|
|
notify_service "gitea.dev/services/notify"
|
|
)
|
|
|
|
type actionNotifier struct {
|
|
notify_service.NullNotifier
|
|
}
|
|
|
|
var _ notify_service.Notifier = &actionNotifier{}
|
|
|
|
func Init() error {
|
|
notify_service.RegisterNotifier(NewNotifier())
|
|
|
|
return nil
|
|
}
|
|
|
|
// NewNotifier create a new actionNotifier notifier
|
|
func NewNotifier() notify_service.Notifier {
|
|
return &actionNotifier{}
|
|
}
|
|
|
|
func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
|
|
if err := issue.LoadPoster(ctx); err != nil {
|
|
log.Error("issue.LoadPoster: %v", err)
|
|
return
|
|
}
|
|
if err := issue.LoadRepo(ctx); err != nil {
|
|
log.Error("issue.LoadRepo: %v", err)
|
|
return
|
|
}
|
|
repo := issue.Repo
|
|
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: issue.Poster.ID,
|
|
ActUser: issue.Poster,
|
|
OpType: activities_model.ActionCreateIssue,
|
|
Content: fmt.Sprintf("%d|%s", issue.Index, issue.Title),
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
// IssueChangeStatus notifies close or reopen issue to notifiers
|
|
func (a *actionNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, closeOrReopen bool) {
|
|
// Compose comment action, could be plain comment, close or reopen issue/pull request.
|
|
// This object will be used to notify watchers in the end of function.
|
|
act := &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
Content: fmt.Sprintf("%d|%s", issue.Index, ""),
|
|
RepoID: issue.Repo.ID,
|
|
Repo: issue.Repo,
|
|
Comment: actionComment,
|
|
CommentID: actionComment.ID,
|
|
IsPrivate: issue.Repo.IsPrivate,
|
|
}
|
|
// Check comment type.
|
|
if closeOrReopen {
|
|
act.OpType = activities_model.ActionCloseIssue
|
|
if issue.IsPull {
|
|
act.OpType = activities_model.ActionClosePullRequest
|
|
}
|
|
} else {
|
|
act.OpType = activities_model.ActionReopenIssue
|
|
if issue.IsPull {
|
|
act.OpType = activities_model.ActionReopenPullRequest
|
|
}
|
|
}
|
|
|
|
// Notify watchers for whatever action comes in, ignore if no action type.
|
|
if err := NotifyWatchers(ctx, act); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
// CreateIssueComment notifies comment on an issue to notifiers
|
|
func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
|
|
issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User,
|
|
) {
|
|
act := &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
RepoID: issue.Repo.ID,
|
|
Repo: issue.Repo,
|
|
Comment: comment,
|
|
CommentID: comment.ID,
|
|
IsPrivate: issue.Repo.IsPrivate,
|
|
}
|
|
|
|
act.Content = fmt.Sprintf("%d|%s", issue.Index, markdown.FeedExcerpt(ctx, comment.Content))
|
|
|
|
if issue.IsPull {
|
|
act.OpType = activities_model.ActionCommentPull
|
|
} else {
|
|
act.OpType = activities_model.ActionCommentIssue
|
|
}
|
|
|
|
// Notify watchers for whatever action comes in, ignore if no action type.
|
|
if err := NotifyWatchers(ctx, act); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) NewPullRequest(ctx context.Context, pull *issues_model.PullRequest, mentions []*user_model.User) {
|
|
if err := pull.LoadIssue(ctx); err != nil {
|
|
log.Error("pull.LoadIssue: %v", err)
|
|
return
|
|
}
|
|
if err := pull.Issue.LoadRepo(ctx); err != nil {
|
|
log.Error("pull.Issue.LoadRepo: %v", err)
|
|
return
|
|
}
|
|
if err := pull.Issue.LoadPoster(ctx); err != nil {
|
|
log.Error("pull.Issue.LoadPoster: %v", err)
|
|
return
|
|
}
|
|
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: pull.Issue.Poster.ID,
|
|
ActUser: pull.Issue.Poster,
|
|
OpType: activities_model.ActionCreatePullRequest,
|
|
Content: fmt.Sprintf("%d|%s", pull.Issue.Index, pull.Issue.Title),
|
|
RepoID: pull.Issue.Repo.ID,
|
|
Repo: pull.Issue.Repo,
|
|
IsPrivate: pull.Issue.Repo.IsPrivate,
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) {
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: activities_model.ActionRenameRepo,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
Content: oldRepoName,
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: activities_model.ActionTransferRepo,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
Content: path.Join(oldOwnerName, repo.Name),
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: activities_model.ActionCreateRepo,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
}); err != nil {
|
|
log.Error("notify watchers '%d/%d': %v", doer.ID, repo.ID, err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: activities_model.ActionCreateRepo,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
}); err != nil {
|
|
log.Error("notify watchers '%d/%d': %v", doer.ID, repo.ID, err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) {
|
|
if err := review.LoadReviewer(ctx); err != nil {
|
|
log.Error("LoadReviewer '%d/%d': %v", review.ID, review.ReviewerID, err)
|
|
return
|
|
}
|
|
if err := review.LoadCodeComments(ctx); err != nil {
|
|
log.Error("LoadCodeComments '%d/%d': %v", review.Reviewer.ID, review.ID, err)
|
|
return
|
|
}
|
|
|
|
actions := make([]*activities_model.Action, 0, 10)
|
|
for _, lines := range review.CodeComments {
|
|
for _, comments := range lines {
|
|
for _, comm := range comments {
|
|
actions = append(actions, &activities_model.Action{
|
|
ActUserID: review.Reviewer.ID,
|
|
ActUser: review.Reviewer,
|
|
Content: fmt.Sprintf("%d|%s", review.Issue.Index, markdown.FeedExcerpt(ctx, comm.Content)),
|
|
OpType: activities_model.ActionCommentPull,
|
|
RepoID: review.Issue.RepoID,
|
|
Repo: review.Issue.Repo,
|
|
IsPrivate: review.Issue.Repo.IsPrivate,
|
|
Comment: comm,
|
|
CommentID: comm.ID,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if review.Type != issues_model.ReviewTypeComment || strings.TrimSpace(comment.Content) != "" {
|
|
action := &activities_model.Action{
|
|
ActUserID: review.Reviewer.ID,
|
|
ActUser: review.Reviewer,
|
|
Content: fmt.Sprintf("%d|%s", review.Issue.Index, markdown.FeedExcerpt(ctx, comment.Content)),
|
|
RepoID: review.Issue.RepoID,
|
|
Repo: review.Issue.Repo,
|
|
IsPrivate: review.Issue.Repo.IsPrivate,
|
|
Comment: comment,
|
|
CommentID: comment.ID,
|
|
}
|
|
|
|
switch review.Type {
|
|
case issues_model.ReviewTypeApprove:
|
|
action.OpType = activities_model.ActionApprovePullRequest
|
|
case issues_model.ReviewTypeReject:
|
|
action.OpType = activities_model.ActionRejectPullRequest
|
|
default:
|
|
action.OpType = activities_model.ActionCommentPull
|
|
}
|
|
|
|
actions = append(actions, action)
|
|
}
|
|
|
|
if err := NotifyWatchers(ctx, actions...); err != nil {
|
|
log.Error("notify watchers '%d/%d': %v", review.Reviewer.ID, review.Issue.RepoID, err)
|
|
}
|
|
}
|
|
|
|
func (*actionNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: activities_model.ActionMergePullRequest,
|
|
Content: fmt.Sprintf("%d|%s", pr.Issue.Index, pr.Issue.Title),
|
|
RepoID: pr.Issue.Repo.ID,
|
|
Repo: pr.Issue.Repo,
|
|
IsPrivate: pr.Issue.Repo.IsPrivate,
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers [%d]: %v", pr.ID, err)
|
|
}
|
|
}
|
|
|
|
func (*actionNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: activities_model.ActionAutoMergePullRequest,
|
|
Content: fmt.Sprintf("%d|%s", pr.Issue.Index, pr.Issue.Title),
|
|
RepoID: pr.Issue.Repo.ID,
|
|
Repo: pr.Issue.Repo,
|
|
IsPrivate: pr.Issue.Repo.IsPrivate,
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers [%d]: %v", pr.ID, err)
|
|
}
|
|
}
|
|
|
|
func (*actionNotifier) NotifyPullRevieweDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) {
|
|
reviewerName := review.Reviewer.Name
|
|
if len(review.OriginalAuthor) > 0 {
|
|
reviewerName = review.OriginalAuthor
|
|
}
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: activities_model.ActionPullReviewDismissed,
|
|
Content: fmt.Sprintf("%d|%s|%s", review.Issue.Index, reviewerName, comment.Content),
|
|
RepoID: review.Issue.Repo.ID,
|
|
Repo: review.Issue.Repo,
|
|
IsPrivate: review.Issue.Repo.IsPrivate,
|
|
CommentID: comment.ID,
|
|
Comment: comment,
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers [%d]: %v", review.Issue.ID, err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
|
data, err := json.Marshal(commits)
|
|
if err != nil {
|
|
log.Error("Marshal: %v", err)
|
|
return
|
|
}
|
|
|
|
opType := activities_model.ActionCommitRepo
|
|
|
|
// Check its tag push or branch.
|
|
if opts.RefFullName.IsTag() {
|
|
opType = activities_model.ActionPushTag
|
|
if opts.IsDelRef() {
|
|
opType = activities_model.ActionDeleteTag
|
|
}
|
|
} else if opts.IsDelRef() {
|
|
opType = activities_model.ActionDeleteBranch
|
|
}
|
|
|
|
// HINT: USER-ACTIVITY-PUSH-COMMITS: it's said that the time of push commits (for user activity display) is designed this way,
|
|
// it doesn't use git commit's time, it only uses the doer's action time.
|
|
// ref: https://github.com/go-gitea/gitea/pull/36469#issuecomment-3901955347
|
|
// ref: https://github.com/go-gitea/gitea/issues/14051
|
|
// ref: https://github.com/go-gitea/gitea/issues/11861#issuecomment-643162143
|
|
if err = NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: pusher.ID,
|
|
ActUser: pusher,
|
|
OpType: opType,
|
|
Content: string(data),
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
RefName: opts.RefFullName.String(),
|
|
IsPrivate: repo.IsPrivate,
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) CreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) {
|
|
opType := activities_model.ActionCommitRepo
|
|
if refFullName.IsTag() {
|
|
// has sent same action in `PushCommits`, so skip it.
|
|
return
|
|
}
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: opType,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
RefName: refFullName.String(),
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) DeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) {
|
|
opType := activities_model.ActionDeleteBranch
|
|
if refFullName.IsTag() {
|
|
// has sent same action in `PushCommits`, so skip it.
|
|
return
|
|
}
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: doer.ID,
|
|
ActUser: doer,
|
|
OpType: opType,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
RefName: refFullName.String(),
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
|
// ignore pull sync message for pull requests refs
|
|
// TODO: it's better to have a UI to let users chose
|
|
if opts.RefFullName.IsPull() {
|
|
return
|
|
}
|
|
|
|
data, err := json.Marshal(commits)
|
|
if err != nil {
|
|
log.Error("json.Marshal: %v", err)
|
|
return
|
|
}
|
|
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: repo.OwnerID,
|
|
ActUser: repo.MustOwner(ctx),
|
|
OpType: activities_model.ActionMirrorSyncPush,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
RefName: opts.RefFullName.String(),
|
|
Content: string(data),
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) SyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) {
|
|
// ignore pull sync message for pull requests refs
|
|
// TODO: it's better to have a UI to let users chose
|
|
if refFullName.IsPull() {
|
|
return
|
|
}
|
|
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: repo.OwnerID,
|
|
ActUser: repo.MustOwner(ctx),
|
|
OpType: activities_model.ActionMirrorSyncCreate,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
RefName: refFullName.String(),
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) SyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) {
|
|
// ignore pull sync message for pull requests refs
|
|
// TODO: it's better to have a UI to let users chose
|
|
if refFullName.IsPull() {
|
|
return
|
|
}
|
|
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: repo.OwnerID,
|
|
ActUser: repo.MustOwner(ctx),
|
|
OpType: activities_model.ActionMirrorSyncDelete,
|
|
RepoID: repo.ID,
|
|
Repo: repo,
|
|
IsPrivate: repo.IsPrivate,
|
|
RefName: refFullName.String(),
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *actionNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) {
|
|
if err := rel.LoadAttributes(ctx); err != nil {
|
|
log.Error("LoadAttributes: %v", err)
|
|
return
|
|
}
|
|
if err := NotifyWatchers(ctx, &activities_model.Action{
|
|
ActUserID: rel.PublisherID,
|
|
ActUser: rel.Publisher,
|
|
OpType: activities_model.ActionPublishRelease,
|
|
RepoID: rel.RepoID,
|
|
Repo: rel.Repo,
|
|
IsPrivate: rel.Repo.IsPrivate,
|
|
Content: rel.Title,
|
|
RefName: git.RefNameFromTag(rel.TagName).String(), // Other functions in this file all use "refFullName.String()"
|
|
}); err != nil {
|
|
log.Error("NotifyWatchers: %v", err)
|
|
}
|
|
}
|