dashboard/app: flexible rules for bug obsoleting

Implement logic described in #1054:
 - close bugs that happened a lot and then stopped faster
 - close bugs in non-final reporting with different period
 - allow closing bugs that happened only on 1 manager with different period

Fixes #1054
diff --git a/dashboard/app/app_test.go b/dashboard/app/app_test.go
index 8b4e45b..89ef7ff 100644
--- a/dashboard/app/app_test.go
+++ b/dashboard/app/app_test.go
@@ -32,6 +32,12 @@
 	EmailBlacklist: []string{
 		"\"Bar\" <BlackListed@Domain.com>",
 	},
+	Obsoleting: ObsoletingConfig{
+		MinPeriod:         80 * 24 * time.Hour,
+		MaxPeriod:         100 * 24 * time.Hour,
+		NonFinalMinPeriod: 40 * 24 * time.Hour,
+		NonFinalMaxPeriod: 60 * 24 * time.Hour,
+	},
 	DefaultNamespace: "test1",
 	Namespaces: map[string]*Config{
 		"test1": {
@@ -54,6 +60,12 @@
 					CC:     []string{"maintainers@repo10.org", "bugs@repo10.org"},
 				},
 			},
+			Managers: map[string]ConfigManager{
+				"special-obsoleting": {
+					ObsoletingMinPeriod: 10 * 24 * time.Hour,
+					ObsoletingMaxPeriod: 20 * 24 * time.Hour,
+				},
+			},
 			Reporting: []Reporting{
 				{
 					Name:       "reporting1",
diff --git a/dashboard/app/config.go b/dashboard/app/config.go
index 34314d6..46a2b95 100644
--- a/dashboard/app/config.go
+++ b/dashboard/app/config.go
@@ -33,6 +33,8 @@
 	Clients map[string]string
 	// List of emails blacklisted from issuing test requests.
 	EmailBlacklist []string
+	// Bug obsoleting settings. See ObsoletingConfig for details.
+	Obsoleting ObsoletingConfig
 	// Namespace that is shown by default (no namespace selected yet).
 	DefaultNamespace string
 	// Per-namespace config.
@@ -78,6 +80,22 @@
 	Repos []KernelRepo
 }
 
+// ObsoletingConfig describes how bugs without reproducer should be obsoleted.
+// First, for each bug we conservatively estimate period since the last crash
+// when we consider it stopped happenning. This estimation is based on the first/last time
+// and number and rate of crashes. Then this period is capped by MinPeriod/MaxPeriod.
+// Then if the period has elapsed since the last crash, we obsolete the bug.
+// NonFinalMinPeriod/NonFinalMaxPeriod (if specified) are used to cap bugs in non-final reportings.
+// Additionally ConfigManager.ObsoletingMin/MaxPeriod override the cap settings
+// for bugs that happen only on that manager.
+// If no periods are specified, no bugs are obsoleted.
+type ObsoletingConfig struct {
+	MinPeriod         time.Duration
+	MaxPeriod         time.Duration
+	NonFinalMinPeriod time.Duration
+	NonFinalMaxPeriod time.Duration
+}
+
 // ConfigManager describes a single syz-manager instance.
 // Dashboard does not generally need to know about all of them,
 // but in some special cases it needs to know some additional information.
@@ -90,6 +108,10 @@
 	// and RestrictedTestingReason contains a human readable reason for the restriction.
 	RestrictedTestingRepo   string
 	RestrictedTestingReason string
+	// If a bug happens only on this manager, this overrides global obsoleting settings.
+	// See ObsoletingConfig for details.
+	ObsoletingMinPeriod time.Duration
+	ObsoletingMaxPeriod time.Duration
 }
 
 // One reporting stage.
@@ -200,6 +222,7 @@
 	clientNames := make(map[string]bool)
 	checkClients(clientNames, cfg.Clients)
 	checkConfigAccessLevel(&cfg.AccessLevel, AccessPublic, "global")
+	checkObsoleting(cfg.Obsoleting)
 	if cfg.Namespaces[cfg.DefaultNamespace] == nil {
 		panic(fmt.Sprintf("default namespace %q is not found", cfg.DefaultNamespace))
 	}
@@ -208,6 +231,30 @@
 	}
 }
 
