package main // Who works on an issue (novox/hq ADR 0284). A move to diagnosing or fixing names, in `by`, the issue's worker: // the agent or person and its node, as who@node. The issue keeps its worker while it stays at either status, and // `get` and `list` show it with since. A move to either by someone else while the worker is not stale is refused, // naming the worker; a take-over gives a reason, and the reason is noted. A worker with no move, note, link or set // from a caller for `worker-stale-after` (24 hours) is stale: `list` says so, and it may be taken over without a // reason. // // `by` is what the worker says it is, not who called: who acted is recorded beside it as the runtime proves it // (the caller's bus user, hq issue 365, or Unproven), and the worker is a way for agents and people to see each // other, not a lock against someone giving a false name. import ( "fmt" "regexp" "strings" "time" ) // Worker is who works on an issue, since when, and when a caller last did anything on it. type Worker struct { By string `json:"by"` Since time.Time `json:"since"` // Active is the last move, note, link or set on the issue by a caller, not by the tracker itself. Active time.Time `json:"active"` } // DefaultWorkerStaleAfter is how long a worker stays current with nothing done on its issue: a working day and a // night, so an agent that stopped (its session ended, its machine asleep) frees the issue by the next day, and // one that works on it keeps it by noting what it found. const DefaultWorkerStaleAfter = 24 * time.Hour // MostWorker is the most a worker's name, who@node, may be. const MostWorker = 120 // workerName is who@node: who is the agent or person (letters, digits and . _ / -, such as fix-399 or jochen), // node the machine it works on. var workerName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*@[a-z0-9][a-z0-9-]*$`) // worked says whether an issue at a status has a worker: at diagnosing and fixing. func worked(status string) bool { return status == StatusDiagnosing || status == StatusFixing } // checkWorker is `by` as kept: who@node, in lower case. func checkWorker(s string) (string, error) { return checkWorkerField("by", s) } // checkWorkerField is a field holding who@node (`by`, or `worker` as list filters), as kept: in lower case. func checkWorkerField(field, s string) (string, error) { s, err := singleLine(field, s, MostWorker) if err != nil { return "", err } s = strings.ToLower(s) if !workerName.MatchString(s) { return "", fmt.Errorf("%s %q is not who works on it and its node: give who@node, the agent or person and the "+ "machine it works on, such as fix-399@g14 or jochen@shanks", field, s) } return s, nil } // ownActor says whether a write is the tracker's own (the triage, the duplicate check, the delivery's notes, the // import), which keeps no worker current. func ownActor(by string) bool { return by == GroomBy || by == LegacyBy || by == ImportedBy } // touch marks a caller's write on an issue as activity of its worker. func touch(is *Issue, by string, at time.Time) { if is.Worker != nil && !ownActor(by) { is.Worker.Active = at } } // stale says whether nothing was done on the issue past the bound since the worker last was. func (w *Worker) stale(now time.Time, after time.Duration) bool { last := w.Active if last.Before(w.Since) { last = w.Since } return now.Sub(last) > after } // workerStaleAfter is the setting `worker-stale-after`, or DefaultWorkerStaleAfter when it is empty or unreadable. func (t *Tracker) workerStaleAfter() time.Duration { if s, err := t.Settings(); err == nil && strings.TrimSpace(s.WorkerStaleAfter) != "" { if d, err := time.ParseDuration(strings.TrimSpace(s.WorkerStaleAfter)); err == nil && d > 0 { return d } } return DefaultWorkerStaleAfter } // workOutcome is what a move did to an issue's worker. 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 // abandoned is a stale worker with no pull request open: the issue is abandoned (novox/hq issue 483). abandoned 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 0284 §3 and §4: another worker not stale is refused // without a take-over reason; a take-over reason with no other worker to take over from is refused. func workerFor(is *Issue, to, by, takeOver string, now time.Time, staleAfter time.Duration) (workOutcome, error) { var out workOutcome cur := is.Worker if !worked(to) { if cur != nil { out.released = cur.By is.Worker = nil } return out, nil } if cur != nil && cur.By == by { if takeOver != "" { return out, fmt.Errorf("%s works on issue %d already: take-over is for taking it over from someone else", by, is.Number) } cur.Active = now return out, nil } if cur == nil { if takeOver != "" { return out, fmt.Errorf("nobody works on issue %d: take-over is for taking it over from someone else; move it without one", is.Number) } is.Worker = &Worker{By: by, Since: now, Active: now} out.took = true return out, nil } out.wasStale = cur.stale(now, staleAfter) out.abandoned = 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 "+ "taken over from without a reason", cur.By, is.Number, is.Status, cur.Since.Format(time.RFC3339), cur.Active.Format(time.RFC3339), staleAfter) } out.from, out.took = cur.By, true is.Worker = &Worker{By: by, Since: now, Active: now} return out, nil } // takeOverNote is the note a take-over leaves on the issue. 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)) switch { case o.abandoned: s += " (stale, and abandoned: no pull request open)" case o.wasStale: s += " (stale)" } if reason != "" { s += ". Why: " + reason } return s + ". (novox/hq ADR 0284)" }