| // Copyright 2026 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package main |
| |
| import ( |
| "fmt" |
| "log" |
| "maps" |
| "math" |
| "regexp" |
| "slices" |
| "strings" |
| "time" |
| |
| "golang.org/x/build/cmd/watchflakes/internal/script" |
| ) |
| |
| // Automatic close parameters. |
| const ( |
| // closeAlpha is the smallest probability of the observed silence, |
| // assuming the flake rate has not changed, that we are willing to |
| // dismiss as coincidence. Each quiet stretch on an issue is one |
| // chance to close it wrongly, so an issue with k flakes over its |
| // lifetime has roughly a 1-(1-closeAlpha)^k chance of being closed |
| // at least once by accident: 18% at k=20 for closeAlpha=0.01, |
| // versus 64% for closeAlpha=0.05. |
| closeAlpha = 0.01 |
| |
| // closeMaxSilence is the longest an issue is ever left open while quiet, |
| // whatever the statistics ask for. |
| // |
| // Two things need it. Some issues have no usable rate at all: a single |
| // flake, or several packed into less than closeMinSpan, leave nothing to |
| // test against. Others have a rate that asks for more silence than is |
| // worth waiting for, because the required wait scales with the span |
| // the flakes cover: two flakes a month apart ask for eight years, and |
| // three flakes over two months ask for eighteen months, so in practice |
| // those issues never close at all. |
| // |
| // Capping the wait gives up calibration for the sparse cases, which will |
| // be closed while still live more often than closeAlpha allows. The cost |
| // of being wrong is one automatic reopen, and the worst case is an issue |
| // that flakes just slower than the cap, which then closes and reopens |
| // every 91 days. |
| closeMaxSilence = 90 * 24 * time.Hour |
| |
| // closeMinSpan is the shortest span of flakes that gives a usable rate. |
| // Flakes closer together than this are a single burst, not a rate. |
| closeMinSpan = 24 * time.Hour |
| |
| // closeMinBuilds is how many builds must have run since the last flake, |
| // on the builders that produced this issue's flakes, before silence |
| // counts as evidence. Without it, an issue goes quiet when the tree is |
| // frozen or the fleet is down, neither of which says anything about |
| // whether the flake was fixed. |
| closeMinBuilds = 20 |
| |
| // closeRetiredQuiet is how long the builders that produced an issue's |
| // flakes must go without running anything before we call them retired |
| // and close the issue on those grounds instead. A renamed builder counts |
| // as retired under its old name, which is the right answer: the failures |
| // this issue collected cannot recur while nothing runs under that name, |
| // and anything the new name produces gets an issue of its own. |
| // |
| // Ideally this would be longer, but the dashboards only reach back |
| // timeLimit, so a longer stretch of no builds is not observable. |
| closeRetiredQuiet = 30 * 24 * time.Hour |
| |
| // closeUntrackDelay is how long an issue stays in the Test Flakes project |
| // after being closed. Once it has been closed that long without a flake |
| // reopening it, watchflakes stops watching it, and a recurrence after |
| // that gets a new issue instead of reviving an old one. This applies |
| // however the issue was closed: one closed by a fix waits the same time, |
| // measured from the fix, as one closed here for looking fixed. |
| closeUntrackDelay = 90 * 24 * time.Hour |
| |
| // closeCheckPeriod is the minimum time between closing passes. A pass |
| // reads the comments on every candidate issue, which is too many round |
| // trips to repeat on every -repeat cycle. |
| closeCheckPeriod = 24 * time.Hour |
| ) |
| |
| // autoIssueMarker is the sentence postNew puts in the body of every issue |
| // watchflakes files. Its absence means a human has rewritten the issue into |
| // something that is no longer ours to close. |
| const autoIssueMarker = "Issue created automatically to collect these failures." |
| |
| // lastCloseCheck is when the last closing pass ran, for closeCheckPeriod. |
| var lastCloseCheck time.Time |
| |
| // closeIssues closes the issues whose flakes appear to have stopped for good. |
| // It reports what it decided for every issue it considered, and closes them |
| // only if both -post and -close are set. |
| func closeIssues(issues []*Issue, boards []*Dashboard, known map[string]bool) { |
| if !lastCloseCheck.IsZero() && time.Since(lastCloseCheck) < closeCheckPeriod { |
| return |
| } |
| lastCloseCheck = time.Now() |
| |
| act := newBuilderActivity(boards, known) |
| now := time.Now() |
| closed, considered := 0, 0 |
| for _, issue := range issues { |
| // Check what we can before spending a round trip on comments. |
| if why := closeCandidate(issue); why != "" { |
| if *verbose { |
| fmt.Printf(" - keeping #%d: %s\n", issue.Number, why) |
| } |
| continue |
| } |
| considered++ |
| readComments(issue) |
| stats, why := shouldClose(issue, act, now) |
| if why != "" { |
| if *verbose { |
| fmt.Printf(" - keeping #%d: %s\n", issue.Number, why) |
| } |
| continue |
| } |
| if stats.Retired { |
| fmt.Printf(" - closing #%d %s\n %d flakes to %s, %s no longer running\n", |
| issue.Number, issue.Title, stats.N, stats.Last.Format("2006-01-02"), |
| builderList(stats.Builders)) |
| } else { |
| how := fmt.Sprintf("p=%.2g", stats.P) |
| if stats.Capped { |
| how = "on the " + days(closeMaxSilence) + " cap" |
| } |
| fmt.Printf(" - closing #%d %s\n %d flakes to %s, quiet %s, %s, %d builds since\n", |
| issue.Number, issue.Title, stats.N, stats.Last.Format("2006-01-02"), |
| days(stats.Silence), how, stats.Builds) |
| } |
| closed++ |
| if !*post || !*doClose { |
| continue |
| } |
| if err := postClose(issue, closeText(stats)); err != nil { |
| log.Print(err) |
| } |
| } |
| untracked := untrackIssues(issues, now) |
| closeVerb, dropVerb := "closed", "dropped" |
| if !*post || !*doClose { |
| closeVerb, dropVerb = "would close", "would drop" |
| } |
| log.Printf("Close pass: %s %d of %d quiet issues, %s %d long-closed ones from the project.", |
| closeVerb, closed, considered, dropVerb, untracked) |
| } |
| |
| // untrackIssues removes from the Test Flakes project the issues that have been |
| // closed for closeUntrackDelay, and reports how many it found. It returns |
| // them to the project's care rather than watchflakes': the script stops |
| // matching, so a failure like the ones it collected files a fresh issue. |
| // |
| // Unlike closing, this is not limited to the issues watchflakes filed. An |
| // issue closed by a fix has had the same chance to prove the fix wrong as one |
| // closed here for going quiet, so both stop being watched on the same terms. |
| func untrackIssues(issues []*Issue, now time.Time) int { |
| n := 0 |
| for _, issue := range issues { |
| if !shouldUntrack(issue, now) { |
| continue |
| } |
| n++ |
| fmt.Printf(" - dropping #%d %s\n closed %s ago\n", |
| issue.Number, issue.Title, days(now.Sub(issue.ClosedAt))) |
| if !*post || !*doClose { |
| continue |
| } |
| if err := gh.DeleteProjectItem(testFlakes, issue.Item); err != nil { |
| log.Print(err) |
| } |
| } |
| return n |
| } |
| |
| // shouldUntrack reports whether issue has been closed long enough to stop |
| // watching. An issue that was reopened is not closed, so its delay starts |
| // over from whenever it is closed again. |
| func shouldUntrack(issue *Issue, now time.Time) bool { |
| return issue.Item != nil && issue.Closed && !issue.ClosedAt.IsZero() && |
| now.Sub(issue.ClosedAt) >= closeUntrackDelay |
| } |
| |
| // closeCandidate reports why issue can never be closed automatically, |
| // or "" if it might be. It looks only at data readIssues has already |
| // loaded, so that callers can skip fetching comments for issues that |
| // cannot qualify. |
| func closeCandidate(issue *Issue) string { |
| switch { |
| case issue.Number == 0 || issue.Closed: |
| return "not an open issue" |
| case len(issue.Post) > 0: |
| return "flaking right now" |
| |
| // Nothing here looks at milestones, assignees, or discussion. A comment |
| // on a flake issue is usually diagnosis rather than a commitment to keep |
| // it open, and the milestones these issues carry say the same: as of |
| // 2026-08-27 a milestone check held back 350 issues, of which the great |
| // majority were Unreleased, Backlog, gopls/backlog, or vuln/unplanned, |
| // which mean the opposite of a promise to fix before a release. |
| // |
| // watchflakes closes only the issues it filed and still owns, which the |
| // marker sentence postNew writes into the body records. A human may |
| // retitle one of these issues, or edit its script, without taking it |
| // over, but rewriting the body past the marker means it has become |
| // something else. |
| // |
| // Not the Automation label, which postNew also applies but which is |
| // missing from most of the issues watchflakes filed: as of 2026-08-27 |
| // the Test Flakes project held 1194 issues carrying the marker and only |
| // 192 carrying the label. Requiring the label would leave the great |
| // majority of watchflakes' own issues untouchable. |
| case !strings.Contains(issue.Body, autoIssueMarker): |
| return "not watchflakes' issue to close" |
| |
| // If the script does not work, or deliberately drops what it matches, |
| // then the issue is quiet for reasons that have nothing to do with |
| // whether the failures stopped. |
| case issue.Script == nil || issue.Error != "": |
| return "script is not usable" |
| case hasSkipRule(issue.Script): |
| return "script skips its failures" |
| } |
| return "" |
| } |
| |
| // shouldClose reports why issue should stay open, or "" if it should be |
| // closed, along with the evidence either way. It requires that issue's |
| // comments have been read. |
| // |
| // There are three ways to close. If the builders that produced the flakes |
| // have stopped running altogether, the issue is moot whatever the flake rate |
| // was. Otherwise the test is that the silence since the last flake is too |
| // long to be a coincidence (see silenceP) and is longer than any quiet |
| // stretch this issue has produced before, or else that the silence has |
| // reached closeMaxSilence. Either way enough builds must have run during the |
| // silence for a flake to have shown up if it were still there. |
| // |
| // There is deliberately no floor on how long an issue must be quiet. The two |
| // things a floor would guard against are already covered, and covered by the |
| // data rather than by a guess: a holiday or a freeze shows up as too few |
| // builds, and a flake that normally goes quiet for a while shows up in |
| // MaxGap. Imposing a floor on top would only delay the clearest cases, such |
| // as a consistent failure that a fix has just made stop. |
| func shouldClose(issue *Issue, act *builderActivity, now time.Time) (*closeStats, string) { |
| events := flakeHistory(issue) |
| if len(events) == 0 { |
| return nil, "no flake history to read" |
| } |
| |
| s := &closeStats{ |
| N: len(events), |
| First: events[0].Time, |
| Last: events[len(events)-1].Time, |
| } |
| s.Span = s.Last.Sub(s.First) |
| s.Silence = now.Sub(s.Last) |
| for i := 1; i < len(events); i++ { |
| if gap := events[i].Time.Sub(events[i-1].Time); gap > s.MaxGap { |
| s.MaxGap = gap |
| } |
| } |
| builders := make(map[string]bool) |
| for _, e := range events { |
| builders[e.Builder] = true |
| } |
| s.Builders = slices.Sorted(maps.Keys(builders)) |
| s.Builds = act.countSince(s.Builders, s.Last) |
| s.LastBuild = act.lastBuild(s.Builders) |
| s.Since = act.since |
| s.P = silenceP(s.N, s.Span, s.Silence) |
| |
| // A builder that has stopped running cannot reproduce the flake, so there |
| // is no rate to test: the issue is moot either way. Build times here are |
| // commit times, which run a little behind when the build actually ran, |
| // but only by hours against a threshold of weeks. |
| switch { |
| case !act.exists(s.Builders): |
| // Gone from LUCI altogether. |
| s.Retired = true |
| return s, "" |
| case !act.seen(s.Builders): |
| // Still configured, but kept off the dashboards, which ListBuilders |
| // does for every builder with a known issue. Those builders run as |
| // usual, so their absence here says nothing at all -- and a builder |
| // with a known issue is exactly the sort to have flake issues open. |
| // Without build times there is no exposure to measure, so leave it. |
| return s, fmt.Sprintf("%s not on the dashboards", strings.Join(s.Builders, ", ")) |
| case now.Sub(s.LastBuild) > closeRetiredQuiet: |
| // Configured, but nothing has run for a long time. |
| s.Retired = true |
| return s, "" |
| } |
| |
| // Work out whether the statistics alone make the case for closing. |
| var statsWhy string |
| switch { |
| case s.N < 2: |
| statsWhy = "a single flake" |
| case s.Span < closeMinSpan: |
| statsWhy = fmt.Sprintf("%d flakes inside a day", s.N) |
| case s.P >= closeAlpha: |
| statsWhy = fmt.Sprintf("p=%.2g, not yet past %v", s.P, closeAlpha) |
| case s.Silence <= s.MaxGap: |
| // The rate test assumes flakes arrive steadily. A seasonal flake |
| // breaks that assumption, and the test alone recovers from it only |
| // slowly: absorbing a quiet season into the span raises the |
| // threshold, but it converges to season*ln(1/alpha)/burst, which |
| // stays inside the season once a burst is more than ln(1/alpha) |
| // flakes, so the issue would be closed and reopened forever. So also |
| // require silence longer than any gap this issue has survived, which |
| // is distribution-free and, for a genuinely steady flake with fewer |
| // than about fifty intervals, is not the binding constraint. |
| statsWhy = fmt.Sprintf("has been quiet %s before", days(s.MaxGap)) |
| } |
| if statsWhy != "" { |
| if s.Silence < closeMaxSilence { |
| return s, fmt.Sprintf("%s, quiet %s of the %s that closes anything", |
| statsWhy, days(s.Silence), days(closeMaxSilence)) |
| } |
| s.Capped = true |
| } |
| if s.Builds < closeMinBuilds { |
| return s, fmt.Sprintf("only %d builds on %s since the last flake", |
| s.Builds, strings.Join(s.Builders, ", ")) |
| } |
| return s, "" |
| } |
| |
| // closeStats is the evidence for closing an issue. |
| type closeStats struct { |
| N int // flakes recorded on the issue, bursts collapsed |
| First time.Time // time of the first flake |
| Last time.Time // time of the most recent flake |
| Span time.Duration // First to Last |
| Silence time.Duration // Last to now |
| MaxGap time.Duration // longest stretch between consecutive flakes |
| P float64 // probability of this much silence, rate unchanged |
| Builders []string // builders that produced the flakes |
| Builds int // builds on those builders since Last |
| LastBuild time.Time // most recent build on them, zero if none in the window |
| Since time.Time // start of the dashboard window LastBuild is drawn from |
| Retired bool // they have stopped running, so the issue is moot |
| Capped bool // closed on closeMaxSilence, not on the statistics |
| } |
| |
| // silenceP returns the probability of seeing no flakes for the given silence, |
| // after n flakes spanning the given time, assuming the flake rate has not |
| // changed. |
| // |
| // Condition on the n-1 flakes that arrived between the first flake and now, |
| // an interval of length span+silence. For a Poisson process, given how many |
| // there are, their arrival times are independent and uniform over that |
| // interval, so the chance that every one of them landed in the first span of |
| // it is (span/(span+silence))^(n-1). This is exact: no rate is estimated and |
| // no large-n approximation is used, which matters because most issues have |
| // only a handful of flakes. Solved for silence, it says to wait |
| // span*(alpha^(-1/(n-1))-1), which for large n approaches ln(1/alpha) mean |
| // intervals but at n=3 is nearly four times that. |
| // |
| // It is also the Bayesian predictive probability under the scale-invariant |
| // prior on the rate, which is some comfort that it is not an artifact of how |
| // the question was set up. |
| func silenceP(n int, span, silence time.Duration) float64 { |
| if n < 2 || span <= 0 { |
| return 1 |
| } |
| return math.Pow(float64(span)/float64(span+silence), float64(n-1)) |
| } |
| |
| // flakeEvent is one flake watchflakes reported on an issue. |
| type flakeEvent struct { |
| Time time.Time |
| Builder string |
| Commit string |
| } |
| |
| // flakeLineRE matches the header (*FailurePost).String writes for each |
| // failure, as wrapped by (*FailurePost).Markdown: a timestamp, the builder, |
| // and repo@commit, where the commit is the short hash shortHash produces. |
| var flakeLineRE = regexp.MustCompile(`(\d{4}-\d\d-\d\d \d\d:\d\d) (\S+) \S+@([0-9a-f]+)`) |
| |
| // flakeHistory returns the flakes watchflakes has reported on issue, oldest |
| // first, read back out of the comments it signed. |
| // |
| // Flakes sharing a commit are counted once. A single bad commit can fail on |
| // many builders at once, and counting each one separately would overstate the |
| // flake rate and so close the issue too eagerly. Collapsing them can only |
| // make closing harder, since the threshold span*(alpha^(-1/(n-1))-1) grows as |
| // n falls. |
| // |
| // Comments left by versions of watchflakes that predate this failure format |
| // do not parse and are ignored, which is likewise conservative. |
| func flakeHistory(issue *Issue) []flakeEvent { |
| var events []flakeEvent |
| seen := make(map[string]bool) |
| for _, com := range issue.Comments { |
| if !isWatchflakesComment(com.Body) { |
| continue |
| } |
| for _, m := range flakeLineRE.FindAllStringSubmatch(com.Body, -1) { |
| t, err := time.Parse("2006-01-02 15:04", m[1]) |
| if err != nil || seen[m[3]] { |
| continue |
| } |
| seen[m[3]] = true |
| events = append(events, flakeEvent{Time: t, Builder: m[2], Commit: m[3]}) |
| } |
| } |
| slices.SortFunc(events, func(a, b flakeEvent) int { return a.Time.Compare(b.Time) }) |
| return events |
| } |
| |
| // builderActivity records when each builder ran, whether or not the build |
| // failed, so that silence on an issue can be told apart from a builder that |
| // has stopped running. |
| type builderActivity struct { |
| since time.Time // start of the dashboard window |
| builds map[string][]time.Time // build times, by builder |
| known map[string]bool // every builder LUCI has configured |
| } |
| |
| // newBuilderActivity gathers the build times on the dashboards, which cover |
| // the most recent timeLimit, alongside the set of builders LUCI has |
| // configured. The two differ: ListBuilders keeps any builder with a known |
| // issue off the dashboards, so a builder can be running steadily and still |
| // have no build times here. |
| func newBuilderActivity(boards []*Dashboard, known map[string]bool) *builderActivity { |
| act := &builderActivity{builds: make(map[string][]time.Time), known: known} |
| for _, dash := range boards { |
| for i, b := range dash.Builders { |
| for _, r := range dash.Results[i] { |
| if r == nil { |
| continue |
| } |
| act.builds[b.Name] = append(act.builds[b.Name], r.Time) |
| if act.since.IsZero() || r.Time.Before(act.since) { |
| act.since = r.Time |
| } |
| } |
| } |
| } |
| return act |
| } |
| |
| // countSince returns how many builds ran on the named builders since t. |
| // The dashboards only reach back timeLimit, so when t is older than that the |
| // count is a lower bound, which can only delay a close. |
| func (act *builderActivity) countSince(builders []string, t time.Time) int { |
| n := 0 |
| for _, b := range builders { |
| for _, bt := range act.builds[b] { |
| if bt.After(t) { |
| n++ |
| } |
| } |
| } |
| return n |
| } |
| |
| // exists reports whether any of builders is still configured in LUCI, whether |
| // or not it reaches the dashboards. |
| func (act *builderActivity) exists(builders []string) bool { |
| for _, b := range builders { |
| if act.known[b] { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // seen reports whether any of builders appears on the dashboards, and so has |
| // build times to reason about. |
| func (act *builderActivity) seen(builders []string) bool { |
| for _, b := range builders { |
| if len(act.builds[b]) > 0 { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // lastBuild returns the most recent time any of builders ran, or the zero |
| // time if none of them ran during the dashboard window. |
| func (act *builderActivity) lastBuild(builders []string) time.Time { |
| var last time.Time |
| for _, b := range builders { |
| for _, bt := range act.builds[b] { |
| if bt.After(last) { |
| last = bt |
| } |
| } |
| } |
| return last |
| } |
| |
| // closeText returns the comment to post when closing an issue. |
| func closeText(s *closeStats) string { |
| if s.Retired { |
| return retiredText(s) |
| } |
| var b strings.Builder |
| if s.Capped { |
| fmt.Fprintf(&b, "Closing automatically: no failures matching this issue in %s.\n\n", days(s.Silence)) |
| } else { |
| fmt.Fprintf(&b, "Closing automatically: the failures this issue tracks appear to have stopped.\n\n") |
| } |
| if s.N == 1 { |
| fmt.Fprintf(&b, "- One flake recorded, on %s.\n", s.First.Format("2006-01-02")) |
| } else { |
| fmt.Fprintf(&b, "- %d flakes recorded, from %s to %s.\n", |
| s.N, s.First.Format("2006-01-02"), s.Last.Format("2006-01-02")) |
| } |
| fmt.Fprintf(&b, "- Quiet for %s.", days(s.Silence)) |
| if s.MaxGap > 0 { |
| fmt.Fprintf(&b, " The longest it had gone quiet before was %s.", days(s.MaxGap)) |
| } |
| fmt.Fprintf(&b, "\n- %d builds have run since then, on %s.\n", s.Builds, builderList(s.Builders)) |
| |
| if !s.Capped { |
| fmt.Fprintf(&b, "\nIf the flakes were still arriving at their old rate, the chance of this much silence would be about %s.\n", |
| percent(s.P)) |
| fmt.Fprintf(&b, "\nThat is evidence the failures stopped, not proof they were fixed. If a matching failure turns up again, watchflakes will reopen this issue.\n") |
| return b.String() |
| } |
| |
| // Closed on the cap, so say what the statistics could not settle. |
| switch { |
| case s.N == 1: |
| fmt.Fprintf(&b, "\nA single flake gives no rate to compare this silence against.") |
| case s.Span < closeMinSpan: |
| fmt.Fprintf(&b, "\nFlakes packed into less than a day give no rate to compare this silence against.") |
| case s.MaxGap >= s.Silence: |
| fmt.Fprintf(&b, "\nThis issue has gone quiet for %s before and come back, so its history cannot tell whether this time is different.", |
| days(s.MaxGap)) |
| default: |
| fmt.Fprintf(&b, "\nThese flakes are too few and too spread out for their rate to settle the question: it would take %s of silence first.", |
| days(requiredSilence(s))) |
| } |
| fmt.Fprintf(&b, " Watchflakes closes any issue quiet for %s regardless, so this is a timeout rather than evidence that the problem is gone.\n", |
| days(closeMaxSilence)) |
| fmt.Fprintf(&b, "\nIf a matching failure turns up again, watchflakes will reopen this issue.\n") |
| return b.String() |
| } |
| |
| // requiredSilence returns how long the rate test would leave an issue open, |
| // for reporting when closeMaxSilence overrides it. |
| func requiredSilence(s *closeStats) time.Duration { |
| d := time.Duration(float64(s.Span) * (math.Pow(closeAlpha, -1/float64(s.N-1)) - 1)) |
| return max(d, s.MaxGap) |
| } |
| |
| // retiredText returns the comment to post when closing an issue because the |
| // builders that produced its failures are no longer running. |
| func retiredText(s *closeStats) string { |
| var b strings.Builder |
| fmt.Fprintf(&b, "Closing automatically: the failures this issue tracks only ever happened on %s, which %s no longer running.\n\n", |
| builderList(s.Builders), plural(len(s.Builders), "is", "are")) |
| if s.LastBuild.IsZero() { |
| fmt.Fprintf(&b, "- No builds at all since %s, as far back as the dashboards go.\n", |
| s.Since.Format("2006-01-02")) |
| } else { |
| fmt.Fprintf(&b, "- Last build on %s.\n", s.LastBuild.Format("2006-01-02")) |
| } |
| if s.N == 1 { |
| fmt.Fprintf(&b, "- One flake recorded, on %s.\n", s.First.Format("2006-01-02")) |
| } else { |
| fmt.Fprintf(&b, "- %d flakes recorded, from %s to %s.\n", |
| s.N, s.First.Format("2006-01-02"), s.Last.Format("2006-01-02")) |
| } |
| fmt.Fprintf(&b, "\nThese failures cannot show up again while nothing is running under %s. This says nothing about whether the underlying problem was fixed: if the builder comes back, or has been renamed, or the problem turns up elsewhere, watchflakes will reopen this issue or file a new one.\n", |
| plural(len(s.Builders), "that name", "those names")) |
| return b.String() |
| } |
| |
| // builderList formats builders for an issue comment, keeping it short when |
| // a flake has hit many builders. |
| func builderList(builders []string) string { |
| const max = 5 |
| if len(builders) <= max { |
| return strings.Join(builders, ", ") |
| } |
| return fmt.Sprintf("%s, and %d more", strings.Join(builders[:max], ", "), len(builders)-max) |
| } |
| |
| // postClose comments on the issue explaining the decision and then closes it, |
| // in that order so that the explanation is there when the notification lands. |
| func postClose(issue *Issue, body string) error { |
| if err := gh.AddIssueComment(issue.Issue, body+signature); err != nil { |
| return err |
| } |
| return gh.CloseIssue(issue.Issue) |
| } |
| |
| // hasSkipRule reports whether s drops any of the failures it matches, |
| // instead of posting all of them. |
| func hasSkipRule(s *script.Script) bool { |
| for _, r := range s.Rules { |
| if r.Action == "skip" { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // plural returns one or many according to n. |
| func plural(n int, one, many string) string { |
| if n == 1 { |
| return one |
| } |
| return many |
| } |
| |
| // days formats d as a whole number of days, for issue comments and logs. |
| func days(d time.Duration) string { |
| n := int(d / (24 * time.Hour)) |
| if n == 1 { |
| return "1 day" |
| } |
| return fmt.Sprintf("%d days", n) |
| } |
| |
| // percent formats a small probability as a percentage. |
| func percent(p float64) string { |
| if p < 0.0001 { |
| return "under 0.01%" |
| } |
| return fmt.Sprintf("%.2g%%", p*100) |
| } |