+func checkObsoleting(o ObsoletingConfig) {
+	if (o.MinPeriod == 0) != (o.MaxPeriod == 0) {
+		panic(fmt.Sprintf("obsoleting: both or none of Min/MaxPeriod must be specified"))
+	}
+	if o.MinPeriod > o.MaxPeriod {
+		panic(fmt.Sprintf("obsoleting: Min > MaxPeriod"))
+	}
+	if o.MinPeriod != 0 && o.MinPeriod < 24*time.Hour {
+		panic(fmt.Sprintf("obsoleting: too low MinPeriod"))
+	}
+	if (o.NonFinalMinPeriod == 0) != (o.NonFinalMaxPeriod == 0) {
+		panic(fmt.Sprintf("obsoleting: both or none of NonFinalMin/MaxPeriod must be specified"))
+	}
+	if o.NonFinalMinPeriod > o.NonFinalMaxPeriod {
+		panic(fmt.Sprintf("obsoleting: NonFinalMin > MaxPeriod"))
+	}
+	if o.NonFinalMinPeriod != 0 && o.NonFinalMinPeriod < 24*time.Hour {
+		panic(fmt.Sprintf("obsoleting: too low MinPeriod"))
+	}
+	if o.MinPeriod == 0 && o.NonFinalMinPeriod != 0 {
+		panic(fmt.Sprintf("obsoleting: NonFinalMinPeriod without MinPeriod"))
+	}
+}
+
 func checkNamespace(ns string, cfg *Config, namespaces, clientNames map[string]bool) {
 	if !namespaceNameRe.MatchString(ns) {
 		panic(fmt.Sprintf("bad namespace name: %q", ns))
@@ -324,6 +371,15 @@
 	if mgr.RestrictedTestingRepo == "" && mgr.RestrictedTestingReason != "" {
 		panic(fmt.Sprintf("unrestricted manager %v/%v has restriction reason", ns, name))
 	}
+	if (mgr.ObsoletingMinPeriod == 0) != (mgr.ObsoletingMaxPeriod == 0) {
+		panic(fmt.Sprintf("manager %v/%v obsoleting: both or none of Min/MaxPeriod must be specified", ns, name))
+	}
+	if mgr.ObsoletingMinPeriod > mgr.ObsoletingMaxPeriod {
+		panic(fmt.Sprintf("manager %v/%v obsoleting: Min > MaxPeriod", ns, name))
+	}
+	if mgr.ObsoletingMinPeriod != 0 && mgr.ObsoletingMinPeriod < 24*time.Hour {
+		panic(fmt.Sprintf("manager %v/%v obsoleting: too low MinPeriod", ns, name))
+	}
 }
 
 func checkConfigAccessLevel(current *AccessLevel, parent AccessLevel, what string) {
diff --git a/dashboard/app/notifications_test.go b/dashboard/app/notifications_test.go
index bbd5d7c..3437576 100644
--- a/dashboard/app/notifications_test.go
+++ b/dashboard/app/notifications_test.go
@@ -102,6 +102,81 @@
 	}
 }
 
