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  }