github.com/google/syzkaller@v0.0.0-20240517125934-c0f1611a36d6/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 err = db.RunInTransaction(c, tx, &db.TransactionOptions{Attempts: 15, XG: true}) 91 if err != nil { 92 return err 93 } 94 // Update individual bug statistics. 95 // We have to do it outside of the main transaction, as we might hit the "operating on 96 // too many entity groups in a single transaction." error. 97 for _, key := range d.BugKeys { 98 err := db.RunInTransaction(c, func(c context.Context) error { 99 return mergeDiscussionSummary(c, key, d.Source, diff) 100 }, &db.TransactionOptions{Attempts: 15}) 101 if err != nil { 102 return fmt.Errorf("failed to put update summary for %s: %w", key, err) 103 } 104 } 105 return nil 106 } 107 108 func mergeDiscussionSummary(c context.Context, key, source string, diff DiscussionSummary) error { 109 bug := new(Bug) 110 bugKey := db.NewKey(c, "Bug", key, 0, nil) 111 if err := db.Get(c, bugKey, bug); err != nil { 112 return fmt.Errorf("failed to get bug: %w", err) 113 } 114 var record *BugDiscussionInfo 115 for i, item := range bug.DiscussionInfo { 116 if item.Source == source { 117 record = &bug.DiscussionInfo[i] 118 } 119 } 120 if record == nil { 121 bug.DiscussionInfo = append(bug.DiscussionInfo, BugDiscussionInfo{ 122 Source: source, 123 }) 124 record = &bug.DiscussionInfo[len(bug.DiscussionInfo)-1] 125 } 126 record.Summary.merge(diff) 127 if _, err := db.Put(c, bugKey, bug); err != nil { 128 return fmt.Errorf("failed to put bug: %w", err) 129 } 130 return nil 131 } 132 133 func (ds *DiscussionSummary) merge(diff DiscussionSummary) { 134 ds.AllMessages += diff.AllMessages 135 ds.ExternalMessages += diff.ExternalMessages 136 if ds.LastMessage.Before(diff.LastMessage) { 137 ds.LastMessage = diff.LastMessage 138 } 139 if ds.LastPatchMessage.Before(diff.LastPatchMessage) { 140 ds.LastPatchMessage = diff.LastPatchMessage 141 } 142 } 143 144 func (bug *Bug) discussionSummary() DiscussionSummary { 145 // TODO: if there ever appear any non-public DiscussionSource, we'll need to consider 146 // their accessLevel as well. 147 var ret DiscussionSummary 148 for _, item := range bug.DiscussionInfo { 149 ret.merge(item.Summary) 150 } 151 return ret 152 } 153 154 const maxMessagesInDiscussion = 1500 155 156 func (d *Discussion) addMessages(messages []dashapi.DiscussionMessage) DiscussionSummary { 157 var diff DiscussionSummary 158 existingIDs := d.messageIDs() 159 for _, m := range messages { 160 if _, ok := existingIDs[m.ID]; ok { 161 continue 162 } 163 existingIDs[m.ID] = struct{}{} 164 diff.AllMessages++ 165 if m.External { 166 diff.ExternalMessages++ 167 } 168 if diff.LastMessage.Before(m.Time) { 169 diff.LastMessage = m.Time 170 } 171 d.Messages = append(d.Messages, DiscussionMessage{ 172 ID: m.ID, 173 External: m.External, 174 Time: m.Time, 175 }) 176 } 177 if len(d.Messages) == 0 { 178 return diff 179 } 180 sort.Slice(d.Messages, func(i, j int) bool { 181 return d.Messages[i].Time.Before(d.Messages[j].Time) 182 }) 183 // Always keep the oldest message. 184 first := d.Messages[0] 185 if len(d.Messages) > maxMessagesInDiscussion { 186 d.Messages = append([]DiscussionMessage{first}, 187 d.Messages[len(d.Messages)-maxMessagesInDiscussion+1:]...) 188 } 189 return diff 190 } 191 192 func (d *Discussion) messageIDs() map[string]struct{} { 193 ret := map[string]struct{}{} 194 for _, m := range d.Messages { 195 ret[m.ID] = struct{}{} 196 } 197 return ret 198 } 199 200 func (d *Discussion) link() string { 201 switch dashapi.DiscussionSource(d.Source) { 202 case dashapi.DiscussionLore: 203 return fmt.Sprintf("https://lore.kernel.org/all/%s/T/", strings.Trim(d.ID, "<>")) 204 } 205 return "" 206 } 207 208 func discussionByMessageID(c context.Context, source dashapi.DiscussionSource, 209 msgID string) (*Discussion, error) { 210 var discussions []*Discussion 211 keys, err := db.NewQuery("Discussion"). 212 Filter("Source=", source). 213 Filter("Messages.ID=", msgID). 214 Limit(2). 215 GetAll(c, &discussions) 216 if err != nil { 217 return nil, err 218 } else if len(keys) == 0 { 219 return nil, db.ErrNoSuchEntity 220 } else if len(keys) == 2 { 221 // TODO: consider merging discussions in this case. 222 return nil, fmt.Errorf("message %s is present in several discussions", msgID) 223 } 224 return discussions[0], nil 225 } 226 227 func discussionsForBug(c context.Context, bugKey *db.Key) ([]*Discussion, error) { 228 var discussions []*Discussion 229 _, err := db.NewQuery("Discussion"). 230 Filter("BugKeys=", bugKey.StringID()). 231 GetAll(c, &discussions) 232 if err != nil { 233 return nil, err 234 } 235 return discussions, nil 236 } 237 238 func getBugKeys(c context.Context, bugIDs []string) ([]string, error) { 239 keys := []string{} 240 for _, id := range bugIDs { 241 _, bugKey, err := findBugByReportingID(c, id) 242 if err != nil { 243 return nil, fmt.Errorf("failed to find bug for %s: %w", id, err) 244 } 245 keys = append(keys, bugKey.StringID()) 246 } 247 return keys, nil 248 } 249 250 func unique(items []string) []string { 251 dup := map[string]struct{}{} 252 ret := []string{} 253 for _, item := range items { 254 if _, ok := dup[item]; ok { 255 continue 256 } 257 dup[item] = struct{}{} 258 ret = append(ret, item) 259 } 260 return ret 261 }