Say in a condition's message which issue tracks it and where that issue stands (issue 400)
mesh/delivery-group group feat/400-alert-names-its-issue checking: 0 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues, messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery-group group feat/400-alert-names-its-issue checking: 0 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues, messenger → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The operator could not tell from a notification whether its cause was known, tracked and being fixed, or whether they had to act. The issue tracker gains the verb tracking, answering for every condition key an issue links which issues link it and where each stands; the operator channel's router asks it outside the work and says one line from it in every condition's message, per hq ADR 0285.
This commit is contained in:
@@ -25,6 +25,7 @@ what it refuses.
|
||||
| `import` | optionally `dry-run`, `commit` | hq's issue folders, read through the records module, each written as an issue under its own number; see below |
|
||||
| `set` | `number`, any of `severity`, `needs`, `suggested-owner` | changes them as a person decides |
|
||||
| `groom` | optionally `pass`, `confirm` | the triage over the backlog: without arguments a dry run in the background, answering its pass id; with `pass` its progress and findings; with `confirm` and `pass` exactly that pass's findings applied, each checked again; see below |
|
||||
| `tracking` | optionally `conditions` (condition keys) | for every condition key an issue links (link kind `condition`), the issues that link it, those not closed first: status, severity, needs, worker with since, active and stale, and where the fix is (`fix`); the operator's channel says it in a condition's message (hq ADR 0285) |
|
||||
|
||||
**The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the
|
||||
folders until the import, and hq ADR 0283): a status is one of `open`,
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
const Seat = "issue-tracker"
|
||||
|
||||
// Verbs are the seat's verbs, as the manifest declares and claims them.
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set"}
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set", "tracking"}
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
@@ -602,5 +602,26 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
}
|
||||
return t.Set(ask)
|
||||
}},
|
||||
{Name: Seat + ".tracking",
|
||||
Description: "Which issues track which conditions: for every condition key an issue links (link kind condition), " +
|
||||
"or only the keys given, the issues that link it, those not resolved first, each with its status, " +
|
||||
"severity, what it needs, who works on it since when and whether they went quiet (stale), and where its " +
|
||||
"fix is (fix: not-merged, merged, held, failed, unheard or delivered; empty when none is named). The " +
|
||||
"operator's channel asks it to say in a condition's message which issue tracks it. Refused: a key not of a condition " +
|
||||
"key's shape.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"conditions": texts("only these condition keys, such as module.mounts.shanks.unhealthy; all when not given"),
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("tracking", m, "conditions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := a.list("conditions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.Tracking(keys)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
// Which issues track which conditions (novox/hq issue 400, ADR 0285). An issue names a condition it tracks by the
|
||||
// condition's key, through a link of kind `condition`, set by whoever opens the issue or works on it. The verb
|
||||
// `tracking` answers, in one call, every condition key an issue links and, for each, the issues that link it and
|
||||
// where each stands: its status and what it needs, who works on it and since when, and where its fix is. The
|
||||
// router of the operator's channel asks it and words the line a condition's message says; the tracker says no
|
||||
// words for the operator itself, and the controller learns nothing of issues.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MostTrackingPerCondition is the most issues the answer gives for one condition key, those not closed first.
|
||||
const MostTrackingPerCondition = 10
|
||||
|
||||
// TrackingIssue is one issue that tracks a condition, as `tracking` answers it.
|
||||
type TrackingIssue struct {
|
||||
Number uint64 `json:"number"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Needs string `json:"needs,omitempty"`
|
||||
// Worker, Since, Active and Stale are who works on it, while it is diagnosing or fixing (ADR 0284).
|
||||
Worker string `json:"worker,omitempty"`
|
||||
Since *time.Time `json:"since,omitempty"`
|
||||
Active *time.Time `json:"active,omitempty"`
|
||||
Stale bool `json:"stale,omitempty"`
|
||||
// Fix is where its fix stands, as the delivery last said it (ADR 0281): see FixState.
|
||||
Fix string `json:"fix,omitempty"`
|
||||
}
|
||||
|
||||
// Tracked is the answer to `tracking`.
|
||||
type Tracked struct {
|
||||
// Conditions is, by condition key, the issues that link it.
|
||||
Conditions map[string][]TrackingIssue `json:"conditions"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// FixState is where an issue's fix stands, one word, from the same facts as DeliveryLine and in its order: empty
|
||||
// when fixed-by names no pull request; failed, held, merged (not delivered yet), not-merged, unheard (nothing
|
||||
// heard from the delivery), or delivered (every pull request it names).
|
||||
func FixState(is *Issue) string {
|
||||
prs := pullRequests(is)
|
||||
if len(prs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var failed, held, merged, open, unheard bool
|
||||
for _, pr := range prs {
|
||||
f := fixOf(is, pr)
|
||||
switch {
|
||||
case contains(is.Delivered, pr):
|
||||
case f == nil:
|
||||
unheard = true
|
||||
case f.State == "held":
|
||||
held = true
|
||||
case f.State == "failed" || f.State == "stopped":
|
||||
failed = true
|
||||
case f.Merged:
|
||||
merged = true
|
||||
default:
|
||||
open = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case failed:
|
||||
return "failed"
|
||||
case held:
|
||||
return "held"
|
||||
case merged:
|
||||
return "merged"
|
||||
case open:
|
||||
return "not-merged"
|
||||
case unheard:
|
||||
return "unheard"
|
||||
}
|
||||
return "delivered"
|
||||
}
|
||||
|
||||
// Tracking answers which issues track which conditions: only the keys asked, or every key an issue links.
|
||||
func (t *Tracker) Tracking(keys []string) (*Tracked, error) {
|
||||
want := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
k, err := checkTarget(LinkCondition, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want[k] = true
|
||||
}
|
||||
all, err := t.allIssues()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now, staleAfter := t.now(), t.workerStaleAfter()
|
||||
out := &Tracked{Conditions: map[string][]TrackingIssue{}}
|
||||
for _, is := range all {
|
||||
for _, l := range is.Links {
|
||||
if l.Kind != LinkCondition || (len(want) > 0 && !want[l.Target]) {
|
||||
continue
|
||||
}
|
||||
ti := TrackingIssue{Number: is.Number, Status: is.Status, Severity: is.Severity, Needs: is.Needs, Fix: FixState(is)}
|
||||
if w := is.Worker; w != nil {
|
||||
since, active := w.Since, w.Active
|
||||
ti.Worker, ti.Since, ti.Active, ti.Stale = w.By, &since, &active, w.stale(now, staleAfter)
|
||||
}
|
||||
out.Conditions[l.Target] = append(out.Conditions[l.Target], ti)
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for k, list := range out.Conditions {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
ci, cj := closedStatus(list[i].Status), closedStatus(list[j].Status)
|
||||
if ci != cj {
|
||||
return !ci
|
||||
}
|
||||
return list[i].Number < list[j].Number
|
||||
})
|
||||
if len(list) > MostTrackingPerCondition {
|
||||
list = list[:MostTrackingPerCondition]
|
||||
}
|
||||
out.Conditions[k] = list
|
||||
n++
|
||||
}
|
||||
switch {
|
||||
case n == 0 && len(want) > 0:
|
||||
out.Said = "No issue tracks " + strings.Join(sortedKeys(want), ", ") + "."
|
||||
case n == 0:
|
||||
out.Said = "No issue tracks a condition."
|
||||
case n == 1:
|
||||
out.Said = "Issues track 1 condition."
|
||||
default:
|
||||
out.Said = fmt.Sprintf("Issues track %d conditions.", n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func closedStatus(s string) bool { return s == StatusResolved || s == StatusWontfix }
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Which issues track which conditions (novox/hq issue 400, ADR 0285).
|
||||
|
||||
const mountsKey = "module.mounts.shanks.unhealthy"
|
||||
|
||||
func TestTrackingAnswersTheIssuesThatLinkEachCondition(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
now := clocked(h)
|
||||
a := h.open(t, "mounts fail on shanks", "mesh-catalog")
|
||||
b := h.open(t, "a wait for the operator's secret", "mesh-controller")
|
||||
c := h.open(t, "rolled-back is repaired by hand", "mesh-controller")
|
||||
d := h.open(t, "unrelated", "hq")
|
||||
for _, l := range []LinkAsk{
|
||||
{Number: a, Kind: LinkCondition, Target: mountsKey},
|
||||
{Number: b, Kind: LinkCondition, Target: mountsKey},
|
||||
{Number: c, Kind: LinkCondition, Target: "mesh.hand-acts.rolled-back.healer-wanted"},
|
||||
{Number: d, Kind: LinkRelatesTo, Target: "365"},
|
||||
} {
|
||||
if _, err := h.Link(l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := h.Move(MoveAsk{Number: b, Status: StatusFixing, By: "fix-386@g14"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Set(SetAsk{Number: c, Needs: "operator"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(25 * time.Hour)
|
||||
|
||||
got, err := h.Tracking(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Conditions) != 2 || got.Said != "Issues track 2 conditions." {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
m := got.Conditions[mountsKey]
|
||||
if len(m) != 2 || m[0].Number != a || m[1].Number != b {
|
||||
t.Fatalf("mounts: %+v", m)
|
||||
}
|
||||
if m[0].Worker != "" || m[0].Status != StatusOpen {
|
||||
t.Errorf("nobody works on %d: %+v", a, m[0])
|
||||
}
|
||||
if m[1].Worker != "fix-386@g14" || m[1].Since == nil || m[1].Active == nil || !m[1].Stale || m[1].Status != StatusFixing {
|
||||
t.Errorf("the worker of %d, gone quiet a day: %+v", b, m[1])
|
||||
}
|
||||
if r := got.Conditions["mesh.hand-acts.rolled-back.healer-wanted"]; len(r) != 1 || r[0].Number != c || r[0].Needs != "operator" {
|
||||
t.Errorf("rolled-back: %+v", r)
|
||||
}
|
||||
|
||||
// Only the keys asked; one nobody links is said.
|
||||
only, err := h.Tracking([]string{"machine.ace.silent"})
|
||||
if err != nil || len(only.Conditions) != 0 || only.Said != "No issue tracks machine.ace.silent." {
|
||||
t.Fatalf("%+v %v", only, err)
|
||||
}
|
||||
if _, err := h.Tracking([]string{"a key\nwith a line"}); err == nil {
|
||||
t.Fatal("a key not of a condition key's shape was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// A resolved issue comes after those not closed, and the answer holds at most MostTrackingPerCondition for a key.
|
||||
func TestTrackingPutsOpenIssuesFirstAndIsBounded(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
var numbers []uint64
|
||||
for i := 0; i < MostTrackingPerCondition+2; i++ {
|
||||
n := h.open(t, "one of many", "hq")
|
||||
if _, err := h.Link(LinkAsk{Number: n, Kind: LinkCondition, Target: mountsKey}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
numbers = append(numbers, n)
|
||||
}
|
||||
first := numbers[0]
|
||||
h.fixing(t, first)
|
||||
if _, err := h.Move(MoveAsk{Number: first, Status: StatusResolved, LocatedIn: []string{"hq"}, FixedBy: []string{"hq#1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := h.Tracking(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := got.Conditions[mountsKey]
|
||||
if len(list) != MostTrackingPerCondition {
|
||||
t.Fatalf("%d issues, want %d", len(list), MostTrackingPerCondition)
|
||||
}
|
||||
for _, is := range list {
|
||||
if is.Number == first {
|
||||
t.Fatalf("the resolved issue was not put last: %+v", list)
|
||||
}
|
||||
}
|
||||
if list[0].Number != numbers[1] {
|
||||
t.Errorf("not by number: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// Where a fix stands, one word per case, in DeliveryLine's order.
|
||||
func TestFixStateSaysWhereTheFixIs(t *testing.T) {
|
||||
merged := true
|
||||
cases := []struct {
|
||||
name string
|
||||
is Issue
|
||||
want string
|
||||
}{
|
||||
{"no fix named", Issue{}, ""},
|
||||
{"nothing heard", Issue{FixedBy: []string{"mesh-catalog#1"}}, "unheard"},
|
||||
{"not merged", Issue{FixedBy: []string{"mesh-catalog#1"}, Fixes: []Fix{{PullRequest: "mesh-catalog#1", State: "open"}}}, "not-merged"},
|
||||
{"merged", Issue{FixedBy: []string{"mesh-catalog#1"}, Fixes: []Fix{{PullRequest: "mesh-catalog#1", State: "merged", Merged: merged}}}, "merged"},
|
||||
{"held", Issue{FixedBy: []string{"mesh-catalog#1"}, Fixes: []Fix{{PullRequest: "mesh-catalog#1", State: "held", Merged: merged}}}, "held"},
|
||||
{"failed", Issue{FixedBy: []string{"mesh-catalog#1"}, Fixes: []Fix{{PullRequest: "mesh-catalog#1", State: "failed", Merged: merged}}}, "failed"},
|
||||
{"delivered", Issue{FixedBy: []string{"mesh-catalog#1"}, Delivered: []string{"mesh-catalog#1"}}, "delivered"},
|
||||
{"one of two failed", Issue{FixedBy: []string{"mesh-catalog#1", "mesh-controller#2"}, Delivered: []string{"mesh-catalog#1"},
|
||||
Fixes: []Fix{{PullRequest: "mesh-controller#2", State: "stopped", Merged: merged}}}, "failed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FixState(&c.is); got != c.want {
|
||||
t.Errorf("%s: %q, want %q (its line: %q)", c.name, got, c.want, DeliveryLine(&c.is))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The verb as the tool runner calls it; its answer carries no report, note or title (nothing the router does not
|
||||
// need), and so no secret an issue's words might hold.
|
||||
func TestTheTrackingVerb(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := h.open(t, "mounts fail", "hq")
|
||||
if _, err := h.Link(LinkAsk{Number: n, Kind: LinkCondition, Target: mountsKey}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Note(n, "the share's password=hunter2 was typed here"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := h.call(t, "tracking", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
if !strings.Contains(string(raw), `"`+mountsKey+`":[{"number":`) {
|
||||
t.Fatalf("%s", raw)
|
||||
}
|
||||
for _, leak := range []string{"hunter2", "mounts fail", "seen:"} {
|
||||
if strings.Contains(string(raw), leak) {
|
||||
t.Errorf("the answer carries %q: %s", leak, raw)
|
||||
}
|
||||
}
|
||||
if _, err := h.call(t, "tracking", map[string]any{"conditions": []string{mountsKey}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.call(t, "tracking", map[string]any{"number": 1}); err == nil {
|
||||
t.Fatal("an argument it does not take was accepted")
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"link",
|
||||
"import",
|
||||
"groom",
|
||||
"set"
|
||||
"set",
|
||||
"tracking"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -32,7 +33,8 @@
|
||||
"link",
|
||||
"import",
|
||||
"groom",
|
||||
"set"
|
||||
"set",
|
||||
"tracking"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user