1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-01-20 16:50:28 -05:00

Compare commits

...

4 commits

Author SHA1 Message Date
Litchi Pi
233c3f4819 lint templates
Signed-off-by: Litchi Pi <litchi.pi@proton.me>
2025-01-20 17:26:25 +01:00
Litchi Pi
c993e4a157 Fixup the lint required for CI
Signed-off-by: Litchi Pi <litchi.pi@proton.me>
2025-01-20 17:17:53 +01:00
Litchi Pi
8ae2e4b2ef format with gofumpt also
Signed-off-by: Litchi Pi <litchi.pi@proton.me>
2025-01-20 16:54:49 +01:00
Litchi Pi
faad7e0017 models: issue: create a comment aggregator to reduce noise in issue
Signed-off-by: Litchi Pi <litchi.pi@proton.me>
2025-01-20 16:47:07 +01:00
7 changed files with 1027 additions and 922 deletions

View file

@ -114,6 +114,8 @@ const (
CommentTypePin // 36 pin Issue CommentTypePin // 36 pin Issue
CommentTypeUnpin // 37 unpin Issue CommentTypeUnpin // 37 unpin Issue
CommentTypeAggregator // 38 Aggregator of comments
) )
var commentStrings = []string{ var commentStrings = []string{
@ -236,10 +238,30 @@ func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
return lang.TrString("repo.issues.role." + string(r) + "_helper") return lang.TrString("repo.issues.role." + string(r) + "_helper")
} }
type RequestReviewTarget interface { type RequestReviewTarget struct {
ID() int64 User *user_model.User
Name() string Team *organization.Team
Type() string }
func (t *RequestReviewTarget) ID() int64 {
if t.User != nil {
return t.User.ID
}
return t.Team.ID
}
func (t *RequestReviewTarget) Name() string {
if t.User != nil {
return t.User.GetDisplayName()
}
return t.Team.Name
}
func (t *RequestReviewTarget) Type() string {
if t.User != nil {
return "user"
}
return "team"
} }
// Comment represents a comment in commit and issue page. // Comment represents a comment in commit and issue page.
@ -254,6 +276,7 @@ type Comment struct {
Issue *Issue `xorm:"-"` Issue *Issue `xorm:"-"`
LabelID int64 LabelID int64
Label *Label `xorm:"-"` Label *Label `xorm:"-"`
Aggregator *CommentAggregator `xorm:"-"`
AddedLabels []*Label `xorm:"-"` AddedLabels []*Label `xorm:"-"`
RemovedLabels []*Label `xorm:"-"` RemovedLabels []*Label `xorm:"-"`
AddedRequestReview []RequestReviewTarget `xorm:"-"` AddedRequestReview []RequestReviewTarget `xorm:"-"`

View file

@ -0,0 +1,328 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package issues
import (
"slices"
)
type CommentAggregator struct {
StartUnix int64
Indexes []int
PosterID int64
PrevClosed bool
IsClosed bool
AddedLabels []*Label
RemovedLabels []*Label
AddedRequestReview []RequestReviewTarget
RemovedRequestReview []RequestReviewTarget
}
// TODO Aggregate also
// - Dependency added / removed
// - Added / Removed due date
// - Milestone Added / Removed
func (agg *CommentAggregator) aggregateComment(c *Comment, index int) {
agg.Indexes = append(agg.Indexes, index)
if c.Type == CommentTypeClose {
agg.IsClosed = true
} else if c.Type == CommentTypeReopen {
agg.IsClosed = false
} else if c.Type == CommentTypeReviewRequest {
if c.AssigneeID > 0 {
req := RequestReviewTarget{User: c.Assignee}
if c.RemovedAssignee {
agg.delReviewRequest(req)
} else {
agg.addReviewRequest(req)
}
} else if c.AssigneeTeamID > 0 {
req := RequestReviewTarget{Team: c.AssigneeTeam}
if c.RemovedAssignee {
agg.delReviewRequest(req)
} else {
agg.addReviewRequest(req)
}
}
for _, r := range c.RemovedRequestReview {
agg.delReviewRequest(r)
}
for _, r := range c.AddedRequestReview {
agg.addReviewRequest(r)
}
} else if c.Type == CommentTypeLabel {
if c.Content == "1" {
agg.addLabel(c.Label)
} else {
agg.delLabel(c.Label)
}
} else if c.Type == CommentTypeAggregator {
agg.Merge(c.Aggregator)
}
}
// Merge a past CommentAggregator with the next one in the issue comments list
func (agg *CommentAggregator) Merge(next *CommentAggregator) {
agg.IsClosed = next.IsClosed
for _, l := range next.AddedLabels {
agg.addLabel(l)
}
for _, l := range next.RemovedLabels {
agg.delLabel(l)
}
for _, r := range next.AddedRequestReview {
agg.addReviewRequest(r)
}
for _, r := range next.RemovedRequestReview {
agg.delReviewRequest(r)
}
}
// Check if a comment can be aggregated or not depending on its type
func (agg *CommentAggregator) IsAggregated(t *CommentType) bool {
a := false
a = a || (*t == CommentTypeAggregator)
a = a || (*t == CommentTypeClose)
a = a || (*t == CommentTypeReopen)
a = a || (*t == CommentTypeLabel)
a = a || (*t == CommentTypeReviewRequest)
return a
}
// Add a label to the aggregated list
func (agg *CommentAggregator) addLabel(lbl *Label) {
for l, agglbl := range agg.RemovedLabels {
if agglbl.ID == lbl.ID {
agg.RemovedLabels = append(agg.RemovedLabels[:l], agg.RemovedLabels[l+1:]...)
return
}
}
if !slices.ContainsFunc(agg.AddedLabels, func(l *Label) bool { return l.ID == lbl.ID }) {
agg.AddedLabels = append(agg.AddedLabels, lbl)
}
}
// Remove a label from the aggregated list
func (agg *CommentAggregator) delLabel(lbl *Label) {
for l, agglbl := range agg.AddedLabels {
if agglbl.ID == lbl.ID {
agg.AddedLabels = append(agg.AddedLabels[:l], agg.AddedLabels[l+1:]...)
return
}
}
if !slices.ContainsFunc(agg.RemovedLabels, func(l *Label) bool { return l.ID == lbl.ID }) {
agg.RemovedLabels = append(agg.RemovedLabels, lbl)
}
}
// Add a review request to the aggregated list
func (agg *CommentAggregator) addReviewRequest(req RequestReviewTarget) {
reqid := req.ID()
reqty := req.Type()
for r, aggreq := range agg.RemovedRequestReview {
if (aggreq.ID() == reqid) && (aggreq.Type() == reqty) {
agg.RemovedRequestReview = append(agg.RemovedRequestReview[:r], agg.RemovedRequestReview[r+1:]...)
return
}
}
if !slices.ContainsFunc(agg.AddedRequestReview, func(r RequestReviewTarget) bool { return (r.ID() == reqid) && (r.Type() == reqty) }) {
agg.AddedRequestReview = append(agg.AddedRequestReview, req)
}
}
// Delete a review request from the aggregated list
func (agg *CommentAggregator) delReviewRequest(req RequestReviewTarget) {
reqid := req.ID()
reqty := req.Type()
for r, aggreq := range agg.AddedRequestReview {
if (aggreq.ID() == reqid) && (aggreq.Type() == reqty) {
agg.AddedRequestReview = append(agg.AddedRequestReview[:r], agg.AddedRequestReview[r+1:]...)
return
}
}
if !slices.ContainsFunc(agg.RemovedRequestReview, func(r RequestReviewTarget) bool { return (r.ID() == reqid) && (r.Type() == reqty) }) {
agg.RemovedRequestReview = append(agg.RemovedRequestReview, req)
}
}
// Check if anything has changed with this aggregated list of comments
func (agg *CommentAggregator) Changed() bool {
changed := false
changed = changed || (agg.IsClosed != agg.PrevClosed)
changed = changed || (len(agg.AddedLabels) > 0)
changed = changed || (len(agg.RemovedLabels) > 0)
changed = changed || (len(agg.AddedRequestReview) > 0)
changed = changed || (len(agg.RemovedRequestReview) > 0)
return changed
}
func (agg *CommentAggregator) OnlyLabelsChanged() bool {
l := (len(agg.AddedLabels) > 0) || (len(agg.RemovedLabels) > 0)
l = l && (len(agg.AddedRequestReview) == 0) && (len(agg.RemovedRequestReview) == 0)
l = l && (agg.PrevClosed == agg.IsClosed)
return l
}
func (agg *CommentAggregator) OnlyRequestReview() bool {
l := (len(agg.AddedRequestReview) > 0) || (len(agg.RemovedRequestReview) > 0)
l = l && (len(agg.AddedLabels) == 0) && (len(agg.RemovedLabels) == 0)
l = l && (agg.PrevClosed == agg.IsClosed)
return l
}
func (agg *CommentAggregator) OnlyClosedReopened() bool {
l := (agg.IsClosed != agg.PrevClosed)
l = l && (len(agg.AddedLabels) == 0) && (len(agg.RemovedLabels) == 0)
l = l && (len(agg.AddedRequestReview) == 0) && (len(agg.RemovedRequestReview) == 0)
return l
}
// Reset the aggregator to start a new aggregating context
func (agg *CommentAggregator) Reset(cur *Comment) {
agg.StartUnix = int64(cur.CreatedUnix)
agg.PosterID = cur.PosterID
agg.PrevClosed = agg.IsClosed
agg.Indexes = []int{}
agg.AddedLabels = []*Label{}
agg.RemovedLabels = []*Label{}
agg.AddedRequestReview = []RequestReviewTarget{}
agg.RemovedRequestReview = []RequestReviewTarget{}
}
// Function that replaces all the comments aggregated with a single one
// Its CommentType depend on whether multiple type of comments are been aggregated or not
// If nothing has changed, we remove all the comments that get nullified
//
// The function returns how many comments has been removed, in order for the "for" loop
// of the main algorithm to change its index
func (agg *CommentAggregator) createAggregatedComment(issue *Issue, final bool) int {
endind := agg.Indexes[len(agg.Indexes)-1]
startind := agg.Indexes[0]
// If the aggregation of comments make the whole thing null, erase all the comments
if !agg.Changed() {
if final {
issue.Comments = issue.Comments[:startind]
} else {
issue.Comments = append(issue.Comments[:startind], issue.Comments[endind+1:]...)
}
return endind - startind
}
newAgg := *agg // Trigger a memory allocation, get a COPY of the aggregator
// Keep the same author, time, etc... But reset the parts we may want to use
comment := issue.Comments[startind]
comment.Content = ""
comment.Label = nil
comment.Aggregator = nil
comment.Assignee = nil
comment.AssigneeID = -1
comment.AssigneeTeam = nil
comment.AssigneeTeamID = -1
comment.RemovedAssignee = false
comment.AddedLabels = nil
comment.RemovedLabels = nil
// In case there's only a single change, create a comment of this type
// instead of an aggregator
if agg.OnlyLabelsChanged() {
comment.Type = CommentTypeLabel
} else if agg.OnlyClosedReopened() {
if agg.IsClosed {
comment.Type = CommentTypeClose
} else {
comment.Type = CommentTypeReopen
}
} else if agg.OnlyRequestReview() {
comment.Type = CommentTypeReviewRequest
} else {
comment.Type = CommentTypeAggregator
comment.Aggregator = &newAgg
}
if len(newAgg.AddedLabels) > 0 {
comment.AddedLabels = newAgg.AddedLabels
}
if len(newAgg.RemovedLabels) > 0 {
comment.RemovedLabels = newAgg.RemovedLabels
}
if len(newAgg.AddedRequestReview) > 0 {
comment.AddedRequestReview = newAgg.AddedRequestReview
}
if len(newAgg.RemovedRequestReview) > 0 {
comment.RemovedRequestReview = newAgg.RemovedRequestReview
}
if final {
issue.Comments = append(issue.Comments[:startind], comment)
} else {
issue.Comments = append(append(issue.Comments[:startind], comment), issue.Comments[endind+1:]...)
}
return endind - startind
}
// combineCommentsHistory combines nearby elements in the history as one
func CombineCommentsHistory(issue *Issue) {
// Initialise a new empty aggregator, ready to combine comments
var agg CommentAggregator
agg.StartUnix = 0
agg.PosterID = -1
reqReset := false
for i := 0; i < len(issue.Comments); i++ {
cur := issue.Comments[i]
if reqReset {
agg.Reset(cur)
reqReset = false
}
// If the comment we encounter is not accepted inside an aggregator
if !agg.IsAggregated(&cur.Type) {
// If we aggregated some data, create the resulting comment for it
if len(agg.Indexes) > 0 {
i -= agg.createAggregatedComment(issue, false)
}
reqReset = true
// Do not need to continue the aggregation loop, skip to next comment
continue
}
// If the comment we encounter cannot be aggregated with the current aggregator,
// we create a new empty aggregator
if ((int64(cur.CreatedUnix) - agg.StartUnix) > 60) || (cur.PosterID != agg.PosterID) {
// First, create the aggregated comment if there's data in it
if len(agg.Indexes) > 0 {
i -= agg.createAggregatedComment(issue, false)
}
agg.Reset(cur)
}
agg.aggregateComment(cur, i)
}
// Create the aggregated comment if there's data in it
if !reqReset && len(agg.Indexes) > 0 {
agg.createAggregatedComment(issue, true)
}
}

1
release-notes/6523.md Normal file
View file

@ -0,0 +1 @@
Reduce noise in issue / PR comments. If performed in under 60 secs, cancel out contradictory actions, aggregate many actions under the same comment

View file

@ -1831,8 +1831,7 @@ func ViewIssue(ctx *context.Context) {
ctx.Data["LatestCloseCommentID"] = latestCloseCommentID ctx.Data["LatestCloseCommentID"] = latestCloseCommentID
// Combine multiple label assignments into a single comment // Combine multiple label assignments into a single comment
combineLabelComments(issue) issues_model.CombineCommentsHistory(issue)
combineRequestReviewComments(issue)
getBranchData(ctx, issue) getBranchData(ctx, issue)
if issue.IsPull { if issue.IsPull {
@ -3693,194 +3692,6 @@ func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment,
return attachHTML return attachHTML
} }
type RequestReviewTarget struct {
user *user_model.User
team *organization.Team
}
func (t *RequestReviewTarget) ID() int64 {
if t.user != nil {
return t.user.ID
}
return t.team.ID
}
func (t *RequestReviewTarget) Name() string {
if t.user != nil {
return t.user.GetDisplayName()
}
return t.team.Name
}
func (t *RequestReviewTarget) Type() string {
if t.user != nil {
return "user"
}
return "team"
}
// combineRequestReviewComments combine the nearby request review comments as one.
func combineRequestReviewComments(issue *issues_model.Issue) {
var prev, cur *issues_model.Comment
for i := 0; i < len(issue.Comments); i++ {
cur = issue.Comments[i]
if i > 0 {
prev = issue.Comments[i-1]
}
if i == 0 || cur.Type != issues_model.CommentTypeReviewRequest ||
(prev != nil && prev.PosterID != cur.PosterID) ||
(prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) {
if cur.Type == issues_model.CommentTypeReviewRequest && (cur.Assignee != nil || cur.AssigneeTeam != nil) {
if cur.RemovedAssignee {
if cur.AssigneeTeam != nil {
cur.RemovedRequestReview = append(cur.RemovedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam})
} else {
cur.RemovedRequestReview = append(cur.RemovedRequestReview, &RequestReviewTarget{user: cur.Assignee})
}
} else {
if cur.AssigneeTeam != nil {
cur.AddedRequestReview = append(cur.AddedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam})
} else {
cur.AddedRequestReview = append(cur.AddedRequestReview, &RequestReviewTarget{user: cur.Assignee})
}
}
}
continue
}
// Previous comment is not a review request, so cannot group. Start a new group.
if prev.Type != issues_model.CommentTypeReviewRequest {
if cur.RemovedAssignee {
if cur.AssigneeTeam != nil {
cur.RemovedRequestReview = append(cur.RemovedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam})
} else {
cur.RemovedRequestReview = append(cur.RemovedRequestReview, &RequestReviewTarget{user: cur.Assignee})
}
} else {
if cur.AssigneeTeam != nil {
cur.AddedRequestReview = append(cur.AddedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam})
} else {
cur.AddedRequestReview = append(cur.AddedRequestReview, &RequestReviewTarget{user: cur.Assignee})
}
}
continue
}
// Start grouping.
if cur.RemovedAssignee {
addedIndex := slices.IndexFunc(prev.AddedRequestReview, func(t issues_model.RequestReviewTarget) bool {
if cur.AssigneeTeam != nil {
return cur.AssigneeTeam.ID == t.ID() && t.Type() == "team"
}
return cur.Assignee.ID == t.ID() && t.Type() == "user"
})
// If for this target a AddedRequestReview, then we remove that entry. If it's not found, then add it to the RemovedRequestReview.
if addedIndex == -1 {
if cur.AssigneeTeam != nil {
prev.RemovedRequestReview = append(prev.RemovedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam})
} else {
prev.RemovedRequestReview = append(prev.RemovedRequestReview, &RequestReviewTarget{user: cur.Assignee})
}
} else {
prev.AddedRequestReview = slices.Delete(prev.AddedRequestReview, addedIndex, addedIndex+1)
}
} else {
removedIndex := slices.IndexFunc(prev.RemovedRequestReview, func(t issues_model.RequestReviewTarget) bool {
if cur.AssigneeTeam != nil {
return cur.AssigneeTeam.ID == t.ID() && t.Type() == "team"
}
return cur.Assignee.ID == t.ID() && t.Type() == "user"
})
// If for this target a RemovedRequestReview, then we remove that entry. If it's not found, then add it to the AddedRequestReview.
if removedIndex == -1 {
if cur.AssigneeTeam != nil {
prev.AddedRequestReview = append(prev.AddedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam})
} else {
prev.AddedRequestReview = append(prev.AddedRequestReview, &RequestReviewTarget{user: cur.Assignee})
}
} else {
prev.RemovedRequestReview = slices.Delete(prev.RemovedRequestReview, removedIndex, removedIndex+1)
}
}
// Propagate creation time.
prev.CreatedUnix = cur.CreatedUnix
// Remove the current comment since it has been combined to prev comment
issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...)
i--
}
}
// combineLabelComments combine the nearby label comments as one.
func combineLabelComments(issue *issues_model.Issue) {
var prev, cur *issues_model.Comment
for i := 0; i < len(issue.Comments); i++ {
cur = issue.Comments[i]
if i > 0 {
prev = issue.Comments[i-1]
}
if i == 0 || cur.Type != issues_model.CommentTypeLabel ||
(prev != nil && prev.PosterID != cur.PosterID) ||
(prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) {
if cur.Type == issues_model.CommentTypeLabel && cur.Label != nil {
if cur.Content != "1" {
cur.RemovedLabels = append(cur.RemovedLabels, cur.Label)
} else {
cur.AddedLabels = append(cur.AddedLabels, cur.Label)
}
}
continue
}
if cur.Label != nil { // now cur MUST be label comment
if prev.Type == issues_model.CommentTypeLabel { // we can combine them only prev is a label comment
if cur.Content != "1" {
// remove labels from the AddedLabels list if the label that was removed is already
// in this list, and if it's not in this list, add the label to RemovedLabels
addedAndRemoved := false
for i, label := range prev.AddedLabels {
if cur.Label.ID == label.ID {
prev.AddedLabels = append(prev.AddedLabels[:i], prev.AddedLabels[i+1:]...)
addedAndRemoved = true
break
}
}
if !addedAndRemoved {
prev.RemovedLabels = append(prev.RemovedLabels, cur.Label)
}
} else {
// remove labels from the RemovedLabels list if the label that was added is already
// in this list, and if it's not in this list, add the label to AddedLabels
removedAndAdded := false
for i, label := range prev.RemovedLabels {
if cur.Label.ID == label.ID {
prev.RemovedLabels = append(prev.RemovedLabels[:i], prev.RemovedLabels[i+1:]...)
removedAndAdded = true
break
}
}
if !removedAndAdded {
prev.AddedLabels = append(prev.AddedLabels, cur.Label)
}
}
prev.CreatedUnix = cur.CreatedUnix
// remove the current comment since it has been combined to prev comment
issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...)
i--
} else { // if prev is not a label comment, start a new group
if cur.Content != "1" {
cur.RemovedLabels = append(cur.RemovedLabels, cur.Label)
} else {
cur.AddedLabels = append(cur.AddedLabels, cur.Label)
}
}
}
}
}
// get all teams that current user can mention // get all teams that current user can mention
func handleTeamMentions(ctx *context.Context) { func handleTeamMentions(ctx *context.Context) {
if ctx.Doer == nil || !ctx.Repo.Owner.IsOrganization() { if ctx.Doer == nil || !ctx.Repo.Owner.IsOrganization() {

File diff suppressed because it is too large Load diff

View file

@ -524,7 +524,7 @@
</div> </div>
</div> </div>
{{else if eq .Type 27}} {{else if eq .Type 27}}
{{if or .AddedRequestReview .RemovedRequestReview}} {{if or .AddedRequestReview .RemovedRequestReview}}
<div class="timeline-item event" id="{{.HashTag}}"> <div class="timeline-item event" id="{{.HashTag}}">
<span class="badge">{{svg "octicon-tag"}}</span> <span class="badge">{{svg "octicon-tag"}}</span>
{{template "shared/user/avatarlink" dict "user" .Poster}} {{template "shared/user/avatarlink" dict "user" .Poster}}
@ -540,7 +540,7 @@
{{end}} {{end}}
</span> </span>
</div> </div>
{{end}} {{end}}
{{else if and (eq .Type 29) (or (gt .CommitsNum 0) .IsForcePush)}} {{else if and (eq .Type 29) (or (gt .CommitsNum 0) .IsForcePush)}}
<!-- If PR is closed, the comments whose type is CommentTypePullRequestPush(29) after latestCloseCommentID won't be rendered. //--> <!-- If PR is closed, the comments whose type is CommentTypePullRequestPush(29) after latestCloseCommentID won't be rendered. //-->
{{if and .Issue.IsClosed (gt .ID $.LatestCloseCommentID)}} {{if and .Issue.IsClosed (gt .ID $.LatestCloseCommentID)}}
@ -676,6 +676,73 @@
{{else}}{{ctx.Locale.Tr "repo.issues.unpin_comment" $createdStr}}{{end}} {{else}}{{ctx.Locale.Tr "repo.issues.unpin_comment" $createdStr}}{{end}}
</span> </span>
</div> </div>
{{else if eq .Type 38}}
<div class="timeline-item event" id="{{.HashTag}}">
<span class="badge">{{svg "octicon-list-unordered" 16}}</span>
{{template "shared/user/avatarlink" dict "user" .Poster}}
<span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}}
{{$createdStr}}
<ul class="comment-aggregator">
<!-- OPEN / CLOSE -->
{{if and .Aggregator.PrevClosed (not .Aggregator.IsClosed)}}
<li>
<span class="badge tw-bg-green tw-text-white">{{svg "octicon-dot-fill"}}</span>
{{if .Issue.IsPull}}
{{ctx.Locale.Tr "repo.pulls.reopened_at" "" ""}}
{{else}}
{{ctx.Locale.Tr "repo.issues.reopened_at" "" ""}}
{{end}}
</li>
{{else if and (not .Aggregator.PrevClosed) .Aggregator.IsClosed}}
<span class="badge tw-bg-red tw-text-white">{{svg "octicon-circle-slash"}}</span>
<li>
{{if .Issue.IsPull}}
{{ctx.Locale.Tr "repo.pulls.closed_at" "" ""}}
{{else}}
{{ctx.Locale.Tr "repo.issues.closed_at" "" ""}}
{{end}}
</li>
{{end}}
<!-- Add labels -->
{{if or .AddedLabels .RemovedLabels}}
<li>
<span class="badge">{{svg "octicon-tag" 20}}</span>
{{if and .AddedLabels (not .RemovedLabels)}}
{{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink .Issue.IsPull) ""}}
{{else if and (not .AddedLabels) .RemovedLabels}}
{{ctx.Locale.TrN (len .RemovedLabels) "repo.issues.remove_label" "repo.issues.remove_labels" (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink .Issue.IsPull) ""}}
{{else}}
{{ctx.Locale.Tr "repo.issues.add_remove_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink .Issue.IsPull) (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink .Issue.IsPull) ""}}
{{end}}
</li>
{{end}}
{{if or .AddedRequestReview .RemovedRequestReview}}
<li>
<span class="badge">{{svg "octicon-tag" 20}}</span>
<span class="text grey muted-links">
{{if and (eq (len .RemovedRequestReview) 1) (eq (len .AddedRequestReview) 0) (eq ((index .RemovedRequestReview 0).ID) .PosterID) (eq ((index .RemovedRequestReview 0).Type) "user")}}
<span class="review-request-list">{{ctx.Locale.Tr "repo.issues.review.remove_review_request_self" ""}}</span>
{{else if and .AddedRequestReview (not .RemovedRequestReview)}}
{{ctx.Locale.TrN (len .AddedRequestReview) "repo.issues.review.add_review_request" "repo.issues.review.add_review_requests" (RenderReviewRequest .AddedRequestReview) ""}}
{{else if and (not .AddedRequestReview) .RemovedRequestReview}}
{{ctx.Locale.TrN (len .RemovedRequestReview) "repo.issues.review.remove_review_request" "repo.issues.review.remove_review_requests" (RenderReviewRequest .RemovedRequestReview) ""}}
{{else}}
{{ctx.Locale.Tr "repo.issues.review.add_remove_review_requests" (RenderReviewRequest .AddedRequestReview) (RenderReviewRequest .RemovedRequestReview) ""}}
{{end}}
</span>
</li>
{{end}}
</ul>
</span>
</div>
{{end}} {{end}}
{{end}} {{end}}
{{end}} {{end}}

View file

@ -840,6 +840,22 @@ td .commit-summary {
margin-right: 0.25em; margin-right: 0.25em;
} }
.comment-aggregator {
list-style-type: none;
}
.repository.view.issue .comment-list .timeline-item .comment-aggregator .badge {
width: 20px;
height: 20px;
margin-top: 5px;
padding: 12px;
}
.repository.view.issue .comment-list .timeline-item .comment-aggregator .badge .svg {
width: 20px;
height: 20px;
}
.singular-commit { .singular-commit {
display: flex; display: flex;
align-items: center; align-items: center;