github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/dashboard/app/discussion.go (about) 1 // Copyright 2023 syzkaller project authors. All rights reserved. 2 // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. 3 4 package main 5 6 import ( 7 "context" 8 "fmt" 9 "sort" 10 "strings" 11 12 "github.com/google/syzkaller/dashboard/dashapi" 13 "github.com/google/syzkaller/pkg/email" 14 db "google.golang.org/appengine/v2/datastore" 15 ) 16 17 // saveDiscussionMessage is meant to be called after each received E-mail message, 18 // for which we know the BugID. 19 func saveDiscussionMessage(c context.Context, msg *email.Email, 20 msgSource dashapi.DiscussionSource, msgType dashapi.DiscussionType) error { 21 discUpdate := &dashapi.Discussion{ 22 Source: msgSource, 23 Type: msgType, 24 BugIDs: msg.BugIDs, 25 } 26 var parent *Discussion 27 var oldThreadInfo *email.OldThreadInfo 28 if msg.InReplyTo != "" { 29 parent, _ = discussionByMessageID(c, msgSource, msg.InReplyTo) 30 if parent != nil { 31 oldThreadInfo = &email.OldThreadInfo{ 32 ThreadType: dashapi.DiscussionType(parent.Type), 33 } 34 } 35 } 36 switch email.NewMessageAction(msg, msgType, oldThreadInfo) { 37 case email.ActionIgnore: 38 return nil 39 case email.ActionAppend: 40 discUpdate.ID = parent.ID 41 discUpdate.Type = oldThreadInfo.ThreadType 42 case email.ActionNewThread: 43 // Use the current message as the discussion's head. 44 discUpdate.ID = msg.MessageID 45 discUpdate.Subject = msg.Subject 46 } 47 discUpdate.Messages = append(discUpdate.Messages, dashapi.DiscussionMessage{ 48 ID: msg.MessageID, 49 Time: msg.Date, 50 External: !msg.OwnEmail, 51 }) 52 return mergeDiscussion(c, discUpdate) 53 } 54 55 // mergeDiscussion either creates a new discussion or updates the existing one. 56 // It is assumed that the input is valid. 57 func mergeDiscussion(c context.Context, update *dashapi.Discussion) error { 58 if len(update.Messages) == 0 { 59 return fmt.Errorf("no messages") 60 } 61 newBugKeys, err := getBugKeys(c, update.BugIDs) 62 if err != nil { 63 return nil 64 } 65 // First update the discussion itself. 66 d := new(Discussion) 67 var diff DiscussionSummary 68 tx := func(c context.Context) error { 69 err := db.Get(c, discussionKey(c, string(update.Source), update.ID), d) 70 if err != nil && err != db.ErrNoSuchEntity { 71 return fmt.Errorf("failed to query Discussion: %w", err) 72 } else if err == db.ErrNoSuchEntity { 73 d.ID = update.ID 74 d.Source = string(update.Source) 75 d.Type = string(update.Type) 76 d.Subject = update.Subject 77 } 78 d.BugKeys = unique(append(d.BugKeys, newBugKeys...)) 79 diff = d.addMessages(update.Messages) 80 if d.Type == string(dashapi.DiscussionPatch) { 81 diff.LastPatchMessage = diff.LastMessage 82 } 83 d.Summary.merge(diff) 84 _, err = db.Put(c, d.key(c), d) 85 if err != nil { 86 return fmt.Errorf("failed to put Discussion: %w", err) 87 } 88 return nil 89 } 90 if err = runInTransaction(c, tx, &db.TransactionOptions{XG: true}); err != nil { 91 return err 92 } 93 // Update individual bug statistics. 94 // We have to do it outside of the main transaction, as we might hit the "operating on 95 // too many entity groups in a single transaction." error. 96 for _, key := range d.BugKeys { 97 if err := runInTransaction(c, func(c context.Context) error { 98 return mergeDiscussionSummary(c, key, d.Source, diff) 99 }, nil); err != nil { 100 return fmt.Errorf("failed to put update summary for %s: %w", key, err) 101 } 102 } 103 return nil 104 } 105 106 func mergeDiscussionSummary(c context.Context, key, source string, diff DiscussionSummary) error { 107 bug := new(Bug) 108 bugKey := db.NewKey(c, "Bug", key, 0, nil) 109 if err := db.Get(c, bugKey, bug); err != nil { 110 return fmt.Errorf("failed to get bug: %w", err) 111 } 112 var record *BugDiscussionInfo 113 for i, item := range bug.DiscussionInfo { 114 if item.Source == source { 115 record = &bug.DiscussionInfo[i] 116 } 117 } 118 if record == nil { 119 bug.DiscussionInfo = append(bug.DiscussionInfo, BugDiscussionInfo{ 120 Source: source, 121 }) 122 record = &bug.DiscussionInfo[len(bug.DiscussionInfo)-1] 123 } 124 record.Summary.merge(diff) 125 if _, err := db.Put(c, bugKey, bug); err != nil { 126 return fmt.Errorf("failed to put bug: %w", err) 127 } 128 return nil 129 } 130 131 func (ds *DiscussionSummary) merge(diff DiscussionSummary) { 132 ds.AllMessages += diff.AllMessages 133 ds.ExternalMessages += diff.ExternalMessages 134 if ds.LastMessage.Before(diff.LastMessage) { 135 ds.LastMessage = diff.LastMessage 136 } 137 if ds.LastPatchMessage.Before(diff.LastPatchMessage) { 138 ds.LastPatchMessage = diff.LastPatchMessage 139 } 140 } 141 142 func (bug *Bug) discussionSummary() DiscussionSummary { 143 // TODO: if there ever appear any non-public DiscussionSource, we'll need to consider 144 // their accessLevel as well. 145 var ret DiscussionSummary 146 for _, item := range bug.DiscussionInfo { 147 ret.merge(item.Summary) 148 } 149 return ret 150 } 151 152 const maxMessagesInDiscussion = 1500 153 154 func (d *Discussion) addMessages(messages []dashapi.DiscussionMessage) DiscussionSummary { 155 var diff DiscussionSummary 156 existingIDs := d.messageIDs() 157 for _, m := range messages { 158 if _, ok := existingIDs[m.ID]; ok { 159 continue 160 } 161 existingIDs[m.ID] = struct{}{} 162 diff.AllMessages++ 163 if m.External { 164 diff.ExternalMessages++ 165 } 166 if diff.LastMessage.Before(m.Time) { 167 diff.LastMessage = m.Time 168 } 169 d.Messages = append(d.Messages, DiscussionMessage{ 170 ID: m.ID, 171 External: m.External, 172 Time: m.Time, 173 }) 174 } 175 if len(d.Messages) == 0 { 176 return diff 177 } 178 sort.Slice(d.Messages, func(i, j int) bool { 179 return d.Messages[i].Time.Before(d.Messages[j].Time) 180 }) 181 // Always keep the oldest message. 182 first := d.Messages[0] 183 if len(d.Messages) > maxMessagesInDiscussion { 184 d.Messages = append([]DiscussionMessage{first}, 185 d.Messages[len(d.Messages)-maxMessagesInDiscussion+1:]...) 186 } 187 return diff 188 } 189 190 func (d *Discussion) messageIDs() map[string]struct{} { 191 ret := map[string]struct{}{} 192 for _, m := range d.Messages { 193 ret[m.ID] = struct{}{} 194 } 195 return ret 196 } 197 198 func (d *Discussion) link() string { 199 switch dashapi.DiscussionSource(d.Source) { 200 case dashapi.DiscussionLore: 201 return fmt.Sprintf("https://lore.kernel.org/all/%s/T/", strings.Trim(d.ID, "<>")) 202 } 203 return "" 204 } 205 206 func discussionByMessageID(c context.Context, source dashapi.DiscussionSource, 207 msgID string) (*Discussion, error) { 208 var discussions []*Discussion 209 keys, err := db.NewQuery("Discussion"). 210 Filter("Source=", source). 211 Filter("Messages.ID=", msgID). 212 Limit(2). 213 GetAll(c, &discussions) 214 if err != nil { 215 return nil, err 216 } else if len(keys) == 0 { 217 return nil, db.ErrNoSuchEntity 218 } else if len(keys) == 2 { 219 // TODO: consider merging discussions in this case. 220 return nil, fmt.Errorf("message %s is present in several discussions", msgID) 221 } 222 return discussions[0], nil 223 } 224 225 func discussionsForBug(c context.Context, bugKey *db.Key) ([]*Discussion, error) { 226 var discussions []*Discussion 227 _, err := db.NewQuery("Discussion"). 228 Filter("BugKeys=", bugKey.StringID()). 229 GetAll(c, &discussions) 230 if err != nil { 231 return nil, err 232 } 233 return discussions, nil 234 } 235 236 func getBugKeys(c context.Context, bugIDs []string) ([]string, error) { 237 keys := []string{} 238 for _, id := range bugIDs { 239 _, bugKey, err := findBugByReportingID(c, id) 240 if err != nil { 241 return nil, fmt.Errorf("failed to find bug for %s: %w", id, err) 242 } 243 keys = append(keys, bugKey.StringID()) 244 } 245 return keys, nil 246 } 247 248 func unique(items []string) []string { 249 dup := map[string]struct{}{} 250 ret := []string{} 251 for _, item := range items { 252 if _, ok := dup[item]; ok { 253 continue 254 } 255 dup[item] = struct{}{} 256 ret = append(ret, item) 257 } 258 return ret 259 }