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  }