mesh-issues: say a delivered fix left in fixing, and flag a stale claim (hq issue 483)
mesh/merge-gate pass: builds mesh-issues → 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 superseded: a newer head of the same pull request

The operator found 13 issues in fixing whose fix was delivered hours before:
their pull requests were linked, not in fixed-by, so their delivery was never
measured, and nothing said an issue left there or a worker gone.
This commit is contained in:
2026-10-11 12:16:59 +02:00
parent c4ea58aeb0
commit d81e7d063b
10 changed files with 345 additions and 24 deletions
+25 -2
View File
@@ -19,7 +19,7 @@ what it refuses.
|---|---|---|
| `open` | `title`, `report`, optionally `located-in`, `record` | checks the issue, then takes the next number and keeps it under that number; answers the number |
| `get` | `number` | the issue whole: fields, notes, links |
| `list` | optionally `status`, `owner`, `phrase`, `limit` | short rows, newest first: number, status, owner, title |
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `stale-claim`) | short rows, newest first: number, status, owner, title, and each flag that holds |
| `move` | `number`, `status`, and what that status needs (`by` for `diagnosing` and `fixing`, optionally `take-over`) | changes the status under the development cycle's rules |
| `note` | `number`, `text` | appends a note; a note is never edited |
| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link |
@@ -110,6 +110,27 @@ event `ready-to-verify`, once per set of fixes; it is resolved once its acceptan
while the issue stays `fixing`: `move` to the status it has changes what it holds and is refused when it changes
nothing.
**The fixes of an issue are its `fixed-by` and the pull requests linked to it** (link kind `pull-request`; hq issue
483): agents link a pull request while the issue is in fixing and give `fixed-by` at the resolve, so a delivery read
on `fixed-by` alone missed every fix being verified. Readiness still reads `fixed-by` alone, so a pull request linked
and abandoned holds no dependent back. A pull request linked after its delivery moved hears no transition again: each
sweep asks mesh-delivery for at most five fixes never heard of, of issues not resolved.
The ready-to-verify note names each fix with when it was delivered, and the issue's worker; the event carries
`worker` and `delivered-at`. No channel of the tracker reaches a worker, so the note and the event are how it hears.
**Delivered, not resolved** (hq issue 483). An issue in fixing whose every fix is delivered carries
`delivered-not-resolved` on `get` and its `list` row. Past `delivered-not-resolved-after` (default `1h`) from the last
delivery, the sweep says it once as a warning on the operator's channel, key
`issue-tracker.delivered-not-resolved.<number>`, "issue N: fixed and delivered, not resolved; <worker> works on it",
cleared there when the issue leaves fixing or names a fix not delivered yet (at its `moved`, or the next sweep).
**A stale claim** (hq issue 483). An issue in diagnosing or fixing whose worker did nothing on it for
`worker-stale-after` (default `24h`) and that names no pull request still open (not merged, or never heard of)
carries `stale-claim` on `get` and its `list` row, beside its `worker`. A move by someone else takes it over without
`take-over`'s reason, as any stale worker; the answer, the note and the `moved` event say the claim was stale
(`stale-claim`).
Every five minutes the module looks for a fix merged and not delivered after `stuck-after` (default `2h`): it is
said once — a note on the issue and a warning on the operator's channel (`seat:operator-channel.notify`, key
`issue-tracker.fix-stuck.<number>`), cleared there when every fix of the issue is delivered. A module that runs only
@@ -243,7 +264,9 @@ as the credential, which ADR 0266 keeps agents from reading (hq issue 333).
`duplicate-check` (`on`; `off` stops the triage of new issues), `judge-model` (`sonnet`; `haiku` or `opus`) and
`forge-owner` (the forge's owner of the repositories `fixed-by` names, for asking a delivery) steer the triage;
`stuck-after` (`2h`) is how long a merged fix may take to be delivered before it is said.
`stuck-after` (`2h`) is how long a merged fix may take to be delivered before it is said;
`delivered-not-resolved-after` (`1h`) how long an issue may stay in fixing once every fix is delivered before it is
said (hq issue 483).
```
{
@@ -0,0 +1,189 @@
package main
// A fix delivered and an issue left in fixing, and a claim nobody works on (novox/hq issue 483). The operator found
// 13 of 23 issues in fixing whose fix was merged and delivered hours before, and two claimed by builders that had
// stopped: the tracker measured the delivery and said "ready to verify", but nothing said an issue left there, and
// nothing told a worked issue from a forgotten one. Here:
//
// - an issue in fixing whose every fix (fixed-by and the pull requests linked) is delivered is
// delivered-not-resolved; past `delivered-not-resolved-after` (1h) it is said to the operator's channel as a
// warning, key `issue-tracker.delivered-not-resolved.<number>`, naming the issue and its worker, cleared there
// when it leaves fixing or names a fix not yet delivered;
// - an issue in diagnosing or fixing whose worker did nothing on it for `worker-stale-after` (24h) and that names
// no pull request still open is a stale claim: get and list say so, and a move by someone else takes it over
// without a reason, saying the claim was stale.
//
// A module that runs only its bundle cannot raise a controller condition (hq ADR 0240 rule 8, ADR 0256), so the
// warning is the operator's channel's, as the stuck fix's is (ADR 0281 §4).
import (
"fmt"
"strings"
"time"
)
// DefaultDeliveredNotResolvedAfter is how long an issue may stay in fixing once its fix is delivered before the
// operator hears of it: proving an acceptance live takes minutes, and a worker that went away would otherwise
// leave the issue there for good.
const DefaultDeliveredNotResolvedAfter = time.Hour
// MostSweepAsks is the most deliveries one sweep asks of mesh-delivery, each up to the bus's 30 seconds: a fix
// linked after its delivery moved is measured within a few sweeps, and a sweep stays short.
const MostSweepAsks = 5
// The flags list filters on (novox/hq issue 483).
const (
FlagDeliveredNotResolved = "delivered-not-resolved"
FlagStaleClaim = "stale-claim"
)
// Flags is every flag list takes.
var Flags = []string{FlagDeliveredNotResolved, FlagStaleClaim}
// deliveredNotResolvedAfter is the setting's bound, or the default.
func (t *Tracker) deliveredNotResolvedAfter() time.Duration {
if s, err := t.Settings(); err == nil && strings.TrimSpace(s.DeliveredNotResolvedAfter) != "" {
if d, err := time.ParseDuration(strings.TrimSpace(s.DeliveredNotResolvedAfter)); err == nil && d > 0 {
return d
}
}
return DefaultDeliveredNotResolvedAfter
}
// deliveredNotResolved says whether an issue is in fixing and every fix it names is delivered.
func deliveredNotResolved(is *Issue) bool { return is.Status == StatusFixing && allDelivered(is) }
// deliveredAt is when the last of an issue's delivered fixes was delivered, as the delivery said it.
func deliveredAt(is *Issue) time.Time {
var last time.Time
for _, pr := range fixPullRequests(is) {
if f := fixOf(is, pr); f != nil && contains(is.Delivered, pr) && f.Since.After(last) {
last = f.Since
}
}
return last
}
func notResolvedKey(n uint64) string { return "issue-tracker.delivered-not-resolved." + keyOf(n) }
// sayNotResolved warns the operator's channel, once per spell, of an issue delivered and not resolved past the
// bound, and answers whether it did; an issue no longer so has its warning cleared.
func (t *Tracker) sayNotResolved(is *Issue, now time.Time) (bool, error) {
if !deliveredNotResolved(is) {
return false, t.clearNotResolved(is)
}
at := deliveredAt(is)
if is.NotResolvedOpen || t.Notify == nil || now.Sub(at) < t.deliveredNotResolvedAfter() {
return false, nil
}
worker := "nobody"
if is.Worker != nil {
worker = is.Worker.By
}
ago := now.Sub(at).Round(time.Minute)
prs := strings.Join(fixPullRequests(is), ", ")
if err := t.Notify(notResolvedKey(is.Number), "warning",
fmt.Sprintf("issue %d: fixed and delivered, not resolved; %s works on it", is.Number, worker),
fmt.Sprintf("Fixed and delivered, not resolved (issue %d)", is.Number),
fmt.Sprintf("Every fix of issue %d (%s) was delivered %s ago, at %s, and the issue is still in fixing; %s works "+
"on it. Its worker proves the acceptance live and resolves it. When the worker is gone, another agent "+
"takes it over with a move to fixing and resolves it; nothing is needed from you otherwise.", is.Number,
prs, ago, at.Format(time.RFC3339), worker), "", false); err != nil {
return false, err
}
_, err := t.change(is.Number, func(i *Issue) error { i.NotResolvedOpen = true; return nil })
return err == nil, err
}
// clearNotResolved clears the warning once the issue is no longer delivered and in fixing: resolved, moved on, or
// naming a fix not delivered yet.
func (t *Tracker) clearNotResolved(is *Issue) error {
if !is.NotResolvedOpen || deliveredNotResolved(is) || t.Notify == nil {
return nil
}
if err := t.Notify(notResolvedKey(is.Number), "", "", "", "", fmt.Sprintf("issue %d left fixing (%s), or names a "+
"fix not delivered yet", is.Number, is.Status), true); err != nil {
return err
}
_, err := t.change(is.Number, func(i *Issue) error { i.NotResolvedOpen = false; return nil })
return err
}
// askUnheard asks mesh-delivery for a few fixes of issues not yet resolved that the tracker never heard of: a pull
// request linked after its delivery moved hears no transition again, and OnMoved asks only at a move.
func (t *Tracker) askUnheard() error {
if t.Deliveries == nil {
return nil
}
all, err := t.allIssues()
if err != nil {
return err
}
asked := 0
for n, is := range all {
if !unresolved(is) {
continue
}
heard := false
for _, pr := range fixPullRequests(is) {
if fixOf(is, pr) != nil {
continue
}
if asked == MostSweepAsks {
return nil
}
asked++
repo, number, _ := strings.Cut(pr, "#")
d, err := t.Deliveries(repo, number)
if err != nil {
return fmt.Errorf("the delivery of %s (issue %d): %v", pr, n, err)
}
if d.State == "" {
continue
}
since := d.Since
if since.IsZero() {
since = t.now()
}
if _, err := t.recordFix(pr, d.State, d.Why, afterMerge(d.State), since, n); err != nil {
return err
}
heard = true
}
if heard {
if err := t.afterFix(n); err != nil {
return err
}
}
}
return nil
}
// openPullRequest says whether an issue names a pull request not merged yet, or one whose delivery the tracker
// never heard of: work in review is work, and a fix unheard of is not taken for none.
func openPullRequest(is *Issue) bool {
for _, pr := range fixPullRequests(is) {
f := fixOf(is, pr)
if f == nil || (!f.Merged && !contains(is.Delivered, pr)) {
return true
}
}
return false
}
// staleClaim says whether an issue's worker went quiet with no pull request open: nothing done on it for the bound,
// and nothing in review.
func staleClaim(is *Issue, now time.Time, after time.Duration) bool {
return worked(is.Status) && is.Worker != nil && is.Worker.stale(now, after) && !openPullRequest(is)
}
// hasFlag says whether an issue carries a list flag.
func hasFlag(is *Issue, flag string, now time.Time, staleAfter time.Duration) bool {
switch flag {
case FlagDeliveredNotResolved:
return deliveredNotResolved(is)
case FlagStaleClaim:
return staleClaim(is, now, staleAfter)
}
return false
}
+52 -13
View File
@@ -64,7 +64,7 @@ func fixOf(is *Issue, pr string) *Fix {
// allDelivered says whether an issue names a pull request and every one is delivered.
func allDelivered(is *Issue) bool {
prs := pullRequests(is)
prs := fixPullRequests(is)
if len(prs) == 0 {
return false
}
@@ -79,7 +79,7 @@ func allDelivered(is *Issue) bool {
// DeliveryLine is the derived delivery state of an issue's fix, in words; empty when fixed-by names no pull
// request.
func DeliveryLine(is *Issue) string {
prs := pullRequests(is)
prs := fixPullRequests(is)
if len(prs) == 0 {
return ""
}
@@ -128,7 +128,7 @@ func (t *Tracker) recordFix(pr, state, why string, merged bool, at time.Time, on
}
var changed []uint64
for n, is := range all {
if (only != 0 && n != only) || !contains(pullRequests(is), pr) {
if (only != 0 && n != only) || !contains(fixPullRequests(is), pr) {
continue
}
kept := cut(why, MostReasonKept)
@@ -177,17 +177,41 @@ func (t *Tracker) afterFix(n uint64) error {
if err := t.clearStuck(is); err != nil {
t.Logf("[mesh-issues] the stuck fix of %d could not be cleared on the operator's channel (tried again at the next sweep): %v", n, err)
}
if err := t.clearNotResolved(is); err != nil {
t.Logf("[mesh-issues] the warning that %d is delivered and not resolved could not be cleared on the operator's channel (tried again at the next sweep): %v", n, err)
}
if allDelivered(is) {
if unresolved(is) {
prs := pullRequests(is)
prs := fixPullRequests(is)
sort.Strings(prs)
if strings.Join(is.VerifyFor, ",") != strings.Join(prs, ",") {
// The note first, kept once by its words; then the claim; then the event. A failure before the claim
// is tried again at the next offer, and the note is not written twice.
prefix := fmt.Sprintf("Ready to verify: every fix of this issue is delivered (%s).", strings.Join(prs, ", "))
// is tried again at the next offer, and the note is not written twice. It names when each fix was
// delivered and who works on the issue: no channel of the tracker reaches a worker, so the note
// and the event are how the worker hears it (novox/hq issue 483).
var when []string
for _, pr := range prs {
if f := fixOf(is, pr); f != nil {
when = append(when, pr+" at "+f.Since.Format(time.RFC3339))
} else {
when = append(when, pr)
}
}
prefix := fmt.Sprintf("Ready to verify: every fix of this issue is delivered (%s).", strings.Join(when, ", "))
worker := ""
if is.Worker != nil {
worker = is.Worker.By
}
if !noted(is, prefix) {
if _, err := t.note(GroomBy, n, prefix+" Delivered is not resolved: prove the acceptance live, then "+
"resolve it, from fixing: move it to fixing with by first if it is not there. (novox/hq ADR 0281, ADR 0283)"); err != nil {
text := prefix
if worker != "" {
text += fmt.Sprintf(" %s works on it: prove the acceptance live and resolve it, or say what is left. "+
"In fixing and not resolved %s after its delivery, it is said to the operator.", worker,
t.deliveredNotResolvedAfter())
}
text += " Delivered is not resolved: prove the acceptance live, then resolve it, from fixing: move it " +
"to fixing with by first if it is not there. (novox/hq ADR 0281, ADR 0284, issue 483)"
if _, err := t.note(GroomBy, n, text); err != nil {
return err
}
}
@@ -203,7 +227,11 @@ func (t *Tracker) afterFix(n uint64) error {
return err
}
if claimed {
t.emit(EventReadyToVerify, map[string]any{"number": n, "fixed-by": prs, "at": t.now()})
body := map[string]any{"number": n, "fixed-by": prs, "at": t.now(), "delivered-at": deliveredAt(is)}
if worker != "" {
body["worker"] = worker
}
t.emit(EventReadyToVerify, body)
}
}
}
@@ -217,7 +245,7 @@ func stillStuck(is *Issue) bool {
if is.Status == StatusWontfix {
return false
}
named := pullRequests(is)
named := fixPullRequests(is)
for _, f := range is.Fixes {
if f.StuckSaid && contains(named, f.PullRequest) && !contains(is.Delivered, f.PullRequest) {
return true
@@ -334,7 +362,7 @@ func (t *Tracker) OnMoved(e stdio.Envelope) error {
// Each ask up to the bus's 30 seconds, under the runtime's two minutes; one that fails, or more left to
// ask, has the event offered again, so a delivery never asked ends in max-deliveries, not in silence.
asked := 0
for _, pr := range pullRequests(is) {
for _, pr := range fixPullRequests(is) {
if fixOf(is, pr) != nil {
continue
}
@@ -395,8 +423,13 @@ func (t *Tracker) stuckAfter() time.Duration {
}
// Sweep says every fix merged and not delivered past the bound: a note on its issue and a warning on the
// operator's channel, once; it answers how many it said. Run every few minutes.
// operator's channel, once; and every issue in fixing whose fixes were all delivered past its own bound and is not
// resolved (novox/hq issue 483). First it asks the delivery of a few fixes never heard of. It answers how many
// warnings it said. Run every few minutes.
func (t *Tracker) Sweep() (int, error) {
if err := t.askUnheard(); err != nil {
t.Logf("[mesh-issues] the sweep could not ask a fix's delivery (asked again at the next sweep): %v", err)
}
all, err := t.allIssues()
if err != nil {
return 0, err
@@ -404,6 +437,12 @@ func (t *Tracker) Sweep() (int, error) {
bound, now := t.stuckAfter(), t.now()
said := 0
for n, is := range all {
ok, err := t.sayNotResolved(is, now)
if err != nil {
t.Logf("[mesh-issues] that %d is delivered and not resolved could not be said on the operator's channel (said at the next sweep): %v", n, err)
} else if ok {
said++
}
if is.Status == StatusWontfix {
if err := t.clearStuck(is); err != nil {
t.Logf("[mesh-issues] the stuck fix of %d could not be cleared on the operator's channel: %v", n, err)
@@ -413,7 +452,7 @@ func (t *Tracker) Sweep() (int, error) {
if err := t.clearStuck(is); err != nil {
t.Logf("[mesh-issues] the stuck fix of %d could not be cleared on the operator's channel: %v", n, err)
}
named := pullRequests(is)
named := fixPullRequests(is)
for _, f := range is.Fixes {
if !contains(named, f.PullRequest) || contains(is.Delivered, f.PullRequest) || !f.Merged || f.MergedAt == nil ||
reached(f.State) || f.StuckSaid || now.Sub(*f.MergedAt) < bound {
@@ -102,6 +102,9 @@ type Issue struct {
VerifyFor []string `json:"verify-for,omitempty"`
// StuckOpen is the warning on the operator's channel said for a stuck fix and not yet cleared there.
StuckOpen bool `json:"stuck-open,omitempty"`
// NotResolvedOpen is the warning on the operator's channel that it is fixed and delivered and not resolved,
// said and not yet cleared there (novox/hq issue 483).
NotResolvedOpen bool `json:"not-resolved-open,omitempty"`
// ReadyFor is the issues it depended on when it was last said ready (ADR 0279 §3), so one spell of
// readiness is said once however many dependencies let it go at one delivery.
ReadyFor []uint64 `json:"ready-for,omitempty"`
+15 -3
View File
@@ -47,6 +47,10 @@ type Settings struct {
// WorkerStaleAfter is how long an issue's worker stays current with nothing done on it (ADR 0283 §2), as a
// duration such as 24h; DefaultWorkerStaleAfter when empty.
WorkerStaleAfter string `json:"worker-stale-after,omitempty"`
// DeliveredNotResolvedAfter is how long an issue may stay in fixing once every fix it names is delivered before
// the operator is warned (novox/hq issue 483), as a duration such as 1h; DefaultDeliveredNotResolvedAfter when
// empty.
DeliveredNotResolvedAfter string `json:"delivered-not-resolved-after,omitempty"`
// DecisionFloor and ResearchFloor are the highest decision and research numbers taken where the tracker
// cannot read, such as an open branch of hq on the forge (issue 424). Read once, when the sequence's
// counter starts: its first number is one above the highest of this floor, the numbers hq's record holds
@@ -369,7 +373,10 @@ func Tools(t *Tracker) []stdio.Tool {
"needs none, ready. An issue is ready when everything it depends on is closed, or resolved with its fix " +
"delivered. Each row says whether its fix is delivered, and, for an issue at diagnosing or fixing, who " +
"works on it (worker) since when, and stale when nobody did anything on it for a day, so it may be " +
"taken over. Answers what is open in one call.",
"taken over. flag delivered-not-resolved keeps the issues in fixing whose every fix (fixed-by and the " +
"pull requests linked) is delivered; flag stale-claim the issues whose worker did nothing on them for a " +
"day with no pull request open, which a move takes over without a reason. Each row says both. Answers " +
"what is open in one call.",
Input: schema(nil, map[string]any{
"status": map[string]any{"type": "string", "enum": Statuses, "description": "only issues at this status"},
"owner": text("only issues located in this owner, such as mesh-controller"),
@@ -378,9 +385,10 @@ func Tools(t *Tracker) []stdio.Tool {
"severity": map[string]any{"type": "string", "enum": Severities, "description": "only issues at this severity"},
"needs": map[string]any{"type": "string", "enum": NeedsKinds, "description": "only issues that need this"},
"ready": map[string]any{"type": "boolean", "description": "only issues ready (true) or waiting (false)"},
"flag": map[string]any{"type": "string", "enum": Flags, "description": "only issues delivered and not resolved, or the stale claims"},
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready")
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag")
if err != nil {
return nil, err
}
@@ -400,6 +408,9 @@ func Tools(t *Tracker) []stdio.Tool {
if ask.Needs, err = a.str("needs"); err != nil {
return nil, err
}
if ask.Flag, err = a.str("flag"); err != nil {
return nil, err
}
if v, given := m["ready"]; given && v != nil {
r, err := a.flag("ready")
if err != nil {
@@ -421,7 +432,8 @@ func Tools(t *Tracker) []stdio.Tool {
"development cycle's rules. Move an issue to diagnosing or fixing when you start work on it, with by: " +
"who works on it and its node, as who@node (such as fix-399@g14); the issue shows it until it moves on. " +
"Refused while someone else works on it, naming them, unless take-over says why (noted on the issue); a " +
"worker who did nothing on it for a day may be taken over from without a reason. located needs located-in, owners on " +
"worker who did nothing on it for a day may be taken over from without a reason, and with no pull request open " +
"the answer says the claim was a stale claim. located needs located-in, owners on " +
"the registered list; resolved is reached only from fixing and needs located-in and fixed-by (the pull " +
"requests or commits), and an issue in the mesh's core also replay (the replay's id, such as R361) or " +
"replay-none (why no replay is possible); wontfix needs a reason. A move to the status it has changes " +
+24 -1
View File
@@ -188,6 +188,24 @@ func pullRequests(is *Issue) []string {
return out
}
// fixPullRequests is every pull request an issue names as its fix: those of fixed-by and those linked as
// pull-request, as `<repository>#<number>`, lower-case. Agents link a pull request while the issue is in fixing and
// give fixed-by only at the resolve, so a delivery measured on fixed-by alone missed every fix still being
// verified (novox/hq issue 483). Readiness (done) still reads fixed-by alone: a resolved issue's fixed-by is what
// fixed it, and a pull request linked and abandoned must not hold its dependents back.
func fixPullRequests(is *Issue) []string {
out := pullRequests(is)
for _, l := range is.Links {
if l.Kind != LinkPullRequest {
continue
}
if pr := strings.ToLower(strings.TrimSpace(l.Target)); pullRequest.MatchString(pr) && !contains(out, pr) {
out = append(out, pr)
}
}
return out
}
func contains(list []string, s string) bool {
for _, e := range list {
if e == s {
@@ -273,6 +291,9 @@ type View struct {
Delivery string `json:"delivery,omitempty"`
// WorkerStale says nothing was done on it by its worker, or anyone, for worker-stale-after (ADR 0283 §2).
WorkerStale bool `json:"worker-stale,omitempty"`
// DeliveredNotResolved and StaleClaim are list's flags, said of this issue (novox/hq issue 483).
DeliveredNotResolved bool `json:"delivered-not-resolved,omitempty"`
StaleClaim bool `json:"stale-claim,omitempty"`
}
// View is `get`.
@@ -285,8 +306,10 @@ func (t *Tracker) View(n uint64) (*View, error) {
if is == nil {
return nil, fmt.Errorf("there is no issue %d: %w", n, ErrNoIssue)
}
now, staleAfter := t.now(), t.workerStaleAfter()
return &View{Issue: is, NeededBy: neededBy(all, n), WaitsFor: waitsFor(all, is), Ready: isReady(all, is),
Delivery: DeliveryLine(is), WorkerStale: is.Worker != nil && is.Worker.stale(t.now(), t.workerStaleAfter())}, nil
Delivery: DeliveryLine(is), WorkerStale: is.Worker != nil && is.Worker.stale(now, staleAfter),
DeliveredNotResolved: deliveredNotResolved(is), StaleClaim: staleClaim(is, now, staleAfter)}, nil
}
// letGo says, for an issue that may have just let others go (its fix delivered, or wontfix), every issue it
+27 -1
View File
@@ -348,6 +348,9 @@ type Moved struct {
Since *time.Time `json:"since,omitempty"`
// TookOverFrom is the worker a take-over took the issue over from.
TookOverFrom string `json:"took-over-from,omitempty"`
// StaleClaim says the worker taken over from had a stale claim: nothing done on it past worker-stale-after and
// no pull request open, so no reason was needed (novox/hq issue 483).
StaleClaim bool `json:"stale-claim,omitempty"`
// Released is the worker whose work on it the move ended.
Released string `json:"released,omitempty"`
Said string `json:"said"`
@@ -518,7 +521,13 @@ func (t *Tracker) move(by string, a MoveAsk) (*Moved, error) {
if takeOver != "" {
body["take-over"] = takeOver
}
out.Said += fmt.Sprintf(" Taken over from %s; noted on the issue.", work.from)
if work.staleClaim {
out.StaleClaim, body["stale-claim"] = true, true
out.Said += fmt.Sprintf(" Taken over from %s, whose claim was a stale claim (nothing done on it for %s and no "+
"pull request open); noted on the issue.", work.from, staleAfter)
} else {
out.Said += fmt.Sprintf(" Taken over from %s; noted on the issue.", work.from)
}
}
if work.released != "" {
body["released"] = work.released
@@ -713,6 +722,8 @@ type ListAsk struct {
Needs string
// Ready, when set, keeps only the issues ready (true) or not ready (false).
Ready *bool
// Flag keeps only the issues delivered and not resolved, or the stale claims (novox/hq issue 483).
Flag string
}
// Row is one issue in a list: short enough to read in a chat.
@@ -733,6 +744,10 @@ type Row struct {
Worker string `json:"worker,omitempty"`
Since *time.Time `json:"since,omitempty"`
Stale bool `json:"stale,omitempty"`
// DeliveredNotResolved says it is in fixing with every fix it names delivered; StaleClaim that its worker
// did nothing on it for worker-stale-after and no pull request of it is open (novox/hq issue 483).
DeliveredNotResolved bool `json:"delivered-not-resolved,omitempty"`
StaleClaim bool `json:"stale-claim,omitempty"`
}
// Listed is the answer to a list.
@@ -771,6 +786,12 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
return nil, err
}
}
flag := strings.TrimSpace(a.Flag)
if flag != "" {
if _, err := oneOf("flag", flag, Flags); err != nil {
return nil, err
}
}
limit := a.Limit
if limit <= 0 {
limit = 50
@@ -799,6 +820,9 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
if needs != "" && is.Needs != needs {
continue
}
if flag != "" && !hasFlag(is, flag, now, staleAfter) {
continue
}
ready := isReady(all, is)
if a.Ready != nil && ready != *a.Ready {
continue
@@ -817,6 +841,8 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
since := c.Since
row.Worker, row.Since, row.Stale = c.By, &since, c.stale(now, staleAfter)
}
row.DeliveredNotResolved = deliveredNotResolved(is)
row.StaleClaim = staleClaim(is, now, staleAfter)
out.Rows = append(out.Rows, row)
}
}
@@ -43,7 +43,7 @@ type Tracked struct {
// 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)
prs := fixPullRequests(is)
if len(prs) == 0 {
return ""
}
@@ -90,7 +90,9 @@ type workOutcome struct {
took bool // a worker started, or the issue changed hands
from string // the worker it was taken over from
wasStale bool // the worker taken over from was stale
released string // the worker whose work ended, on a move to a status that has none
// staleClaim is a stale worker with no pull request open: a claim nobody works on (novox/hq issue 483).
staleClaim bool
released string // the worker whose work ended, on a move to a status that has none
}
// workerFor applies a move's worker to an issue, under ADR 0283 §1 and §2: another worker not stale is refused
@@ -121,6 +123,7 @@ func workerFor(is *Issue, to, by, takeOver string, now time.Time, staleAfter tim
return out, nil
}
out.wasStale = cur.stale(now, staleAfter)
out.staleClaim = out.wasStale && !openPullRequest(is)
if !out.wasStale && takeOver == "" {
return out, fmt.Errorf("%s works on issue %d (%s) since %s, last active %s. Ask them, or take it over with "+
"take-over saying why; the reason is noted on the issue. A worker who did nothing on it for %s may be "+
@@ -136,7 +139,10 @@ func workerFor(is *Issue, to, by, takeOver string, now time.Time, staleAfter tim
func takeOverNote(o workOutcome, by, reason string, prev *Worker) string {
s := fmt.Sprintf("Taken over by %s from %s, who worked on it since %s and was last active %s", by, o.from,
prev.Since.Format(time.RFC3339), prev.Active.Format(time.RFC3339))
if o.wasStale {
switch {
case o.staleClaim:
s += " (a stale claim: nothing done on it past the bound, and no pull request open)"
case o.wasStale:
s += " (stale)"
}
if reason != "" {
File diff suppressed because one or more lines are too long