// 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" }