+func TestBugObsoleting(t *testing.T) {
+	// To simplify test we specify all dates in days from a fixed point in time.
+	const day = 24 * time.Hour
+	days := func(n int) time.Time {
+		t := time.Date(2000, 0, 0, 0, 0, 0, 0, time.UTC)
+		t.Add(time.Duration(n+1) * day)
+		return t
+	}
+	tests := []struct {
+		bug    *Bug
+		period time.Duration
+	}{
+		// Final bug with just 1 crash: max final period.
+		{
+			bug: &Bug{
+				FirstTime:  days(0),
+				LastTime:   days(0),
+				NumCrashes: 1,
+				Reporting:  []BugReporting{{Reported: days(0)}},
+			},
+			period: 100 * day,
+		},
+		// Non-final bug with just 1 crash: max non-final period.
+		{
+			bug: &Bug{
+				FirstTime:  days(0),
+				LastTime:   days(0),
+				NumCrashes: 1,
+				Reporting:  []BugReporting{{Reported: days(0)}, {}},
+			},
+			period: 60 * day,
+		},
+		// Special manger: max period that that manager.
+		{
+			bug: &Bug{
+				FirstTime:  days(0),
+				LastTime:   days(0),
+				NumCrashes: 1,
+				HappenedOn: []string{"special-obsoleting"},
+				Reporting:  []BugReporting{{Reported: days(0)}, {}},
+			},
+			period: 20 * day,
+		},
+		// Special manger and a non-special: normal rules.
+		{
+			bug: &Bug{
+				FirstTime:  days(0),
+				LastTime:   days(0),
+				NumCrashes: 1,
+				HappenedOn: []string{"special-obsoleting", "non-special-manager"},
+				Reporting:  []BugReporting{{Reported: days(0)}},
+			},
+			period: 100 * day,
+		},
+		// Happened a lot: min period.
+		{
+			bug: &Bug{
+				FirstTime:  days(0),
+				LastTime:   days(1),
+				NumCrashes: 1000,
+				Reporting:  []BugReporting{{Reported: days(0)}},
+			},
+			period: 80 * day,
+		},
+	}
+	for i, test := range tests {
+		test.bug.Namespace = "test1"
+		got := test.bug.obsoletePeriod()
+		if got != test.period {
+			t.Errorf("test #%v: got: %.2f, want %.2f",
+				i, float64(got/time.Hour)/24, float64(test.period/time.Hour)/24)
+		}
+	}
+}
+
 func TestEmailNotifObsoleted(t *testing.T) {
 	c := NewCtx(t)
 	defer c.Close()
@@ -124,7 +199,7 @@
 	c.expectNoEmail()
 
 	// Not yet.
-	c.advanceTime(119 * 24 * time.Hour)
+	c.advanceTime(59 * 24 * time.Hour)
 	c.expectNoEmail()
 
 	// Now!
@@ -145,7 +220,7 @@
 	c.incomingEmail(report.Sender, "#syz upstream")
 	report = c.pollEmailBug()
 
-	c.advanceTime(121 * 24 * time.Hour)
+	c.advanceTime(101 * 24 * time.Hour)
 	notif = c.pollEmailBug()
 	if !strings.Contains(notif.Body, "Auto-closing this bug as obsolete") {
 		t.Fatalf("bad notification text: %q", notif.Body)
@@ -190,7 +265,7 @@
 	c.incomingEmail(report4.Sender, "#syz upstream")
 	report4 = c.pollEmailBug()
 
-	c.advanceTime(119 * 24 * time.Hour)
+	c.advanceTime(59 * 24 * time.Hour)
 	c.expectNoEmail()
 
 	c.client2.ReportCrash(crash2)
diff --git a/dashboard/app/reporting.go b/dashboard/app/reporting.go
index 99adb19..cb3f52c 100644
--- a/dashboard/app/reporting.go
+++ b/dashboard/app/reporting.go
@@ -31,7 +31,7 @@
 	maxInlineError             = 16 << 10
 	notifyResendPeriod         = 14 * 24 * time.Hour
 	notifyAboutBadCommitPeriod = 90 * 24 * time.Hour
-	autoObsoletePeriod         = 120 * 24 * time.Hour
+	never                      = 100 * 365 * 24 * time.Hour
 	internalError              = "internal error"
 	// This is embedded as first line of syzkaller reproducer files.
 	syzReproPrefix = "# See https://goo.gl/kgGztJ for information about syzkaller reproducers.\n"
@@ -207,7 +207,7 @@
 	if len(bug.Commits) == 0 &&
 		bug.ReproLevel == ReproLevelNone &&
 		timeSince(c, bug.LastActivity) > notifyResendPeriod &&
-		timeSince(c, bug.LastTime) > autoObsoletePeriod {
+		timeSince(c, bug.LastTime) > bug.obsoletePeriod() {
 		log.Infof(c, "%v: obsoleting: %v", bug.Namespace, bug.Title)
 		return createNotification(c, dashapi.BugNotifObsoleted, false, "", bug, reporting, bugReporting)
 	}
@@ -222,6 +222,39 @@
 	return nil, nil
 }
 
+func (bug *Bug) obsoletePeriod() time.Duration {
+	period := never
+	if config.Obsoleting.MinPeriod == 0 {
+		return period
+	}
+	// Before we have at least 10 crashes, any estimation of frequency is too imprecise.
+	// In such case we conservatively assume it still happens.
+	if bug.NumCrashes >= 10 {
+		// This is linear extrapolation for when the next crash should happen.
+		period = bug.LastTime.Sub(bug.FirstTime) / time.Duration(bug.NumCrashes-1)
+		// Let's be conservative with obsoleting too early.
+		period *= 100
+	}
+	min, max := config.Obsoleting.MinPeriod, config.Obsoleting.MaxPeriod
+	if config.Obsoleting.NonFinalMinPeriod != 0 &&
+		bug.Reporting[len(bug.Reporting)-1].Reported.IsZero() {
+		min, max = config.Obsoleting.NonFinalMinPeriod, config.Obsoleting.NonFinalMaxPeriod
+	}
+	if len(bug.HappenedOn) == 1 {
+		mgr := config.Namespaces[bug.Namespace].Managers[bug.HappenedOn[0]]
+		if mgr.ObsoletingMinPeriod != 0 {
+			min, max = mgr.ObsoletingMinPeriod, mgr.ObsoletingMaxPeriod
+		}
+	}
+	if period < min {
+		period = min
+	}
+	if period > max {
+		period = max
+	}
+	return period
+}
+
 func createNotification(c context.Context, typ dashapi.BugNotif, public bool, text string, bug *Bug,
 	reporting *Reporting, bugReporting *BugReporting) (*dashapi.BugNotification, error) {
 	reportingConfig, err := json.Marshal(reporting.Config)