loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): create a comment aggregator to reduce noise in issues (#6523)

Closes: https://codeberg.org/forgejo/forgejo/issues/6042
Continuation of: https://codeberg.org/forgejo/forgejo/pulls/6284
Replaces: https://codeberg.org/forgejo/forgejo/pulls/6285
Context: https://codeberg.org/forgejo/forgejo/pulls/6284#issuecomment-2518599

Create a new type of comment: `CommentTypeAggregator`

Replaces the grouping of labels and review request in a single place: the comment aggregator

The whole list of comments is "scanned", if they can get aggregated (diff of time < 60secs, same poster, open / close issue, add / del labels, add /del review req), they are added to the aggregator.
Once needed, the list of all the aggregated comments are replaced with a single aggregated comment containing all the data required.

In templates, have a specific HTML rendering part for the comment aggregator, reuse the same rendering as with the other types of comments.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6523
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-by: Otto <otto@codeberg.org>
Co-authored-by: Litchi Pi <litchi.pi@proton.me>
Co-committed-by: Litchi Pi <litchi.pi@proton.me>

authored by

Litchi Pi
Litchi Pi
and committed by
0ko
dc7f5d6b 2c27a0f7

+1264 -1006
+375
models/issues/action_aggregator.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: GPL-3.0-or-later 3 + 4 + package issues 5 + 6 + import ( 7 + "slices" 8 + 9 + "code.gitea.io/gitea/models/organization" 10 + user_model "code.gitea.io/gitea/models/user" 11 + ) 12 + 13 + type ActionAggregator struct { 14 + StartUnix int64 15 + AggAge int64 16 + PosterID int64 17 + StartInd int 18 + EndInd int 19 + 20 + PrevClosed bool 21 + IsClosed bool 22 + 23 + AddedLabels []*Label 24 + RemovedLabels []*Label 25 + 26 + AddedRequestReview []RequestReviewTarget 27 + RemovedRequestReview []RequestReviewTarget 28 + } 29 + 30 + // Get the time threshold for aggregation of multiple actions together 31 + func (agg *ActionAggregator) timeThreshold() int64 { 32 + if agg.AggAge > (60 * 60 * 24 * 30) { // Age > 1 month, aggregate by day 33 + return 60 * 60 * 24 34 + } else if agg.AggAge > (60 * 60 * 24) { // Age > 1 day, aggregate by hour 35 + return 60 * 60 36 + } else if agg.AggAge > (60 * 60) { // Age > 1 hour, aggregate by 10 mins 37 + return 60 * 10 38 + } 39 + // Else, aggregate by minute 40 + return 60 41 + } 42 + 43 + // TODO Aggregate also 44 + // - Dependency added / removed 45 + // - Added / Removed due date 46 + // - Milestone Added / Removed 47 + func (agg *ActionAggregator) aggregateAction(c *Comment, index int) { 48 + if agg.StartInd == -1 { 49 + agg.StartInd = index 50 + } 51 + agg.EndInd = index 52 + 53 + if c.Type == CommentTypeClose { 54 + agg.IsClosed = true 55 + } else if c.Type == CommentTypeReopen { 56 + agg.IsClosed = false 57 + } else if c.Type == CommentTypeReviewRequest { 58 + if c.AssigneeID > 0 { 59 + req := RequestReviewTarget{User: c.Assignee} 60 + if c.RemovedAssignee { 61 + agg.delReviewRequest(req) 62 + } else { 63 + agg.addReviewRequest(req) 64 + } 65 + } else if c.AssigneeTeamID > 0 { 66 + req := RequestReviewTarget{Team: c.AssigneeTeam} 67 + if c.RemovedAssignee { 68 + agg.delReviewRequest(req) 69 + } else { 70 + agg.addReviewRequest(req) 71 + } 72 + } 73 + 74 + for _, r := range c.RemovedRequestReview { 75 + agg.delReviewRequest(r) 76 + } 77 + 78 + for _, r := range c.AddedRequestReview { 79 + agg.addReviewRequest(r) 80 + } 81 + } else if c.Type == CommentTypeLabel { 82 + if c.Content == "1" { 83 + agg.addLabel(c.Label) 84 + } else { 85 + agg.delLabel(c.Label) 86 + } 87 + } else if c.Type == CommentTypeAggregator { 88 + agg.Merge(c.Aggregator) 89 + } 90 + } 91 + 92 + // Merge a past CommentAggregator with the next one in the issue comments list 93 + func (agg *ActionAggregator) Merge(next *ActionAggregator) { 94 + agg.IsClosed = next.IsClosed 95 + 96 + for _, l := range next.AddedLabels { 97 + agg.addLabel(l) 98 + } 99 + 100 + for _, l := range next.RemovedLabels { 101 + agg.delLabel(l) 102 + } 103 + 104 + for _, r := range next.AddedRequestReview { 105 + agg.addReviewRequest(r) 106 + } 107 + 108 + for _, r := range next.RemovedRequestReview { 109 + agg.delReviewRequest(r) 110 + } 111 + } 112 + 113 + // Check if a comment can be aggregated or not depending on its type 114 + func (agg *ActionAggregator) IsAggregated(t *CommentType) bool { 115 + switch *t { 116 + case CommentTypeAggregator, CommentTypeClose, CommentTypeReopen, CommentTypeLabel, CommentTypeReviewRequest: 117 + { 118 + return true 119 + } 120 + default: 121 + { 122 + return false 123 + } 124 + } 125 + } 126 + 127 + // Add a label to the aggregated list 128 + func (agg *ActionAggregator) addLabel(lbl *Label) { 129 + for l, agglbl := range agg.RemovedLabels { 130 + if agglbl.ID == lbl.ID { 131 + agg.RemovedLabels = slices.Delete(agg.RemovedLabels, l, l+1) 132 + return 133 + } 134 + } 135 + 136 + if !slices.ContainsFunc(agg.AddedLabels, func(l *Label) bool { return l.ID == lbl.ID }) { 137 + agg.AddedLabels = append(agg.AddedLabels, lbl) 138 + } 139 + } 140 + 141 + // Remove a label from the aggregated list 142 + func (agg *ActionAggregator) delLabel(lbl *Label) { 143 + for l, agglbl := range agg.AddedLabels { 144 + if agglbl.ID == lbl.ID { 145 + agg.AddedLabels = slices.Delete(agg.AddedLabels, l, l+1) 146 + return 147 + } 148 + } 149 + 150 + if !slices.ContainsFunc(agg.RemovedLabels, func(l *Label) bool { return l.ID == lbl.ID }) { 151 + agg.RemovedLabels = append(agg.RemovedLabels, lbl) 152 + } 153 + } 154 + 155 + // Add a review request to the aggregated list 156 + func (agg *ActionAggregator) addReviewRequest(req RequestReviewTarget) { 157 + reqid := req.ID() 158 + reqty := req.Type() 159 + for r, aggreq := range agg.RemovedRequestReview { 160 + if (aggreq.ID() == reqid) && (aggreq.Type() == reqty) { 161 + agg.RemovedRequestReview = slices.Delete(agg.RemovedRequestReview, r, r+1) 162 + return 163 + } 164 + } 165 + 166 + if !slices.ContainsFunc(agg.AddedRequestReview, func(r RequestReviewTarget) bool { return (r.ID() == reqid) && (r.Type() == reqty) }) { 167 + agg.AddedRequestReview = append(agg.AddedRequestReview, req) 168 + } 169 + } 170 + 171 + // Delete a review request from the aggregated list 172 + func (agg *ActionAggregator) delReviewRequest(req RequestReviewTarget) { 173 + reqid := req.ID() 174 + reqty := req.Type() 175 + for r, aggreq := range agg.AddedRequestReview { 176 + if (aggreq.ID() == reqid) && (aggreq.Type() == reqty) { 177 + agg.AddedRequestReview = slices.Delete(agg.AddedRequestReview, r, r+1) 178 + return 179 + } 180 + } 181 + 182 + if !slices.ContainsFunc(agg.RemovedRequestReview, func(r RequestReviewTarget) bool { return (r.ID() == reqid) && (r.Type() == reqty) }) { 183 + agg.RemovedRequestReview = append(agg.RemovedRequestReview, req) 184 + } 185 + } 186 + 187 + // Check if anything has changed with this aggregated list of comments 188 + func (agg *ActionAggregator) Changed() bool { 189 + return (agg.IsClosed != agg.PrevClosed) || 190 + (len(agg.AddedLabels) > 0) || 191 + (len(agg.RemovedLabels) > 0) || 192 + (len(agg.AddedRequestReview) > 0) || 193 + (len(agg.RemovedRequestReview) > 0) 194 + } 195 + 196 + func (agg *ActionAggregator) OnlyLabelsChanged() bool { 197 + return ((len(agg.AddedLabels) > 0) || (len(agg.RemovedLabels) > 0)) && 198 + (len(agg.AddedRequestReview) == 0) && (len(agg.RemovedRequestReview) == 0) && 199 + (agg.PrevClosed == agg.IsClosed) 200 + } 201 + 202 + func (agg *ActionAggregator) OnlyRequestReview() bool { 203 + return ((len(agg.AddedRequestReview) > 0) || (len(agg.RemovedRequestReview) > 0)) && 204 + (len(agg.AddedLabels) == 0) && (len(agg.RemovedLabels) == 0) && 205 + (agg.PrevClosed == agg.IsClosed) 206 + } 207 + 208 + func (agg *ActionAggregator) OnlyClosedReopened() bool { 209 + return (agg.IsClosed != agg.PrevClosed) && 210 + (len(agg.AddedLabels) == 0) && (len(agg.RemovedLabels) == 0) && 211 + (len(agg.AddedRequestReview) == 0) && (len(agg.RemovedRequestReview) == 0) 212 + } 213 + 214 + // Reset the aggregator to start a new aggregating context 215 + func (agg *ActionAggregator) Reset(cur *Comment, now int64) { 216 + agg.StartUnix = int64(cur.CreatedUnix) 217 + agg.AggAge = now - agg.StartUnix 218 + agg.PosterID = cur.PosterID 219 + 220 + agg.PrevClosed = agg.IsClosed 221 + 222 + agg.StartInd = -1 223 + agg.EndInd = -1 224 + agg.AddedLabels = []*Label{} 225 + agg.RemovedLabels = []*Label{} 226 + agg.AddedRequestReview = []RequestReviewTarget{} 227 + agg.RemovedRequestReview = []RequestReviewTarget{} 228 + } 229 + 230 + // Function that replaces all the comments aggregated with a single one 231 + // Its CommentType depend on whether multiple type of comments are been aggregated or not 232 + // If nothing has changed, we remove all the comments that get nullified 233 + // 234 + // The function returns how many comments has been removed, in order for the "for" loop 235 + // of the main algorithm to change its index 236 + func (agg *ActionAggregator) createAggregatedComment(issue *Issue, final bool) int { 237 + // If the aggregation of comments make the whole thing null, erase all the comments 238 + if !agg.Changed() { 239 + if final { 240 + issue.Comments = issue.Comments[:agg.StartInd] 241 + } else { 242 + issue.Comments = slices.Replace(issue.Comments, agg.StartInd, agg.EndInd+1) 243 + } 244 + return (agg.EndInd - agg.StartInd) + 1 245 + } 246 + 247 + newAgg := *agg // Trigger a memory allocation, get a COPY of the aggregator 248 + 249 + // Keep the same author, time, etc... But reset the parts we may want to use 250 + comment := issue.Comments[agg.StartInd] 251 + comment.Content = "" 252 + comment.Label = nil 253 + comment.Aggregator = nil 254 + comment.Assignee = nil 255 + comment.AssigneeID = 0 256 + comment.AssigneeTeam = nil 257 + comment.AssigneeTeamID = 0 258 + comment.RemovedAssignee = false 259 + comment.AddedLabels = nil 260 + comment.RemovedLabels = nil 261 + 262 + // In case there's only a single change, create a comment of this type 263 + // instead of an aggregator 264 + if agg.OnlyLabelsChanged() { 265 + comment.Type = CommentTypeLabel 266 + } else if agg.OnlyClosedReopened() { 267 + if agg.IsClosed { 268 + comment.Type = CommentTypeClose 269 + } else { 270 + comment.Type = CommentTypeReopen 271 + } 272 + } else if agg.OnlyRequestReview() { 273 + comment.Type = CommentTypeReviewRequest 274 + } else { 275 + comment.Type = CommentTypeAggregator 276 + comment.Aggregator = &newAgg 277 + } 278 + 279 + if len(newAgg.AddedLabels) > 0 { 280 + comment.AddedLabels = newAgg.AddedLabels 281 + } 282 + 283 + if len(newAgg.RemovedLabels) > 0 { 284 + comment.RemovedLabels = newAgg.RemovedLabels 285 + } 286 + 287 + if len(newAgg.AddedRequestReview) > 0 { 288 + comment.AddedRequestReview = newAgg.AddedRequestReview 289 + } 290 + 291 + if len(newAgg.RemovedRequestReview) > 0 { 292 + comment.RemovedRequestReview = newAgg.RemovedRequestReview 293 + } 294 + 295 + if final { 296 + issue.Comments = append(issue.Comments[:agg.StartInd], comment) 297 + } else { 298 + issue.Comments = slices.Replace(issue.Comments, agg.StartInd, agg.EndInd+1, comment) 299 + } 300 + return agg.EndInd - agg.StartInd 301 + } 302 + 303 + // combineCommentsHistory combines nearby elements in the history as one 304 + func CombineCommentsHistory(issue *Issue, now int64) { 305 + if len(issue.Comments) < 1 { 306 + return 307 + } 308 + 309 + // Initialise a new empty aggregator, ready to combine comments 310 + var agg ActionAggregator 311 + agg.Reset(issue.Comments[0], now) 312 + 313 + for i := 0; i < len(issue.Comments); i++ { 314 + cur := issue.Comments[i] 315 + // If the comment we encounter is not accepted inside an aggregator 316 + if !agg.IsAggregated(&cur.Type) { 317 + // If we aggregated some data, create the resulting comment for it 318 + if agg.StartInd != -1 { 319 + i -= agg.createAggregatedComment(issue, false) 320 + } 321 + 322 + agg.StartInd = -1 323 + if i+1 < len(issue.Comments) { 324 + agg.Reset(issue.Comments[i+1], now) 325 + } 326 + 327 + // Do not need to continue the aggregation loop, skip to next comment 328 + continue 329 + } 330 + 331 + // If the comment we encounter cannot be aggregated with the current aggregator, 332 + // we create a new empty aggregator 333 + threshold := agg.timeThreshold() 334 + if ((int64(cur.CreatedUnix) - agg.StartUnix) > threshold) || (cur.PosterID != agg.PosterID) { 335 + // First, create the aggregated comment if there's data in it 336 + if agg.StartInd != -1 { 337 + i -= agg.createAggregatedComment(issue, false) 338 + } 339 + agg.Reset(cur, now) 340 + } 341 + 342 + agg.aggregateAction(cur, i) 343 + } 344 + 345 + // Create the aggregated comment if there's data in it 346 + if agg.StartInd != -1 { 347 + agg.createAggregatedComment(issue, true) 348 + } 349 + } 350 + 351 + type RequestReviewTarget struct { 352 + User *user_model.User 353 + Team *organization.Team 354 + } 355 + 356 + func (t *RequestReviewTarget) ID() int64 { 357 + if t.User != nil { 358 + return t.User.ID 359 + } 360 + return t.Team.ID 361 + } 362 + 363 + func (t *RequestReviewTarget) Name() string { 364 + if t.User != nil { 365 + return t.User.GetDisplayName() 366 + } 367 + return t.Team.Name 368 + } 369 + 370 + func (t *RequestReviewTarget) Type() string { 371 + if t.User != nil { 372 + return "user" 373 + } 374 + return "team" 375 + }
+4 -6
models/issues/comment.go
··· 114 114 115 115 CommentTypePin // 36 pin Issue 116 116 CommentTypeUnpin // 37 unpin Issue 117 + 118 + CommentTypeAggregator // 38 Aggregator of comments 117 119 ) 118 120 119 121 var commentStrings = []string{ ··· 155 157 "pull_cancel_scheduled_merge", 156 158 "pin", 157 159 "unpin", 160 + "action_aggregator", 158 161 } 159 162 160 163 func (t CommentType) String() string { ··· 236 239 return lang.TrString("repo.issues.role." + string(r) + "_helper") 237 240 } 238 241 239 - type RequestReviewTarget interface { 240 - ID() int64 241 - Name() string 242 - Type() string 243 - } 244 - 245 242 // Comment represents a comment in commit and issue page. 246 243 type Comment struct { 247 244 ID int64 `xorm:"pk autoincr"` ··· 254 251 Issue *Issue `xorm:"-"` 255 252 LabelID int64 256 253 Label *Label `xorm:"-"` 254 + Aggregator *ActionAggregator `xorm:"-"` 257 255 AddedLabels []*Label `xorm:"-"` 258 256 RemovedLabels []*Label `xorm:"-"` 259 257 AddedRequestReview []RequestReviewTarget `xorm:"-"`
+1
release-notes/6523.md
··· 1 + Reduce noise in the timeline of issues and pull requests. If certain timeline events are performed within a certain timeframe of each other with no other events in between, they will be combined into a single timeline event, and any contradictory actions will be canceled and not displayed. The older the events, the wider the timeframe will become.
+800
routers/web/repo/action_aggregator_test.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: GPL-3.0-or-later 3 + 4 + package repo 5 + 6 + import ( 7 + "strings" 8 + "testing" 9 + 10 + issue_model "code.gitea.io/gitea/models/issues" 11 + "code.gitea.io/gitea/models/organization" 12 + user_model "code.gitea.io/gitea/models/user" 13 + "code.gitea.io/gitea/modules/timeutil" 14 + 15 + "github.com/stretchr/testify/assert" 16 + ) 17 + 18 + // *************** Helper functions for the tests *************** 19 + 20 + func testComment(t int64) *issue_model.Comment { 21 + return &issue_model.Comment{PosterID: 1, CreatedUnix: timeutil.TimeStamp(t)} 22 + } 23 + 24 + func nameToID(name string) int64 { 25 + var id int64 26 + for c, letter := range name { 27 + id += int64((c+1)*1000) * int64(letter) 28 + } 29 + return id 30 + } 31 + 32 + func createReqReviewTarget(name string) issue_model.RequestReviewTarget { 33 + if strings.HasSuffix(name, "-team") { 34 + team := createTeam(name) 35 + return issue_model.RequestReviewTarget{Team: &team} 36 + } 37 + user := createUser(name) 38 + return issue_model.RequestReviewTarget{User: &user} 39 + } 40 + 41 + func createUser(name string) user_model.User { 42 + return user_model.User{Name: name, ID: nameToID(name)} 43 + } 44 + 45 + func createTeam(name string) organization.Team { 46 + return organization.Team{Name: name, ID: nameToID(name)} 47 + } 48 + 49 + func createLabel(name string) issue_model.Label { 50 + return issue_model.Label{Name: name, ID: nameToID(name)} 51 + } 52 + 53 + func addLabel(t int64, name string) *issue_model.Comment { 54 + c := testComment(t) 55 + c.Type = issue_model.CommentTypeLabel 56 + c.Content = "1" 57 + lbl := createLabel(name) 58 + c.Label = &lbl 59 + c.AddedLabels = []*issue_model.Label{&lbl} 60 + return c 61 + } 62 + 63 + func delLabel(t int64, name string) *issue_model.Comment { 64 + c := addLabel(t, name) 65 + c.Content = "" 66 + c.RemovedLabels = c.AddedLabels 67 + c.AddedLabels = nil 68 + return c 69 + } 70 + 71 + func openOrClose(t int64, close bool) *issue_model.Comment { 72 + c := testComment(t) 73 + if close { 74 + c.Type = issue_model.CommentTypeClose 75 + } else { 76 + c.Type = issue_model.CommentTypeReopen 77 + } 78 + return c 79 + } 80 + 81 + func reqReview(t int64, name string, delReq bool) *issue_model.Comment { 82 + c := testComment(t) 83 + c.Type = issue_model.CommentTypeReviewRequest 84 + if strings.HasSuffix(name, "-team") { 85 + team := createTeam(name) 86 + c.AssigneeTeam = &team 87 + c.AssigneeTeamID = team.ID 88 + } else { 89 + user := createUser(name) 90 + c.Assignee = &user 91 + c.AssigneeID = user.ID 92 + } 93 + c.RemovedAssignee = delReq 94 + return c 95 + } 96 + 97 + func reqReviewList(t int64, del bool, names ...string) *issue_model.Comment { 98 + req := []issue_model.RequestReviewTarget{} 99 + for _, name := range names { 100 + req = append(req, createReqReviewTarget(name)) 101 + } 102 + cmnt := testComment(t) 103 + cmnt.Type = issue_model.CommentTypeReviewRequest 104 + if del { 105 + cmnt.RemovedRequestReview = req 106 + } else { 107 + cmnt.AddedRequestReview = req 108 + } 109 + return cmnt 110 + } 111 + 112 + func aggregatedComment(t int64, 113 + closed bool, 114 + addLabels []*issue_model.Label, 115 + delLabels []*issue_model.Label, 116 + addReqReview []issue_model.RequestReviewTarget, 117 + delReqReview []issue_model.RequestReviewTarget, 118 + ) *issue_model.Comment { 119 + cmnt := testComment(t) 120 + cmnt.Type = issue_model.CommentTypeAggregator 121 + cmnt.Aggregator = &issue_model.ActionAggregator{ 122 + IsClosed: closed, 123 + AddedLabels: addLabels, 124 + RemovedLabels: delLabels, 125 + AddedRequestReview: addReqReview, 126 + RemovedRequestReview: delReqReview, 127 + } 128 + if len(addLabels) > 0 { 129 + cmnt.AddedLabels = addLabels 130 + } 131 + if len(delLabels) > 0 { 132 + cmnt.RemovedLabels = delLabels 133 + } 134 + if len(addReqReview) > 0 { 135 + cmnt.AddedRequestReview = addReqReview 136 + } 137 + if len(delReqReview) > 0 { 138 + cmnt.RemovedRequestReview = delReqReview 139 + } 140 + return cmnt 141 + } 142 + 143 + func commentText(t int64, text string) *issue_model.Comment { 144 + c := testComment(t) 145 + c.Type = issue_model.CommentTypeComment 146 + c.Content = text 147 + return c 148 + } 149 + 150 + // **************************************************************** 151 + 152 + type testCase struct { 153 + name string 154 + beforeCombined []*issue_model.Comment 155 + afterCombined []*issue_model.Comment 156 + sameAfter bool 157 + timestampCombination int64 158 + } 159 + 160 + func (kase *testCase) doTest(t *testing.T) { 161 + issue := issue_model.Issue{Comments: kase.beforeCombined} 162 + 163 + var now int64 = -9223372036854775808 164 + for c := 0; c < len(kase.beforeCombined); c++ { 165 + assert.Greater(t, int64(kase.beforeCombined[c].CreatedUnix), now) 166 + now = int64(kase.beforeCombined[c].CreatedUnix) 167 + } 168 + 169 + if kase.timestampCombination != 0 { 170 + now = kase.timestampCombination 171 + } 172 + 173 + issue_model.CombineCommentsHistory(&issue, now) 174 + 175 + after := kase.afterCombined 176 + if kase.sameAfter { 177 + after = kase.beforeCombined 178 + } 179 + 180 + if len(after) != len(issue.Comments) { 181 + t.Logf("Expected %v comments, got %v", len(after), len(issue.Comments)) 182 + t.Logf("Comments got after combination:") 183 + for c := 0; c < len(issue.Comments); c++ { 184 + cmt := issue.Comments[c] 185 + t.Logf("%v %v %v\n", cmt.Type, cmt.CreatedUnix, cmt.Content) 186 + } 187 + assert.EqualValues(t, len(after), len(issue.Comments)) 188 + t.Fail() 189 + return 190 + } 191 + 192 + for c := 0; c < len(after); c++ { 193 + l := (after)[c] 194 + r := issue.Comments[c] 195 + 196 + // Ignore some inner data of the aggregator to facilitate testing 197 + if l.Type == issue_model.CommentTypeAggregator { 198 + r.Aggregator.StartUnix = 0 199 + r.Aggregator.PrevClosed = false 200 + r.Aggregator.PosterID = 0 201 + r.Aggregator.StartInd = 0 202 + r.Aggregator.EndInd = 0 203 + r.Aggregator.AggAge = 0 204 + } 205 + 206 + // We can safely ignore this if the rest matches 207 + if l.Type == issue_model.CommentTypeLabel { 208 + l.Label = nil 209 + l.Content = "" 210 + } else if l.Type == issue_model.CommentTypeReviewRequest { 211 + l.Assignee = nil 212 + l.AssigneeID = 0 213 + l.AssigneeTeam = nil 214 + l.AssigneeTeamID = 0 215 + } 216 + 217 + assert.EqualValues(t, (after)[c], issue.Comments[c], 218 + "Comment %v is not equal", c, 219 + ) 220 + } 221 + } 222 + 223 + // **************** Start of the tests ****************** 224 + 225 + func TestCombineLabelComments(t *testing.T) { 226 + var tmon int64 = 60 * 60 * 24 * 30 227 + var tday int64 = 60 * 60 * 24 228 + var thour int64 = 60 * 60 229 + kases := []testCase{ 230 + // ADD single = normal label comment 231 + { 232 + name: "add_single_label", 233 + beforeCombined: []*issue_model.Comment{ 234 + addLabel(0, "a"), 235 + commentText(10, "I'm a salmon"), 236 + }, 237 + sameAfter: true, 238 + }, 239 + 240 + // ADD then REMOVE = Nothing 241 + { 242 + name: "add_label_then_remove", 243 + beforeCombined: []*issue_model.Comment{ 244 + addLabel(0, "a"), 245 + delLabel(1, "a"), 246 + commentText(65, "I'm a salmon"), 247 + }, 248 + afterCombined: []*issue_model.Comment{ 249 + commentText(65, "I'm a salmon"), 250 + }, 251 + }, 252 + 253 + // ADD 1 then comment then REMOVE = separate comments 254 + { 255 + name: "add_label_then_comment_then_remove", 256 + beforeCombined: []*issue_model.Comment{ 257 + addLabel(0, "a"), 258 + commentText(10, "I'm a salmon"), 259 + delLabel(20, "a"), 260 + }, 261 + sameAfter: true, 262 + }, 263 + 264 + // ADD 2 = Combined labels 265 + { 266 + name: "combine_labels", 267 + beforeCombined: []*issue_model.Comment{ 268 + addLabel(0, "a"), 269 + addLabel(10, "b"), 270 + commentText(20, "I'm a salmon"), 271 + addLabel(30, "c"), 272 + addLabel(80, "d"), 273 + addLabel(85, "e"), 274 + delLabel(90, "c"), 275 + }, 276 + afterCombined: []*issue_model.Comment{ 277 + { 278 + PosterID: 1, 279 + Type: issue_model.CommentTypeLabel, 280 + CreatedUnix: timeutil.TimeStamp(0), 281 + AddedLabels: []*issue_model.Label{ 282 + {Name: "a", ID: nameToID("a")}, 283 + {Name: "b", ID: nameToID("b")}, 284 + }, 285 + }, 286 + commentText(20, "I'm a salmon"), 287 + { 288 + PosterID: 1, 289 + Type: issue_model.CommentTypeLabel, 290 + CreatedUnix: timeutil.TimeStamp(30), 291 + AddedLabels: []*issue_model.Label{ 292 + {Name: "d", ID: nameToID("d")}, 293 + {Name: "e", ID: nameToID("e")}, 294 + }, 295 + }, 296 + }, 297 + }, 298 + 299 + // ADD 1, then 1 later = 2 separate comments 300 + { 301 + name: "add_then_later_label", 302 + beforeCombined: []*issue_model.Comment{ 303 + addLabel(0, "a"), 304 + addLabel(60, "b"), 305 + addLabel(121, "c"), 306 + }, 307 + afterCombined: []*issue_model.Comment{ 308 + { 309 + PosterID: 1, 310 + Type: issue_model.CommentTypeLabel, 311 + CreatedUnix: timeutil.TimeStamp(0), 312 + AddedLabels: []*issue_model.Label{ 313 + {Name: "a", ID: nameToID("a")}, 314 + {Name: "b", ID: nameToID("b")}, 315 + }, 316 + }, 317 + addLabel(121, "c"), 318 + }, 319 + }, 320 + 321 + // ADD 2 then REMOVE 1 = label 322 + { 323 + name: "add_2_remove_1", 324 + beforeCombined: []*issue_model.Comment{ 325 + addLabel(0, "a"), 326 + addLabel(10, "b"), 327 + delLabel(20, "a"), 328 + }, 329 + afterCombined: []*issue_model.Comment{ 330 + // The timestamp will be the one of the first aggregated comment 331 + addLabel(0, "b"), 332 + }, 333 + }, 334 + 335 + // ADD then REMOVE multiple = nothing 336 + { 337 + name: "add_multiple_remove_all", 338 + beforeCombined: []*issue_model.Comment{ 339 + addLabel(0, "a"), 340 + addLabel(1, "b"), 341 + addLabel(2, "c"), 342 + addLabel(3, "d"), 343 + addLabel(4, "e"), 344 + delLabel(5, "d"), 345 + delLabel(6, "a"), 346 + delLabel(7, "e"), 347 + delLabel(8, "c"), 348 + delLabel(9, "b"), 349 + }, 350 + afterCombined: nil, 351 + }, 352 + 353 + // ADD 2, wait, REMOVE 2 = +2 then -2 comments 354 + { 355 + name: "add2_wait_rm2_labels", 356 + beforeCombined: []*issue_model.Comment{ 357 + addLabel(0, "a"), 358 + addLabel(1, "b"), 359 + delLabel(120, "a"), 360 + delLabel(121, "b"), 361 + }, 362 + afterCombined: []*issue_model.Comment{ 363 + { 364 + PosterID: 1, 365 + Type: issue_model.CommentTypeLabel, 366 + CreatedUnix: timeutil.TimeStamp(0), 367 + AddedLabels: []*issue_model.Label{ 368 + {Name: "a", ID: nameToID("a")}, 369 + {Name: "b", ID: nameToID("b")}, 370 + }, 371 + }, 372 + { 373 + PosterID: 1, 374 + Type: issue_model.CommentTypeLabel, 375 + CreatedUnix: timeutil.TimeStamp(120), 376 + RemovedLabels: []*issue_model.Label{ 377 + {Name: "a", ID: nameToID("a")}, 378 + {Name: "b", ID: nameToID("b")}, 379 + }, 380 + }, 381 + }, 382 + }, 383 + 384 + // Regression check on edge case 385 + { 386 + name: "regression_edgecase_finalagg", 387 + beforeCombined: []*issue_model.Comment{ 388 + commentText(0, "hey"), 389 + commentText(1, "ho"), 390 + addLabel(2, "a"), 391 + addLabel(3, "b"), 392 + delLabel(4, "a"), 393 + delLabel(5, "b"), 394 + 395 + addLabel(120, "a"), 396 + 397 + addLabel(220, "c"), 398 + addLabel(221, "d"), 399 + addLabel(222, "e"), 400 + delLabel(223, "d"), 401 + 402 + delLabel(400, "a"), 403 + }, 404 + afterCombined: []*issue_model.Comment{ 405 + commentText(0, "hey"), 406 + commentText(1, "ho"), 407 + addLabel(120, "a"), 408 + { 409 + PosterID: 1, 410 + Type: issue_model.CommentTypeLabel, 411 + CreatedUnix: timeutil.TimeStamp(220), 412 + AddedLabels: []*issue_model.Label{ 413 + {Name: "c", ID: nameToID("c")}, 414 + {Name: "e", ID: nameToID("e")}, 415 + }, 416 + }, 417 + delLabel(400, "a"), 418 + }, 419 + }, 420 + 421 + { 422 + name: "combine_label_high_timestamp_separated", 423 + timestampCombination: tmon + 1, 424 + beforeCombined: []*issue_model.Comment{ 425 + // 1 month old, comments separated by 1 Day + 1 sec (not agg) 426 + addLabel(0, "d"), 427 + delLabel(tday+1, "d"), 428 + 429 + // 1 day old, comments separated by 1 hour + 1 sec (not agg) 430 + addLabel((tmon-tday)-thour, "c"), 431 + delLabel((tmon-tday)+1, "c"), 432 + 433 + // 1 hour old, comments separated by 10 mins + 1 sec (not agg) 434 + addLabel(tmon-thour, "b"), 435 + delLabel((tmon-(50*60))+1, "b"), 436 + 437 + // Else, aggregate by minute 438 + addLabel(tmon-61, "a"), 439 + delLabel(tmon, "a"), 440 + }, 441 + sameAfter: true, 442 + }, 443 + 444 + // Test higher timestamp diff 445 + { 446 + name: "combine_label_high_timestamp_merged", 447 + timestampCombination: tmon + 1, 448 + beforeCombined: []*issue_model.Comment{ 449 + // 1 month old, comments separated by 1 Day (aggregated) 450 + addLabel(0, "d"), 451 + delLabel(tday, "d"), 452 + 453 + // 1 day old, comments separated by 1 hour (aggregated) 454 + addLabel((tmon-tday)-thour, "c"), 455 + delLabel(tmon-tday, "c"), 456 + 457 + // 1 hour old, comments separated by 10 mins (aggregated) 458 + addLabel(tmon-thour, "b"), 459 + delLabel(tmon-(50*60), "b"), 460 + 461 + addLabel(tmon-60, "a"), 462 + delLabel(tmon, "a"), 463 + }, 464 + }, 465 + } 466 + 467 + for _, kase := range kases { 468 + t.Run(kase.name, kase.doTest) 469 + } 470 + } 471 + 472 + func TestCombineReviewRequests(t *testing.T) { 473 + kases := []testCase{ 474 + // ADD single = normal request review comment 475 + { 476 + name: "add_single_review", 477 + beforeCombined: []*issue_model.Comment{ 478 + reqReview(0, "toto", false), 479 + commentText(10, "I'm a salmon"), 480 + reqReview(20, "toto-team", false), 481 + }, 482 + sameAfter: true, 483 + }, 484 + 485 + // ADD then REMOVE = Nothing 486 + { 487 + name: "add_then_remove_review", 488 + beforeCombined: []*issue_model.Comment{ 489 + reqReview(0, "toto", false), 490 + reqReview(5, "toto", true), 491 + commentText(10, "I'm a salmon"), 492 + }, 493 + afterCombined: []*issue_model.Comment{ 494 + commentText(10, "I'm a salmon"), 495 + }, 496 + }, 497 + 498 + // ADD 1 then comment then REMOVE = separate comments 499 + { 500 + name: "add_comment_del_review", 501 + beforeCombined: []*issue_model.Comment{ 502 + reqReview(0, "toto", false), 503 + commentText(5, "I'm a salmon"), 504 + reqReview(10, "toto", true), 505 + }, 506 + sameAfter: true, 507 + }, 508 + 509 + // ADD 2 = Combined request reviews 510 + { 511 + name: "combine_reviews", 512 + beforeCombined: []*issue_model.Comment{ 513 + reqReview(0, "toto", false), 514 + reqReview(10, "tutu-team", false), 515 + commentText(20, "I'm a salmon"), 516 + reqReview(30, "titi", false), 517 + reqReview(80, "tata", false), 518 + reqReview(85, "tyty-team", false), 519 + reqReview(90, "titi", true), 520 + }, 521 + afterCombined: []*issue_model.Comment{ 522 + reqReviewList(0, false, "toto", "tutu-team"), 523 + commentText(20, "I'm a salmon"), 524 + reqReviewList(30, false, "tata", "tyty-team"), 525 + }, 526 + }, 527 + 528 + // ADD 1, then 1 later = 2 separate comments 529 + { 530 + name: "add_then_later_review", 531 + beforeCombined: []*issue_model.Comment{ 532 + reqReview(0, "titi", false), 533 + reqReview(60, "toto-team", false), 534 + reqReview(121, "tutu", false), 535 + }, 536 + afterCombined: []*issue_model.Comment{ 537 + reqReviewList(0, false, "titi", "toto-team"), 538 + reqReviewList(121, false, "tutu"), 539 + }, 540 + }, 541 + 542 + // ADD 2 then REMOVE 1 = single request review 543 + { 544 + name: "add_2_then_remove_review", 545 + beforeCombined: []*issue_model.Comment{ 546 + reqReview(0, "titi-team", false), 547 + reqReview(59, "toto", false), 548 + reqReview(60, "titi-team", true), 549 + }, 550 + afterCombined: []*issue_model.Comment{ 551 + reqReviewList(0, false, "toto"), 552 + }, 553 + }, 554 + 555 + // ADD then REMOVE multiple = nothing 556 + { 557 + name: "add_multiple_then_remove_all_review", 558 + beforeCombined: []*issue_model.Comment{ 559 + reqReview(0, "titi0-team", false), 560 + reqReview(1, "toto1", false), 561 + reqReview(2, "titi2", false), 562 + reqReview(3, "titi3-team", false), 563 + reqReview(4, "titi4", false), 564 + reqReview(5, "titi5", false), 565 + reqReview(6, "titi6-team", false), 566 + reqReview(10, "titi0-team", true), 567 + reqReview(11, "toto1", true), 568 + reqReview(12, "titi2", true), 569 + reqReview(13, "titi3-team", true), 570 + reqReview(14, "titi4", true), 571 + reqReview(15, "titi5", true), 572 + reqReview(16, "titi6-team", true), 573 + }, 574 + afterCombined: nil, 575 + }, 576 + 577 + // ADD 2, wait, REMOVE 2 = +2 then -2 comments 578 + { 579 + name: "add2_wait_rm2_requests", 580 + beforeCombined: []*issue_model.Comment{ 581 + reqReview(1, "titi", false), 582 + reqReview(2, "toto-team", false), 583 + reqReview(121, "titi", true), 584 + reqReview(122, "toto-team", true), 585 + }, 586 + afterCombined: []*issue_model.Comment{ 587 + reqReviewList(1, false, "titi", "toto-team"), 588 + reqReviewList(121, true, "titi", "toto-team"), 589 + }, 590 + }, 591 + } 592 + 593 + for _, kase := range kases { 594 + t.Run(kase.name, kase.doTest) 595 + } 596 + } 597 + 598 + func TestCombineOpenClose(t *testing.T) { 599 + kases := []testCase{ 600 + // Close then open = nullified 601 + { 602 + name: "close_open_nullified", 603 + beforeCombined: []*issue_model.Comment{ 604 + openOrClose(0, true), 605 + openOrClose(10, false), 606 + }, 607 + afterCombined: nil, 608 + }, 609 + 610 + // Close then open later = separate comments 611 + { 612 + name: "close_open_later", 613 + beforeCombined: []*issue_model.Comment{ 614 + openOrClose(0, true), 615 + openOrClose(61, false), 616 + }, 617 + sameAfter: true, 618 + }, 619 + 620 + // Close then comment then open = separate comments 621 + { 622 + name: "close_comment_open", 623 + beforeCombined: []*issue_model.Comment{ 624 + openOrClose(0, true), 625 + commentText(1, "I'm a salmon"), 626 + openOrClose(2, false), 627 + }, 628 + sameAfter: true, 629 + }, 630 + } 631 + 632 + for _, kase := range kases { 633 + t.Run(kase.name, kase.doTest) 634 + } 635 + } 636 + 637 + func TestCombineMultipleDifferentComments(t *testing.T) { 638 + lblA := createLabel("a") 639 + kases := []testCase{ 640 + // Add Label + Close + ReqReview = Combined 641 + { 642 + name: "label_close_reqreview_combined", 643 + beforeCombined: []*issue_model.Comment{ 644 + reqReview(1, "toto", false), 645 + addLabel(2, "a"), 646 + openOrClose(3, true), 647 + 648 + reqReview(101, "toto", true), 649 + openOrClose(102, false), 650 + delLabel(103, "a"), 651 + }, 652 + afterCombined: []*issue_model.Comment{ 653 + aggregatedComment(1, 654 + true, 655 + []*issue_model.Label{&lblA}, 656 + []*issue_model.Label{}, 657 + []issue_model.RequestReviewTarget{createReqReviewTarget("toto")}, 658 + []issue_model.RequestReviewTarget{}, 659 + ), 660 + aggregatedComment(101, 661 + false, 662 + []*issue_model.Label{}, 663 + []*issue_model.Label{&lblA}, 664 + []issue_model.RequestReviewTarget{}, 665 + []issue_model.RequestReviewTarget{createReqReviewTarget("toto")}, 666 + ), 667 + }, 668 + }, 669 + 670 + // Add Req + Add Label + Close + Del Req + Del Label = Close only 671 + { 672 + name: "req_label_close_dellabel_delreq", 673 + beforeCombined: []*issue_model.Comment{ 674 + addLabel(2, "a"), 675 + reqReview(3, "titi", false), 676 + openOrClose(4, true), 677 + delLabel(5, "a"), 678 + reqReview(6, "titi", true), 679 + }, 680 + afterCombined: []*issue_model.Comment{ 681 + openOrClose(2, true), 682 + }, 683 + }, 684 + 685 + // Close + Add Req + Add Label + Del Req + Open = Label only 686 + { 687 + name: "close_req_label_open_delreq", 688 + beforeCombined: []*issue_model.Comment{ 689 + openOrClose(2, true), 690 + reqReview(4, "titi", false), 691 + addLabel(5, "a"), 692 + reqReview(6, "titi", true), 693 + openOrClose(8, false), 694 + }, 695 + afterCombined: []*issue_model.Comment{ 696 + addLabel(2, "a"), 697 + }, 698 + }, 699 + 700 + // Add Label + Close + Add ReqReview + Del Label + Open = ReqReview only 701 + { 702 + name: "label_close_req_dellabel_open", 703 + beforeCombined: []*issue_model.Comment{ 704 + addLabel(1, "a"), 705 + openOrClose(2, true), 706 + reqReview(4, "titi", false), 707 + openOrClose(7, false), 708 + delLabel(8, "a"), 709 + }, 710 + afterCombined: []*issue_model.Comment{ 711 + reqReviewList(1, false, "titi"), 712 + }, 713 + }, 714 + 715 + // Add Label + Close + ReqReview, then delete everything = nothing 716 + { 717 + name: "add_multiple_delete_everything", 718 + beforeCombined: []*issue_model.Comment{ 719 + addLabel(1, "a"), 720 + openOrClose(2, true), 721 + reqReview(4, "titi", false), 722 + openOrClose(7, false), 723 + delLabel(8, "a"), 724 + reqReview(10, "titi", true), 725 + }, 726 + afterCombined: nil, 727 + }, 728 + 729 + // Add multiple, then comment, then delete everything = separate aggregation 730 + { 731 + name: "add_multiple_comment_delete_everything", 732 + beforeCombined: []*issue_model.Comment{ 733 + addLabel(1, "a"), 734 + openOrClose(2, true), 735 + reqReview(4, "titi", false), 736 + 737 + commentText(6, "I'm a salmon"), 738 + 739 + openOrClose(7, false), 740 + delLabel(8, "a"), 741 + reqReview(10, "titi", true), 742 + }, 743 + afterCombined: []*issue_model.Comment{ 744 + aggregatedComment(1, 745 + true, 746 + []*issue_model.Label{&lblA}, 747 + []*issue_model.Label{}, 748 + []issue_model.RequestReviewTarget{createReqReviewTarget("titi")}, 749 + []issue_model.RequestReviewTarget{}, 750 + ), 751 + commentText(6, "I'm a salmon"), 752 + aggregatedComment(7, 753 + false, 754 + []*issue_model.Label{}, 755 + []*issue_model.Label{&lblA}, 756 + []issue_model.RequestReviewTarget{}, 757 + []issue_model.RequestReviewTarget{createReqReviewTarget("titi")}, 758 + ), 759 + }, 760 + }, 761 + 762 + { 763 + name: "regression_edgecase_finalagg", 764 + beforeCombined: []*issue_model.Comment{ 765 + commentText(0, "hey"), 766 + commentText(1, "ho"), 767 + addLabel(2, "a"), 768 + reqReview(3, "titi", false), 769 + delLabel(4, "a"), 770 + reqReview(5, "titi", true), 771 + 772 + addLabel(120, "a"), 773 + 774 + openOrClose(220, true), 775 + addLabel(221, "d"), 776 + reqReview(222, "toto-team", false), 777 + delLabel(223, "d"), 778 + 779 + delLabel(400, "a"), 780 + }, 781 + afterCombined: []*issue_model.Comment{ 782 + commentText(0, "hey"), 783 + commentText(1, "ho"), 784 + addLabel(120, "a"), 785 + aggregatedComment(220, 786 + true, 787 + []*issue_model.Label{}, 788 + []*issue_model.Label{}, 789 + []issue_model.RequestReviewTarget{createReqReviewTarget("toto-team")}, 790 + []issue_model.RequestReviewTarget{}, 791 + ), 792 + delLabel(400, "a"), 793 + }, 794 + }, 795 + } 796 + 797 + for _, kase := range kases { 798 + t.Run(kase.name, kase.doTest) 799 + } 800 + }
+1 -190
routers/web/repo/issue.go
··· 1834 1834 ctx.Data["LatestCloseCommentID"] = latestCloseCommentID 1835 1835 1836 1836 // Combine multiple label assignments into a single comment 1837 - combineLabelComments(issue) 1838 - combineRequestReviewComments(issue) 1837 + issues_model.CombineCommentsHistory(issue, time.Now().Unix()) 1839 1838 1840 1839 getBranchData(ctx, issue) 1841 1840 if issue.IsPull { ··· 3708 3707 return "" 3709 3708 } 3710 3709 return attachHTML 3711 - } 3712 - 3713 - type RequestReviewTarget struct { 3714 - user *user_model.User 3715 - team *organization.Team 3716 - } 3717 - 3718 - func (t *RequestReviewTarget) ID() int64 { 3719 - if t.user != nil { 3720 - return t.user.ID 3721 - } 3722 - return t.team.ID 3723 - } 3724 - 3725 - func (t *RequestReviewTarget) Name() string { 3726 - if t.user != nil { 3727 - return t.user.GetDisplayName() 3728 - } 3729 - return t.team.Name 3730 - } 3731 - 3732 - func (t *RequestReviewTarget) Type() string { 3733 - if t.user != nil { 3734 - return "user" 3735 - } 3736 - return "team" 3737 - } 3738 - 3739 - // combineRequestReviewComments combine the nearby request review comments as one. 3740 - func combineRequestReviewComments(issue *issues_model.Issue) { 3741 - var prev, cur *issues_model.Comment 3742 - for i := 0; i < len(issue.Comments); i++ { 3743 - cur = issue.Comments[i] 3744 - if i > 0 { 3745 - prev = issue.Comments[i-1] 3746 - } 3747 - if i == 0 || cur.Type != issues_model.CommentTypeReviewRequest || 3748 - (prev != nil && prev.PosterID != cur.PosterID) || 3749 - (prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) { 3750 - if cur.Type == issues_model.CommentTypeReviewRequest && (cur.Assignee != nil || cur.AssigneeTeam != nil) { 3751 - if cur.RemovedAssignee { 3752 - if cur.AssigneeTeam != nil { 3753 - cur.RemovedRequestReview = append(cur.RemovedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam}) 3754 - } else { 3755 - cur.RemovedRequestReview = append(cur.RemovedRequestReview, &RequestReviewTarget{user: cur.Assignee}) 3756 - } 3757 - } else { 3758 - if cur.AssigneeTeam != nil { 3759 - cur.AddedRequestReview = append(cur.AddedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam}) 3760 - } else { 3761 - cur.AddedRequestReview = append(cur.AddedRequestReview, &RequestReviewTarget{user: cur.Assignee}) 3762 - } 3763 - } 3764 - } 3765 - continue 3766 - } 3767 - 3768 - // Previous comment is not a review request, so cannot group. Start a new group. 3769 - if prev.Type != issues_model.CommentTypeReviewRequest { 3770 - if cur.RemovedAssignee { 3771 - if cur.AssigneeTeam != nil { 3772 - cur.RemovedRequestReview = append(cur.RemovedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam}) 3773 - } else { 3774 - cur.RemovedRequestReview = append(cur.RemovedRequestReview, &RequestReviewTarget{user: cur.Assignee}) 3775 - } 3776 - } else { 3777 - if cur.AssigneeTeam != nil { 3778 - cur.AddedRequestReview = append(cur.AddedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam}) 3779 - } else { 3780 - cur.AddedRequestReview = append(cur.AddedRequestReview, &RequestReviewTarget{user: cur.Assignee}) 3781 - } 3782 - } 3783 - continue 3784 - } 3785 - 3786 - // Start grouping. 3787 - if cur.RemovedAssignee { 3788 - addedIndex := slices.IndexFunc(prev.AddedRequestReview, func(t issues_model.RequestReviewTarget) bool { 3789 - if cur.AssigneeTeam != nil { 3790 - return cur.AssigneeTeam.ID == t.ID() && t.Type() == "team" 3791 - } 3792 - return cur.Assignee.ID == t.ID() && t.Type() == "user" 3793 - }) 3794 - 3795 - // If for this target a AddedRequestReview, then we remove that entry. If it's not found, then add it to the RemovedRequestReview. 3796 - if addedIndex == -1 { 3797 - if cur.AssigneeTeam != nil { 3798 - prev.RemovedRequestReview = append(prev.RemovedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam}) 3799 - } else { 3800 - prev.RemovedRequestReview = append(prev.RemovedRequestReview, &RequestReviewTarget{user: cur.Assignee}) 3801 - } 3802 - } else { 3803 - prev.AddedRequestReview = slices.Delete(prev.AddedRequestReview, addedIndex, addedIndex+1) 3804 - } 3805 - } else { 3806 - removedIndex := slices.IndexFunc(prev.RemovedRequestReview, func(t issues_model.RequestReviewTarget) bool { 3807 - if cur.AssigneeTeam != nil { 3808 - return cur.AssigneeTeam.ID == t.ID() && t.Type() == "team" 3809 - } 3810 - return cur.Assignee.ID == t.ID() && t.Type() == "user" 3811 - }) 3812 - 3813 - // If for this target a RemovedRequestReview, then we remove that entry. If it's not found, then add it to the AddedRequestReview. 3814 - if removedIndex == -1 { 3815 - if cur.AssigneeTeam != nil { 3816 - prev.AddedRequestReview = append(prev.AddedRequestReview, &RequestReviewTarget{team: cur.AssigneeTeam}) 3817 - } else { 3818 - prev.AddedRequestReview = append(prev.AddedRequestReview, &RequestReviewTarget{user: cur.Assignee}) 3819 - } 3820 - } else { 3821 - prev.RemovedRequestReview = slices.Delete(prev.RemovedRequestReview, removedIndex, removedIndex+1) 3822 - } 3823 - } 3824 - 3825 - // Propagate creation time. 3826 - prev.CreatedUnix = cur.CreatedUnix 3827 - 3828 - // Remove the current comment since it has been combined to prev comment 3829 - issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...) 3830 - i-- 3831 - } 3832 - } 3833 - 3834 - // combineLabelComments combine the nearby label comments as one. 3835 - func combineLabelComments(issue *issues_model.Issue) { 3836 - var prev, cur *issues_model.Comment 3837 - for i := 0; i < len(issue.Comments); i++ { 3838 - cur = issue.Comments[i] 3839 - if i > 0 { 3840 - prev = issue.Comments[i-1] 3841 - } 3842 - if i == 0 || cur.Type != issues_model.CommentTypeLabel || 3843 - (prev != nil && prev.PosterID != cur.PosterID) || 3844 - (prev != nil && cur.CreatedUnix-prev.CreatedUnix >= 60) { 3845 - if cur.Type == issues_model.CommentTypeLabel && cur.Label != nil { 3846 - if cur.Content != "1" { 3847 - cur.RemovedLabels = append(cur.RemovedLabels, cur.Label) 3848 - } else { 3849 - cur.AddedLabels = append(cur.AddedLabels, cur.Label) 3850 - } 3851 - } 3852 - continue 3853 - } 3854 - 3855 - if cur.Label != nil { // now cur MUST be label comment 3856 - if prev.Type == issues_model.CommentTypeLabel { // we can combine them only prev is a label comment 3857 - if cur.Content != "1" { 3858 - // remove labels from the AddedLabels list if the label that was removed is already 3859 - // in this list, and if it's not in this list, add the label to RemovedLabels 3860 - addedAndRemoved := false 3861 - for i, label := range prev.AddedLabels { 3862 - if cur.Label.ID == label.ID { 3863 - prev.AddedLabels = append(prev.AddedLabels[:i], prev.AddedLabels[i+1:]...) 3864 - addedAndRemoved = true 3865 - break 3866 - } 3867 - } 3868 - if !addedAndRemoved { 3869 - prev.RemovedLabels = append(prev.RemovedLabels, cur.Label) 3870 - } 3871 - } else { 3872 - // remove labels from the RemovedLabels list if the label that was added is already 3873 - // in this list, and if it's not in this list, add the label to AddedLabels 3874 - removedAndAdded := false 3875 - for i, label := range prev.RemovedLabels { 3876 - if cur.Label.ID == label.ID { 3877 - prev.RemovedLabels = append(prev.RemovedLabels[:i], prev.RemovedLabels[i+1:]...) 3878 - removedAndAdded = true 3879 - break 3880 - } 3881 - } 3882 - if !removedAndAdded { 3883 - prev.AddedLabels = append(prev.AddedLabels, cur.Label) 3884 - } 3885 - } 3886 - prev.CreatedUnix = cur.CreatedUnix 3887 - // remove the current comment since it has been combined to prev comment 3888 - issue.Comments = append(issue.Comments[:i], issue.Comments[i+1:]...) 3889 - i-- 3890 - } else { // if prev is not a label comment, start a new group 3891 - if cur.Content != "1" { 3892 - cur.RemovedLabels = append(cur.RemovedLabels, cur.Label) 3893 - } else { 3894 - cur.AddedLabels = append(cur.AddedLabels, cur.Label) 3895 - } 3896 - } 3897 - } 3898 - } 3899 3710 } 3900 3711 3901 3712 // get all teams that current user can mention
-806
routers/web/repo/issue_test.go
··· 1 - // Copyright 2020 The Gitea Authors. All rights reserved. 2 - // SPDX-License-Identifier: MIT 3 - 4 - package repo 5 - 6 - import ( 7 - "testing" 8 - 9 - issues_model "code.gitea.io/gitea/models/issues" 10 - org_model "code.gitea.io/gitea/models/organization" 11 - user_model "code.gitea.io/gitea/models/user" 12 - 13 - "github.com/stretchr/testify/assert" 14 - ) 15 - 16 - func TestCombineLabelComments(t *testing.T) { 17 - kases := []struct { 18 - name string 19 - beforeCombined []*issues_model.Comment 20 - afterCombined []*issues_model.Comment 21 - }{ 22 - { 23 - name: "kase 1", 24 - beforeCombined: []*issues_model.Comment{ 25 - { 26 - Type: issues_model.CommentTypeLabel, 27 - PosterID: 1, 28 - Content: "1", 29 - Label: &issues_model.Label{ 30 - Name: "kind/bug", 31 - }, 32 - CreatedUnix: 0, 33 - }, 34 - { 35 - Type: issues_model.CommentTypeLabel, 36 - PosterID: 1, 37 - Content: "", 38 - Label: &issues_model.Label{ 39 - Name: "kind/bug", 40 - }, 41 - CreatedUnix: 0, 42 - }, 43 - { 44 - Type: issues_model.CommentTypeComment, 45 - PosterID: 1, 46 - Content: "test", 47 - CreatedUnix: 0, 48 - }, 49 - }, 50 - afterCombined: []*issues_model.Comment{ 51 - { 52 - Type: issues_model.CommentTypeLabel, 53 - PosterID: 1, 54 - Content: "1", 55 - CreatedUnix: 0, 56 - AddedLabels: []*issues_model.Label{}, 57 - Label: &issues_model.Label{ 58 - Name: "kind/bug", 59 - }, 60 - }, 61 - { 62 - Type: issues_model.CommentTypeComment, 63 - PosterID: 1, 64 - Content: "test", 65 - CreatedUnix: 0, 66 - }, 67 - }, 68 - }, 69 - { 70 - name: "kase 2", 71 - beforeCombined: []*issues_model.Comment{ 72 - { 73 - Type: issues_model.CommentTypeLabel, 74 - PosterID: 1, 75 - Content: "1", 76 - Label: &issues_model.Label{ 77 - Name: "kind/bug", 78 - }, 79 - CreatedUnix: 0, 80 - }, 81 - { 82 - Type: issues_model.CommentTypeLabel, 83 - PosterID: 1, 84 - Content: "", 85 - Label: &issues_model.Label{ 86 - Name: "kind/bug", 87 - }, 88 - CreatedUnix: 70, 89 - }, 90 - { 91 - Type: issues_model.CommentTypeComment, 92 - PosterID: 1, 93 - Content: "test", 94 - CreatedUnix: 0, 95 - }, 96 - }, 97 - afterCombined: []*issues_model.Comment{ 98 - { 99 - Type: issues_model.CommentTypeLabel, 100 - PosterID: 1, 101 - Content: "1", 102 - CreatedUnix: 0, 103 - AddedLabels: []*issues_model.Label{ 104 - { 105 - Name: "kind/bug", 106 - }, 107 - }, 108 - Label: &issues_model.Label{ 109 - Name: "kind/bug", 110 - }, 111 - }, 112 - { 113 - Type: issues_model.CommentTypeLabel, 114 - PosterID: 1, 115 - Content: "", 116 - CreatedUnix: 70, 117 - RemovedLabels: []*issues_model.Label{ 118 - { 119 - Name: "kind/bug", 120 - }, 121 - }, 122 - Label: &issues_model.Label{ 123 - Name: "kind/bug", 124 - }, 125 - }, 126 - { 127 - Type: issues_model.CommentTypeComment, 128 - PosterID: 1, 129 - Content: "test", 130 - CreatedUnix: 0, 131 - }, 132 - }, 133 - }, 134 - { 135 - name: "kase 3", 136 - beforeCombined: []*issues_model.Comment{ 137 - { 138 - Type: issues_model.CommentTypeLabel, 139 - PosterID: 1, 140 - Content: "1", 141 - Label: &issues_model.Label{ 142 - Name: "kind/bug", 143 - }, 144 - CreatedUnix: 0, 145 - }, 146 - { 147 - Type: issues_model.CommentTypeLabel, 148 - PosterID: 2, 149 - Content: "", 150 - Label: &issues_model.Label{ 151 - Name: "kind/bug", 152 - }, 153 - CreatedUnix: 0, 154 - }, 155 - { 156 - Type: issues_model.CommentTypeComment, 157 - PosterID: 1, 158 - Content: "test", 159 - CreatedUnix: 0, 160 - }, 161 - }, 162 - afterCombined: []*issues_model.Comment{ 163 - { 164 - Type: issues_model.CommentTypeLabel, 165 - PosterID: 1, 166 - Content: "1", 167 - CreatedUnix: 0, 168 - AddedLabels: []*issues_model.Label{ 169 - { 170 - Name: "kind/bug", 171 - }, 172 - }, 173 - Label: &issues_model.Label{ 174 - Name: "kind/bug", 175 - }, 176 - }, 177 - { 178 - Type: issues_model.CommentTypeLabel, 179 - PosterID: 2, 180 - Content: "", 181 - CreatedUnix: 0, 182 - RemovedLabels: []*issues_model.Label{ 183 - { 184 - Name: "kind/bug", 185 - }, 186 - }, 187 - Label: &issues_model.Label{ 188 - Name: "kind/bug", 189 - }, 190 - }, 191 - { 192 - Type: issues_model.CommentTypeComment, 193 - PosterID: 1, 194 - Content: "test", 195 - CreatedUnix: 0, 196 - }, 197 - }, 198 - }, 199 - { 200 - name: "kase 4", 201 - beforeCombined: []*issues_model.Comment{ 202 - { 203 - Type: issues_model.CommentTypeLabel, 204 - PosterID: 1, 205 - Content: "1", 206 - Label: &issues_model.Label{ 207 - Name: "kind/bug", 208 - }, 209 - CreatedUnix: 0, 210 - }, 211 - { 212 - Type: issues_model.CommentTypeLabel, 213 - PosterID: 1, 214 - Content: "1", 215 - Label: &issues_model.Label{ 216 - Name: "kind/backport", 217 - }, 218 - CreatedUnix: 10, 219 - }, 220 - }, 221 - afterCombined: []*issues_model.Comment{ 222 - { 223 - Type: issues_model.CommentTypeLabel, 224 - PosterID: 1, 225 - Content: "1", 226 - CreatedUnix: 10, 227 - AddedLabels: []*issues_model.Label{ 228 - { 229 - Name: "kind/bug", 230 - }, 231 - { 232 - Name: "kind/backport", 233 - }, 234 - }, 235 - Label: &issues_model.Label{ 236 - Name: "kind/bug", 237 - }, 238 - }, 239 - }, 240 - }, 241 - { 242 - name: "kase 5", 243 - beforeCombined: []*issues_model.Comment{ 244 - { 245 - Type: issues_model.CommentTypeLabel, 246 - PosterID: 1, 247 - Content: "1", 248 - Label: &issues_model.Label{ 249 - Name: "kind/bug", 250 - }, 251 - CreatedUnix: 0, 252 - }, 253 - { 254 - Type: issues_model.CommentTypeComment, 255 - PosterID: 2, 256 - Content: "testtest", 257 - CreatedUnix: 0, 258 - }, 259 - { 260 - Type: issues_model.CommentTypeLabel, 261 - PosterID: 1, 262 - Content: "", 263 - Label: &issues_model.Label{ 264 - Name: "kind/bug", 265 - }, 266 - CreatedUnix: 0, 267 - }, 268 - }, 269 - afterCombined: []*issues_model.Comment{ 270 - { 271 - Type: issues_model.CommentTypeLabel, 272 - PosterID: 1, 273 - Content: "1", 274 - Label: &issues_model.Label{ 275 - Name: "kind/bug", 276 - }, 277 - AddedLabels: []*issues_model.Label{ 278 - { 279 - Name: "kind/bug", 280 - }, 281 - }, 282 - CreatedUnix: 0, 283 - }, 284 - { 285 - Type: issues_model.CommentTypeComment, 286 - PosterID: 2, 287 - Content: "testtest", 288 - CreatedUnix: 0, 289 - }, 290 - { 291 - Type: issues_model.CommentTypeLabel, 292 - PosterID: 1, 293 - Content: "", 294 - RemovedLabels: []*issues_model.Label{ 295 - { 296 - Name: "kind/bug", 297 - }, 298 - }, 299 - Label: &issues_model.Label{ 300 - Name: "kind/bug", 301 - }, 302 - CreatedUnix: 0, 303 - }, 304 - }, 305 - }, 306 - { 307 - name: "kase 6", 308 - beforeCombined: []*issues_model.Comment{ 309 - { 310 - Type: issues_model.CommentTypeLabel, 311 - PosterID: 1, 312 - Content: "1", 313 - Label: &issues_model.Label{ 314 - Name: "kind/bug", 315 - }, 316 - CreatedUnix: 0, 317 - }, 318 - { 319 - Type: issues_model.CommentTypeLabel, 320 - PosterID: 1, 321 - Content: "1", 322 - Label: &issues_model.Label{ 323 - Name: "reviewed/confirmed", 324 - }, 325 - CreatedUnix: 0, 326 - }, 327 - { 328 - Type: issues_model.CommentTypeLabel, 329 - PosterID: 1, 330 - Content: "", 331 - Label: &issues_model.Label{ 332 - Name: "kind/bug", 333 - }, 334 - CreatedUnix: 0, 335 - }, 336 - { 337 - Type: issues_model.CommentTypeLabel, 338 - PosterID: 1, 339 - Content: "1", 340 - Label: &issues_model.Label{ 341 - Name: "kind/feature", 342 - }, 343 - CreatedUnix: 0, 344 - }, 345 - }, 346 - afterCombined: []*issues_model.Comment{ 347 - { 348 - Type: issues_model.CommentTypeLabel, 349 - PosterID: 1, 350 - Content: "1", 351 - Label: &issues_model.Label{ 352 - Name: "kind/bug", 353 - }, 354 - AddedLabels: []*issues_model.Label{ 355 - { 356 - Name: "reviewed/confirmed", 357 - }, 358 - { 359 - Name: "kind/feature", 360 - }, 361 - }, 362 - CreatedUnix: 0, 363 - }, 364 - }, 365 - }, 366 - } 367 - 368 - for _, kase := range kases { 369 - t.Run(kase.name, func(t *testing.T) { 370 - issue := issues_model.Issue{ 371 - Comments: kase.beforeCombined, 372 - } 373 - combineLabelComments(&issue) 374 - assert.EqualValues(t, kase.afterCombined, issue.Comments) 375 - }) 376 - } 377 - } 378 - 379 - func TestCombineReviewRequests(t *testing.T) { 380 - testCases := []struct { 381 - name string 382 - beforeCombined []*issues_model.Comment 383 - afterCombined []*issues_model.Comment 384 - }{ 385 - { 386 - name: "case 1", 387 - beforeCombined: []*issues_model.Comment{ 388 - { 389 - Type: issues_model.CommentTypeReviewRequest, 390 - PosterID: 1, 391 - Assignee: &user_model.User{ 392 - ID: 1, 393 - Name: "Ghost", 394 - }, 395 - CreatedUnix: 0, 396 - }, 397 - { 398 - Type: issues_model.CommentTypeReviewRequest, 399 - PosterID: 1, 400 - RemovedAssignee: true, 401 - Assignee: &user_model.User{ 402 - ID: 1, 403 - Name: "Ghost", 404 - }, 405 - CreatedUnix: 0, 406 - }, 407 - { 408 - Type: issues_model.CommentTypeComment, 409 - PosterID: 1, 410 - Content: "test", 411 - CreatedUnix: 0, 412 - }, 413 - }, 414 - afterCombined: []*issues_model.Comment{ 415 - { 416 - Type: issues_model.CommentTypeReviewRequest, 417 - PosterID: 1, 418 - CreatedUnix: 0, 419 - AddedRequestReview: []issues_model.RequestReviewTarget{}, 420 - Assignee: &user_model.User{ 421 - ID: 1, 422 - Name: "Ghost", 423 - }, 424 - }, 425 - { 426 - Type: issues_model.CommentTypeComment, 427 - PosterID: 1, 428 - Content: "test", 429 - CreatedUnix: 0, 430 - }, 431 - }, 432 - }, 433 - { 434 - name: "case 2", 435 - beforeCombined: []*issues_model.Comment{ 436 - { 437 - Type: issues_model.CommentTypeReviewRequest, 438 - PosterID: 1, 439 - Assignee: &user_model.User{ 440 - ID: 1, 441 - Name: "Ghost", 442 - }, 443 - CreatedUnix: 0, 444 - }, 445 - { 446 - Type: issues_model.CommentTypeReviewRequest, 447 - PosterID: 1, 448 - Assignee: &user_model.User{ 449 - ID: 2, 450 - Name: "Ghost 2", 451 - }, 452 - CreatedUnix: 0, 453 - }, 454 - }, 455 - afterCombined: []*issues_model.Comment{ 456 - { 457 - Type: issues_model.CommentTypeReviewRequest, 458 - PosterID: 1, 459 - CreatedUnix: 0, 460 - AddedRequestReview: []issues_model.RequestReviewTarget{ 461 - &RequestReviewTarget{ 462 - user: &user_model.User{ 463 - ID: 1, 464 - Name: "Ghost", 465 - }, 466 - }, 467 - &RequestReviewTarget{ 468 - user: &user_model.User{ 469 - ID: 2, 470 - Name: "Ghost 2", 471 - }, 472 - }, 473 - }, 474 - Assignee: &user_model.User{ 475 - ID: 1, 476 - Name: "Ghost", 477 - }, 478 - }, 479 - }, 480 - }, 481 - { 482 - name: "case 3", 483 - beforeCombined: []*issues_model.Comment{ 484 - { 485 - Type: issues_model.CommentTypeReviewRequest, 486 - PosterID: 1, 487 - Assignee: &user_model.User{ 488 - ID: 1, 489 - Name: "Ghost", 490 - }, 491 - CreatedUnix: 0, 492 - }, 493 - { 494 - Type: issues_model.CommentTypeReviewRequest, 495 - PosterID: 1, 496 - RemovedAssignee: true, 497 - AssigneeTeam: &org_model.Team{ 498 - ID: 1, 499 - Name: "Team 1", 500 - }, 501 - CreatedUnix: 0, 502 - }, 503 - }, 504 - afterCombined: []*issues_model.Comment{ 505 - { 506 - Type: issues_model.CommentTypeReviewRequest, 507 - PosterID: 1, 508 - CreatedUnix: 0, 509 - AddedRequestReview: []issues_model.RequestReviewTarget{ 510 - &RequestReviewTarget{ 511 - user: &user_model.User{ 512 - ID: 1, 513 - Name: "Ghost", 514 - }, 515 - }, 516 - }, 517 - RemovedRequestReview: []issues_model.RequestReviewTarget{ 518 - &RequestReviewTarget{ 519 - team: &org_model.Team{ 520 - ID: 1, 521 - Name: "Team 1", 522 - }, 523 - }, 524 - }, 525 - Assignee: &user_model.User{ 526 - ID: 1, 527 - Name: "Ghost", 528 - }, 529 - }, 530 - }, 531 - }, 532 - { 533 - name: "case 4", 534 - beforeCombined: []*issues_model.Comment{ 535 - { 536 - Type: issues_model.CommentTypeReviewRequest, 537 - PosterID: 1, 538 - Assignee: &user_model.User{ 539 - ID: 1, 540 - Name: "Ghost", 541 - }, 542 - CreatedUnix: 0, 543 - }, 544 - { 545 - Type: issues_model.CommentTypeReviewRequest, 546 - PosterID: 1, 547 - RemovedAssignee: true, 548 - AssigneeTeam: &org_model.Team{ 549 - ID: 1, 550 - Name: "Team 1", 551 - }, 552 - CreatedUnix: 0, 553 - }, 554 - { 555 - Type: issues_model.CommentTypeReviewRequest, 556 - PosterID: 1, 557 - AssigneeTeam: &org_model.Team{ 558 - ID: 1, 559 - Name: "Team 1", 560 - }, 561 - CreatedUnix: 0, 562 - }, 563 - }, 564 - afterCombined: []*issues_model.Comment{ 565 - { 566 - Type: issues_model.CommentTypeReviewRequest, 567 - PosterID: 1, 568 - CreatedUnix: 0, 569 - AddedRequestReview: []issues_model.RequestReviewTarget{ 570 - &RequestReviewTarget{ 571 - user: &user_model.User{ 572 - ID: 1, 573 - Name: "Ghost", 574 - }, 575 - }, 576 - }, 577 - RemovedRequestReview: []issues_model.RequestReviewTarget{}, 578 - Assignee: &user_model.User{ 579 - ID: 1, 580 - Name: "Ghost", 581 - }, 582 - }, 583 - }, 584 - }, 585 - { 586 - name: "case 5", 587 - beforeCombined: []*issues_model.Comment{ 588 - { 589 - Type: issues_model.CommentTypeReviewRequest, 590 - PosterID: 1, 591 - Assignee: &user_model.User{ 592 - ID: 1, 593 - Name: "Ghost", 594 - }, 595 - CreatedUnix: 0, 596 - }, 597 - { 598 - Type: issues_model.CommentTypeReviewRequest, 599 - PosterID: 1, 600 - RemovedAssignee: true, 601 - AssigneeTeam: &org_model.Team{ 602 - ID: 1, 603 - Name: "Team 1", 604 - }, 605 - CreatedUnix: 0, 606 - }, 607 - { 608 - Type: issues_model.CommentTypeReviewRequest, 609 - PosterID: 1, 610 - AssigneeTeam: &org_model.Team{ 611 - ID: 1, 612 - Name: "Team 1", 613 - }, 614 - CreatedUnix: 0, 615 - }, 616 - { 617 - Type: issues_model.CommentTypeReviewRequest, 618 - PosterID: 1, 619 - RemovedAssignee: true, 620 - Assignee: &user_model.User{ 621 - ID: 1, 622 - Name: "Ghost", 623 - }, 624 - CreatedUnix: 0, 625 - }, 626 - }, 627 - afterCombined: []*issues_model.Comment{ 628 - { 629 - Type: issues_model.CommentTypeReviewRequest, 630 - PosterID: 1, 631 - CreatedUnix: 0, 632 - AddedRequestReview: []issues_model.RequestReviewTarget{}, 633 - RemovedRequestReview: []issues_model.RequestReviewTarget{}, 634 - Assignee: &user_model.User{ 635 - ID: 1, 636 - Name: "Ghost", 637 - }, 638 - }, 639 - }, 640 - }, 641 - { 642 - name: "case 6", 643 - beforeCombined: []*issues_model.Comment{ 644 - { 645 - Type: issues_model.CommentTypeReviewRequest, 646 - PosterID: 1, 647 - Assignee: &user_model.User{ 648 - ID: 1, 649 - Name: "Ghost", 650 - }, 651 - CreatedUnix: 0, 652 - }, 653 - { 654 - Type: issues_model.CommentTypeReviewRequest, 655 - PosterID: 1, 656 - RemovedAssignee: true, 657 - AssigneeTeam: &org_model.Team{ 658 - ID: 1, 659 - Name: "Team 1", 660 - }, 661 - CreatedUnix: 0, 662 - }, 663 - { 664 - Type: issues_model.CommentTypeComment, 665 - PosterID: 1, 666 - Content: "test", 667 - CreatedUnix: 0, 668 - }, 669 - { 670 - Type: issues_model.CommentTypeReviewRequest, 671 - PosterID: 1, 672 - AssigneeTeam: &org_model.Team{ 673 - ID: 1, 674 - Name: "Team 1", 675 - }, 676 - CreatedUnix: 0, 677 - }, 678 - { 679 - Type: issues_model.CommentTypeReviewRequest, 680 - PosterID: 1, 681 - RemovedAssignee: true, 682 - Assignee: &user_model.User{ 683 - ID: 1, 684 - Name: "Ghost", 685 - }, 686 - CreatedUnix: 0, 687 - }, 688 - }, 689 - afterCombined: []*issues_model.Comment{ 690 - { 691 - Type: issues_model.CommentTypeReviewRequest, 692 - PosterID: 1, 693 - CreatedUnix: 0, 694 - RemovedRequestReview: []issues_model.RequestReviewTarget{&RequestReviewTarget{ 695 - team: &org_model.Team{ 696 - ID: 1, 697 - Name: "Team 1", 698 - }, 699 - }}, 700 - AddedRequestReview: []issues_model.RequestReviewTarget{&RequestReviewTarget{ 701 - user: &user_model.User{ 702 - ID: 1, 703 - Name: "Ghost", 704 - }, 705 - }}, 706 - Assignee: &user_model.User{ 707 - ID: 1, 708 - Name: "Ghost", 709 - }, 710 - }, 711 - { 712 - Type: issues_model.CommentTypeComment, 713 - PosterID: 1, 714 - Content: "test", 715 - CreatedUnix: 0, 716 - }, 717 - { 718 - Type: issues_model.CommentTypeReviewRequest, 719 - PosterID: 1, 720 - CreatedUnix: 0, 721 - AddedRequestReview: []issues_model.RequestReviewTarget{&RequestReviewTarget{ 722 - team: &org_model.Team{ 723 - ID: 1, 724 - Name: "Team 1", 725 - }, 726 - }}, 727 - RemovedRequestReview: []issues_model.RequestReviewTarget{&RequestReviewTarget{ 728 - user: &user_model.User{ 729 - ID: 1, 730 - Name: "Ghost", 731 - }, 732 - }}, 733 - AssigneeTeam: &org_model.Team{ 734 - ID: 1, 735 - Name: "Team 1", 736 - }, 737 - }, 738 - }, 739 - }, 740 - { 741 - name: "case 7", 742 - beforeCombined: []*issues_model.Comment{ 743 - { 744 - Type: issues_model.CommentTypeReviewRequest, 745 - PosterID: 1, 746 - Assignee: &user_model.User{ 747 - ID: 1, 748 - Name: "Ghost", 749 - }, 750 - CreatedUnix: 0, 751 - }, 752 - { 753 - Type: issues_model.CommentTypeReviewRequest, 754 - PosterID: 1, 755 - AssigneeTeam: &org_model.Team{ 756 - ID: 1, 757 - Name: "Team 1", 758 - }, 759 - CreatedUnix: 61, 760 - }, 761 - }, 762 - afterCombined: []*issues_model.Comment{ 763 - { 764 - Type: issues_model.CommentTypeReviewRequest, 765 - PosterID: 1, 766 - CreatedUnix: 0, 767 - AddedRequestReview: []issues_model.RequestReviewTarget{&RequestReviewTarget{ 768 - user: &user_model.User{ 769 - ID: 1, 770 - Name: "Ghost", 771 - }, 772 - }}, 773 - Assignee: &user_model.User{ 774 - ID: 1, 775 - Name: "Ghost", 776 - }, 777 - }, 778 - { 779 - Type: issues_model.CommentTypeReviewRequest, 780 - PosterID: 1, 781 - CreatedUnix: 0, 782 - RemovedRequestReview: []issues_model.RequestReviewTarget{&RequestReviewTarget{ 783 - team: &org_model.Team{ 784 - ID: 1, 785 - Name: "Team 1", 786 - }, 787 - }}, 788 - AssigneeTeam: &org_model.Team{ 789 - ID: 1, 790 - Name: "Team 1", 791 - }, 792 - }, 793 - }, 794 - }, 795 - } 796 - 797 - for _, testCase := range testCases { 798 - t.Run(testCase.name, func(t *testing.T) { 799 - issue := issues_model.Issue{ 800 - Comments: testCase.beforeCombined, 801 - } 802 - combineRequestReviewComments(&issue) 803 - assert.EqualValues(t, testCase.afterCombined[0], issue.Comments[0]) 804 - }) 805 - } 806 - }
+70 -3
templates/repo/issue/view_content/comments.tmpl
··· 12 12 26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST, 13 13 29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED 14 14 32 = DISMISSED_REVIEW, 33 = COMMENT_TYPE_CHANGE_ISSUE_REF, 34 = PR_SCHEDULE_TO_AUTO_MERGE, 15 - 35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE --> 15 + 35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE, 38 = ACTION_AGGREGATOR --> 16 16 {{if eq .Type 0}} 17 17 <div class="timeline-item comment" id="{{.HashTag}}"> 18 18 {{if .OriginalAuthor}} ··· 524 524 </div> 525 525 </div> 526 526 {{else if eq .Type 27}} 527 - {{if or .AddedRequestReview .RemovedRequestReview}} 527 + {{if or .AddedRequestReview .RemovedRequestReview}} 528 528 <div class="timeline-item event" id="{{.HashTag}}"> 529 529 <span class="badge">{{svg "octicon-tag"}}</span> 530 530 {{template "shared/user/avatarlink" dict "user" .Poster}} ··· 540 540 {{end}} 541 541 </span> 542 542 </div> 543 - {{end}} 543 + {{end}} 544 544 {{else if and (eq .Type 29) (or (gt .CommitsNum 0) .IsForcePush)}} 545 545 <!-- If PR is closed, the comments whose type is CommentTypePullRequestPush(29) after latestCloseCommentID won't be rendered. //--> 546 546 {{if and .Issue.IsClosed (gt .ID $.LatestCloseCommentID)}} ··· 674 674 {{template "shared/user/authorlink" .Poster}} 675 675 {{if eq .Type 36}}{{ctx.Locale.Tr "repo.issues.pin_comment" $createdStr}} 676 676 {{else}}{{ctx.Locale.Tr "repo.issues.unpin_comment" $createdStr}}{{end}} 677 + </span> 678 + </div> 679 + {{else if eq .Type 38}} 680 + <div class="timeline-item event" id="{{.HashTag}}"> 681 + <span class="badge">{{svg "octicon-list-unordered" 16}}</span> 682 + {{template "shared/user/avatarlink" dict "user" .Poster}} 683 + 684 + <span class="text grey muted-links"> 685 + {{template "shared/user/authorlink" .Poster}} 686 + {{$createdStr}} 687 + 688 + <ul class="tw-list-none aggregated-actions"> 689 + 690 + <!-- OPEN / CLOSE --> 691 + {{if and .Aggregator.PrevClosed (not .Aggregator.IsClosed)}} 692 + <li> 693 + <span class="badge tw-bg-green tw-text-white">{{svg "octicon-dot-fill"}}</span> 694 + {{if .Issue.IsPull}} 695 + {{ctx.Locale.Tr "repo.pulls.reopened_at" "" ""}} 696 + {{else}} 697 + {{ctx.Locale.Tr "repo.issues.reopened_at" "" ""}} 698 + {{end}} 699 + </li> 700 + {{else if and (not .Aggregator.PrevClosed) .Aggregator.IsClosed}} 701 + <span class="badge tw-bg-red tw-text-white">{{svg "octicon-circle-slash"}}</span> 702 + <li> 703 + {{if .Issue.IsPull}} 704 + {{ctx.Locale.Tr "repo.pulls.closed_at" "" ""}} 705 + {{else}} 706 + {{ctx.Locale.Tr "repo.issues.closed_at" "" ""}} 707 + {{end}} 708 + </li> 709 + {{end}} 710 + 711 + <!-- Add labels --> 712 + {{if or .AddedLabels .RemovedLabels}} 713 + <li> 714 + <span class="badge">{{svg "octicon-tag" 20}}</span> 715 + {{if and .AddedLabels (not .RemovedLabels)}} 716 + {{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink .Issue.IsPull) ""}} 717 + {{else if and (not .AddedLabels) .RemovedLabels}} 718 + {{ctx.Locale.TrN (len .RemovedLabels) "repo.issues.remove_label" "repo.issues.remove_labels" (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink .Issue.IsPull) ""}} 719 + {{else}} 720 + {{ctx.Locale.Tr "repo.issues.add_remove_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink .Issue.IsPull) (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink .Issue.IsPull) ""}} 721 + {{end}} 722 + </li> 723 + {{end}} 724 + 725 + {{if or .AddedRequestReview .RemovedRequestReview}} 726 + <li> 727 + <span class="badge">{{svg "octicon-tag" 20}}</span> 728 + <span class="text grey muted-links"> 729 + 730 + {{if and (eq (len .RemovedRequestReview) 1) (eq (len .AddedRequestReview) 0) (eq ((index .RemovedRequestReview 0).ID) .PosterID) (eq ((index .RemovedRequestReview 0).Type) "user")}} 731 + <span class="review-request-list">{{ctx.Locale.Tr "repo.issues.review.remove_review_request_self" ""}}</span> 732 + {{else if and .AddedRequestReview (not .RemovedRequestReview)}} 733 + {{ctx.Locale.TrN (len .AddedRequestReview) "repo.issues.review.add_review_request" "repo.issues.review.add_review_requests" (RenderReviewRequest .AddedRequestReview) ""}} 734 + {{else if and (not .AddedRequestReview) .RemovedRequestReview}} 735 + {{ctx.Locale.TrN (len .RemovedRequestReview) "repo.issues.review.remove_review_request" "repo.issues.review.remove_review_requests" (RenderReviewRequest .RemovedRequestReview) ""}} 736 + {{else}} 737 + {{ctx.Locale.Tr "repo.issues.review.add_remove_review_requests" (RenderReviewRequest .AddedRequestReview) (RenderReviewRequest .RemovedRequestReview) ""}} 738 + {{end}} 739 + 740 + </span> 741 + </li> 742 + {{end}} 743 + </ul> 677 744 </span> 678 745 </div> 679 746 {{end}}
+13 -1
web_src/css/repo.css
··· 759 759 760 760 .repository.view.issue .comment-list .timeline-item, 761 761 .repository.view.issue .comment-list .timeline-item-group { 762 - padding: 16px 0; 762 + padding: 0.65rem 0; 763 763 } 764 764 765 765 .repository.view.issue .comment-list .timeline-item-group .timeline-item { ··· 840 840 .repository.view.issue .comment-list .timeline-item.commits-list .ui.avatar, 841 841 .repository.view.issue .comment-list .timeline-item.event .ui.avatar { 842 842 margin-right: 0.25em; 843 + } 844 + 845 + .repository.view.issue .comment-list .timeline-item .aggregated-actions .badge { 846 + width: 20px; 847 + height: 20px; 848 + margin-top: 5px; 849 + padding: 12px; 850 + } 851 + 852 + .repository.view.issue .comment-list .timeline-item .aggregated-actions .badge .svg { 853 + width: 20px; 854 + height: 20px; 843 855 } 844 856 845 857 .singular-commit {