mirror of
1
Fork 0
forgejo/models/issues/action_aggregator.go

376 lines
10 KiB
Go
Raw Normal View History

// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package issues
import (
"slices"
"code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
)
type ActionAggregator struct {
StartUnix int64
AggAge int64
PosterID int64
StartInd int
EndInd int
PrevClosed bool
IsClosed bool
AddedLabels []*Label
RemovedLabels []*Label
AddedRequestReview []RequestReviewTarget
RemovedRequestReview []RequestReviewTarget
}
// Get the time threshold for aggregation of multiple actions together
func (agg *ActionAggregator) timeThreshold() int64 {
if agg.AggAge > (60 * 60 * 24 * 30) { // Age > 1 month, aggregate by day
return 60 * 60 * 24
} else if agg.AggAge > (60 * 60 * 24) { // Age > 1 day, aggregate by hour
return 60 * 60
} else if agg.AggAge > (60 * 60) { // Age > 1 hour, aggregate by 10 mins
return 60 * 10
}
// Else, aggregate by minute
return 60
}
// TODO Aggregate also
// - Dependency added / removed
// - Added / Removed due date
// - Milestone Added / Removed
func (agg *ActionAggregator) aggregateAction(c *Comment, index int) {
if agg.StartInd == -1 {
agg.StartInd = index
}
agg.EndInd = 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 *ActionAggregator) Merge(next *ActionAggregator) {
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 *ActionAggregator) IsAggregated(t *CommentType) bool {
switch *t {
case CommentTypeAggregator, CommentTypeClose, CommentTypeReopen, CommentTypeLabel, CommentTypeReviewRequest:
{
return true
}
default:
{
return false
}
}
}
// Add a label to the aggregated list
func (agg *ActionAggregator) addLabel(lbl *Label) {
for l, agglbl := range agg.RemovedLabels {
if agglbl.ID == lbl.ID {
agg.RemovedLabels = slices.Delete(agg.RemovedLabels, l, 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 *ActionAggregator) delLabel(lbl *Label) {
for l, agglbl := range agg.AddedLabels {
if agglbl.ID == lbl.ID {
agg.AddedLabels = slices.Delete(agg.AddedLabels, l, 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 *ActionAggregator) addReviewRequest(req RequestReviewTarget) {
reqid := req.ID()
reqty := req.Type()
for r, aggreq := range agg.RemovedRequestReview {
if (aggreq.ID() == reqid) && (aggreq.Type() == reqty) {
agg.RemovedRequestReview = slices.Delete(agg.RemovedRequestReview, r, 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 *ActionAggregator) delReviewRequest(req RequestReviewTarget) {
reqid := req.ID()
reqty := req.Type()
for r, aggreq := range agg.AddedRequestReview {
if (aggreq.ID() == reqid) && (aggreq.Type() == reqty) {
agg.AddedRequestReview = slices.Delete(agg.AddedRequestReview, r, 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 *ActionAggregator) Changed() bool {
return (agg.IsClosed != agg.PrevClosed) ||
(len(agg.AddedLabels) > 0) ||
(len(agg.RemovedLabels) > 0) ||
(len(agg.AddedRequestReview) > 0) ||
(len(agg.RemovedRequestReview) > 0)
}
func (agg *ActionAggregator) OnlyLabelsChanged() bool {
return ((len(agg.AddedLabels) > 0) || (len(agg.RemovedLabels) > 0)) &&
(len(agg.AddedRequestReview) == 0) && (len(agg.RemovedRequestReview) == 0) &&
(agg.PrevClosed == agg.IsClosed)
}
func (agg *ActionAggregator) OnlyRequestReview() bool {
return ((len(agg.AddedRequestReview) > 0) || (len(agg.RemovedRequestReview) > 0)) &&
(len(agg.AddedLabels) == 0) && (len(agg.RemovedLabels) == 0) &&
(agg.PrevClosed == agg.IsClosed)
}
func (agg *ActionAggregator) OnlyClosedReopened() bool {
return (agg.IsClosed != agg.PrevClosed) &&
(len(agg.AddedLabels) == 0) && (len(agg.RemovedLabels) == 0) &&
(len(agg.AddedRequestReview) == 0) && (len(agg.RemovedRequestReview) == 0)
}
// Reset the aggregator to start a new aggregating context
func (agg *ActionAggregator) Reset(cur *Comment, now int64) {
agg.StartUnix = int64(cur.CreatedUnix)
agg.AggAge = now - agg.StartUnix
agg.PosterID = cur.PosterID
agg.PrevClosed = agg.IsClosed
agg.StartInd = -1
agg.EndInd = -1
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 *ActionAggregator) createAggregatedComment(issue *Issue, final bool) int {
// If the aggregation of comments make the whole thing null, erase all the comments
if !agg.Changed() {
if final {
issue.Comments = issue.Comments[:agg.StartInd]
} else {
issue.Comments = slices.Replace(issue.Comments, agg.StartInd, agg.EndInd+1)
}
return (agg.EndInd - agg.StartInd) + 1
}
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[agg.StartInd]
comment.Content = ""
comment.Label = nil
comment.Aggregator = nil
comment.Assignee = nil
comment.AssigneeID = 0
comment.AssigneeTeam = nil
comment.AssigneeTeamID = 0
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[:agg.StartInd], comment)
} else {
issue.Comments = slices.Replace(issue.Comments, agg.StartInd, agg.EndInd+1, comment)
}
return agg.EndInd - agg.StartInd
}
// combineCommentsHistory combines nearby elements in the history as one
func CombineCommentsHistory(issue *Issue, now int64) {
if len(issue.Comments) < 1 {
return
}
// Initialise a new empty aggregator, ready to combine comments
var agg ActionAggregator
agg.Reset(issue.Comments[0], now)
for i := 0; i < len(issue.Comments); i++ {
cur := issue.Comments[i]
// 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 agg.StartInd != -1 {
i -= agg.createAggregatedComment(issue, false)
}
agg.StartInd = -1
if i+1 < len(issue.Comments) {
agg.Reset(issue.Comments[i+1], now)
}
// 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
threshold := agg.timeThreshold()
if ((int64(cur.CreatedUnix) - agg.StartUnix) > threshold) || (cur.PosterID != agg.PosterID) {
// First, create the aggregated comment if there's data in it
if agg.StartInd != -1 {
i -= agg.createAggregatedComment(issue, false)
}
agg.Reset(cur, now)
}
agg.aggregateAction(cur, i)
}
// Create the aggregated comment if there's data in it
if agg.StartInd != -1 {
agg.createAggregatedComment(issue, true)
}
}
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"
}