diff --git a/changelog.d/changed-readable-human-gate-advice.md b/changelog.d/changed-readable-human-gate-advice.md
new file mode 100644
index 0000000000..3d20dfeb2a
--- /dev/null
+++ b/changelog.d/changed-readable-human-gate-advice.md
@@ -0,0 +1 @@
+- The weekly owner advice "Clear the human gate on PRs" and "Unblock the human queue" are readable. They no longer say only "59 of 94 open PRs are your turn (1 INSPECT, 24 DECIDE, 34 REVIEW)" followed by five items whose notes could run to a paragraph. Each now gives the count waiting on a person, **why they wait** (grouped by reason in plain words, with where they sit, such as "24 parked until a maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite)"), a **How to help** line for the largest groups, and one short line per listed item. The raw signals moved to the end of the digest entry and fold away on the dashboard card. The API adds `why` and `help` to those recommendations.
diff --git a/src/docs/advisory.md b/src/docs/advisory.md
index 2e6f4d574c..5ec1d5edca 100644
--- a/src/docs/advisory.md
+++ b/src/docs/advisory.md
@@ -427,15 +427,34 @@ Two families of rules feed the block:
| `reduce-blocked-prs` | Blocked PRs ≥ `blocked_pr_pct` of open PRs | the dominant cause and the first action: *"41 of 70 fail check `go-security-analysis` — fix the check before touching cadence"*, *"N have merge conflicts — rebase or close the oldest"*, or the top blocked-verdict reason |
| `fix-failing-check` | one check fails on ≥ `check_share_pct` of CI-blocked PRs (at least 2) | the check name and how many PRs it holds |
| `throttle-lane` | one agent lane produced ≥ `lane_share_pct` of Blocked PRs (at least 2) | the lane to slow down |
-| `clear-human-gate-prs` | PRs that are your turn (the inbox's N plus M) ≥ `needs_human_pr_pct` of open PRs | the per-verb split of those PRs |
-| `unblock-human-issues` | Issues that are your turn (the inbox's N plus M, parked issues included) ≥ `needs_human_issue_pct` of open issues | the per-verb split of those issues |
+| `clear-human-gate-prs` | PRs that are your turn (the inbox's N plus M) ≥ `needs_human_pr_pct` of open PRs | how many wait on a person, **why** they wait (grouped by reason, with where they sit) and **how to help** |
+| `unblock-human-issues` | Issues that are your turn (the inbox's N plus M, parked issues included) ≥ `needs_human_issue_pct` of open issues | the same why / how to help breakdown for issues |
| `review-stale-prs` | Blocked PRs past `dashboard.issue_bands.stale_days` ≥ `stale_blocked_prs` | stale = no activity past the threshold, not proven dead |
| `confirm-and-close` | any issue in Confirm & close | how much of the backlog confirming them removes |
+The two needs-human rules also carry:
+
+- **Why they wait:** the your-turn items grouped by the reason the dashboard
+ recorded for each (its ownership reason code), in plain words, largest
+ group first, each with where it sits, for example *"24 parked until a
+ maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite)"*. A
+ reviewer's requested changes and a code owner's review are their own
+ groups, apart from approvals branch protection requires. The five largest
+ groups are named; the rest fold into one "N more for other reasons" line.
+- **How to help:** the action for the three largest groups, naming the
+ repositories only when two of them hold most of that group, for example
+ *"answer the parked decisions in utah-packages and utah; approve PRs in
+ common and actions"*.
+
Every queue-health recommendation carries:
- **First:** the first five issues/PRs it is about (oldest activity first),
each with why it is listed (the failing check, "merge conflict", "held").
+ A needs-human item's note is its verb and reason, cut to the reason's first
+ clause when it runs past 100 characters (a parked hive review lists every
+ reviewer's summary); a review-decision note drops "Waiting on a person's
+ review:", which the REVIEW verb already says. The inbox and the CSV keep the
+ whole reason.
- **Full list:** the matching Overview export, e.g.
`/api/overview/prs.csv?band=blocked` (plus a `repo=` link when one repo
holds half the blocked PRs). The digest prefixes the link with
@@ -444,6 +463,9 @@ Every queue-health recommendation carries:
- **Band rules:** the one-line rule for each band the advice names, the same
text the Overview tooltips print — so *Blocked* is read as "blocked sweep
verdict, merge conflicts, or failing CI", not the `blocked` label.
+- **Numbers behind this:** the raw signals the rule read (including the
+ per-verb split), last in the digest and folded away on the dashboard
+ card.
### Which count is quoted
diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md
index f24224605a..3276c3ddfc 100644
--- a/src/docs/api-reference.md
+++ b/src/docs/api-reference.md
@@ -296,7 +296,7 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of `
| `GET` | `/api/acmm/evaluation` | Dashboard auth/session | ACMMEvaluation — combined codebase + operational result, cached server-side for 1 hour (`acmmEvalTTL`). `?refresh=1` (the dashboard's "🔄 Re-evaluate" button, #5877) bypasses the hourly TTL but is debounced server-side: requests within 1 minute of the last evaluation (`acmmRefreshDebounce`) still serve the cache, since a full refresh costs up to ~29 GitHub GetContents calls per repo. The response's `last_evaluated_at` timestamp reports when the cached evaluation was computed | `pkg/dashboard/api.go:352`, `pkg/dashboard/api_acmm_eval.go` |
| `POST` | `/api/acmm/issue` | Owner only | ACMMCreate Issue — files on GitHub or, with `governor.acmm.issue_tracker: work_source` / body `tracker: "work_source"` on a Linear-sourced hive, on Linear; response `tracker` says which. See [ACMM policy matrix](acmm-policy-matrix.md#where-acmm-gap-issues-are-filed) | `pkg/dashboard/api.go:353` |
| `GET` | `/api/acmm-recommendation` | Dashboard auth/session | Advisory level-up recommendation (`acmmadvisor.Recommendation`, JSON): never changes the applied level — see [ACMM advisor](acmm-advisor.md) | `pkg/dashboard/api.go:354` |
-| `GET` | `/api/hive-advice` | Dashboard auth/session | Weekly owner advice (`hiveadvisor.Result`, JSON) shared with the advisory digest: recommendations with `items`, `links` (Overview export paths) and `bands`; `bands[]` rule text; `counts` naming the governor actionable queue vs. the Overview total. Which recommendations show is frozen for 7 days; their numbers refresh every call — see [Owner advice](advisory.md#owner-advice) | `pkg/dashboard/api.go:356`, `pkg/dashboard/api_hive_advice.go` |
+| `GET` | `/api/hive-advice` | Dashboard auth/session | Weekly owner advice (`hiveadvisor.Result`, JSON) shared with the advisory digest: recommendations with `items`, `links` (Overview export paths) and `bands`, and on the needs-human advice `why` (plain-language reasons with counts) and `help`; `bands[]` rule text; `counts` naming the governor actionable queue vs. the Overview total. Which recommendations show is frozen for 7 days; their numbers refresh every call — see [Owner advice](advisory.md#owner-advice) | `pkg/dashboard/api.go:356`, `pkg/dashboard/api_hive_advice.go` |
## Cost, tokens, telemetry
diff --git a/src/pkg/advisory/advice_section_test.go b/src/pkg/advisory/advice_section_test.go
index 7f423968ab..4be73e8f42 100644
--- a/src/pkg/advisory/advice_section_test.go
+++ b/src/pkg/advisory/advice_section_test.go
@@ -61,7 +61,8 @@ func TestAdviceSectionRendersQueueExplanation(t *testing.T) {
withOrigin := FormatDigestMarkdown(BuildDigest(nil, "IDLE"), DigestOptions{ShowEmpty: true, Advice: advice, DashboardURL: "https://hive.example.com/"})
for _, want := range []string{
"- **Reduce the blocked PR queue** — 70 of 93 open PRs (75%) are Blocked.",
- " - First: [octo/demo#12](https://github.com/octo/demo/pull/12) (fails go-security-analysis), octo/demo#13 (merge conflict)\n",
+ " - First:\n - [octo/demo#12](https://github.com/octo/demo/pull/12) — fails go-security-analysis\n - octo/demo#13 — merge conflict\n",
+ " - Numbers behind this: blocked=70\n",
" - Full list: [Blocked PRs (CSV)](https://hive.example.com/api/overview/prs.csv?band=blocked)\n",
"- ~~**Widen HIVE_REPOS**~~ — No longer applies",
"Bands named above (same rules as the Overview chart):\n- **Blocked** (PRs): blocked sweep verdict, merge conflicts, or failing CI",
@@ -80,3 +81,36 @@ func TestAdviceSectionRendersQueueExplanation(t *testing.T) {
t.Fatalf("relative export path should render as code without a dashboard origin:\n%s", withoutOrigin)
}
}
+
+// The needs-human advice reads as a short list: the headline, why the items
+// wait (one line per reason), how to help, then one item per line, with the
+// raw signals last instead of trailing the headline.
+func TestAdviceSectionRendersWhyAndHowToHelp(t *testing.T) {
+ advice := &hiveadvisor.Result{
+ Epoch: hiveadvisor.Epoch{Mode: "BUSY", End: time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)},
+ Recommendations: []hiveadvisor.Recommendation{{
+ ID: "clear-human-gate-prs",
+ Title: "Clear the human gate on PRs",
+ Rationale: "59 of 94 open PRs (62%) are waiting on a person; the hive cannot move them on its own.",
+ Why: []string{
+ "24 parked until a maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite).",
+ "21 waiting for an approving review that branch protection requires (16 in common, 5 in actions).",
+ },
+ Help: "answer the parked decisions in utah-packages and utah; approve PRs in common and actions",
+ Items: []hiveadvisor.Item{{Repo: "actions", Number: 675, Note: "REVIEW — Hive's review is parked for a person"}},
+ Signals: []hiveadvisor.Signal{{Name: "needs_human", Value: "59"}},
+ }},
+ }
+ md := FormatDigestMarkdown(BuildDigest(nil, "BUSY"), DigestOptions{ShowEmpty: true, Advice: advice})
+ want := "- **Clear the human gate on PRs** — 59 of 94 open PRs (62%) are waiting on a person; the hive cannot move them on its own.\n" +
+ " - Why they wait:\n" +
+ " - 24 parked until a maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite).\n" +
+ " - 21 waiting for an approving review that branch protection requires (16 in common, 5 in actions).\n" +
+ " - **How to help:** answer the parked decisions in utah-packages and utah; approve PRs in common and actions.\n" +
+ " - First:\n" +
+ " - actions#675 — REVIEW — Hive's review is parked for a person\n" +
+ " - Numbers behind this: needs_human=59\n"
+ if !strings.Contains(md, want) {
+ t.Fatalf("digest advice block:\n%s\nwant:\n%s", md, want)
+ }
+}
diff --git a/src/pkg/advisory/advisory.go b/src/pkg/advisory/advisory.go
index bd46ae71b0..07eb86c49b 100644
--- a/src/pkg/advisory/advisory.go
+++ b/src/pkg/advisory/advisory.go
@@ -1458,28 +1458,30 @@ func writeAdviceSection(b *strings.Builder, advice *hiveadvisor.Result, dashboar
b.WriteString(fmt.Sprintf("- ~~**%s**~~ — %s\n", rec.Title, rec.Rationale))
continue
}
- b.WriteString(fmt.Sprintf("- **%s** — %s", rec.Title, rec.Rationale))
- if len(rec.Signals) > 0 {
- parts := make([]string, 0, len(rec.Signals))
- for _, s := range rec.Signals {
- parts = append(parts, fmt.Sprintf("%s=%s", s.Name, s.Value))
+ // One idea per line: the headline, why the items wait, how to help,
+ // where to start, the full list, and last the raw signals.
+ b.WriteString(fmt.Sprintf("- **%s** — %s\n", rec.Title, rec.Rationale))
+ if len(rec.Why) > 0 {
+ b.WriteString(" - Why they wait:\n")
+ for _, w := range rec.Why {
+ b.WriteString(" - " + w + "\n")
}
- b.WriteString(" (signals: " + strings.Join(parts, ", ") + ")")
}
- b.WriteString("\n")
+ if rec.Help != "" {
+ b.WriteString(" - **How to help:** " + rec.Help + ".\n")
+ }
if len(rec.Items) > 0 {
- parts := make([]string, 0, len(rec.Items))
+ b.WriteString(" - First:\n")
for _, it := range rec.Items {
ref := fmt.Sprintf("%s#%d", it.Repo, it.Number)
if it.URL != "" {
ref = fmt.Sprintf("[%s](%s)", ref, it.URL)
}
if it.Note != "" {
- ref += " (" + it.Note + ")"
+ ref += " — " + it.Note
}
- parts = append(parts, ref)
+ b.WriteString(" - " + ref + "\n")
}
- b.WriteString(" - First: " + strings.Join(parts, ", ") + "\n")
}
if len(rec.Links) > 0 {
parts := make([]string, 0, len(rec.Links))
@@ -1492,6 +1494,13 @@ func writeAdviceSection(b *strings.Builder, advice *hiveadvisor.Result, dashboar
}
b.WriteString(" - Full list: " + strings.Join(parts, ", ") + "\n")
}
+ if len(rec.Signals) > 0 {
+ parts := make([]string, 0, len(rec.Signals))
+ for _, s := range rec.Signals {
+ parts = append(parts, fmt.Sprintf("%s=%s", s.Name, s.Value))
+ }
+ b.WriteString(" - Numbers behind this: " + strings.Join(parts, ", ") + "\n")
+ }
}
if len(advice.Bands) > 0 {
b.WriteString("\nBands named above (same rules as the Overview chart):\n")
diff --git a/src/pkg/dashboard/hive_advice_readable_test.go b/src/pkg/dashboard/hive_advice_readable_test.go
new file mode 100644
index 0000000000..bee20743e4
--- /dev/null
+++ b/src/pkg/dashboard/hive_advice_readable_test.go
@@ -0,0 +1,79 @@
+package dashboard
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+
+ "github.com/hivecommons/hive/pkg/hiveadvisor"
+)
+
+// The needs-human advice words each item's reason code in plain language
+// (hiveadvisor.gateReasons). A code the dashboard can produce but the advisor
+// has no words for still groups, under its raw reason text, which is the
+// wording the advice moved away from: keep the two lists in step.
+func TestHiveAdviceWordsEveryOwnershipReason(t *testing.T) {
+ for code := range ownershipReasonVerbs {
+ if !hiveadvisor.DescribesReasonCode(code) {
+ t.Errorf("ownership reason %q has no plain-language wording in hiveadvisor.gateReasons", code)
+ }
+ }
+}
+
+// Execute the shipped card renderer: the advice reads as headline, why the
+// items wait, how to help, one item per line, then the full list, with the
+// raw signals folded away.
+func TestHiveAdviceCardReadsAsWhyHelpAndItems(t *testing.T) {
+ node, err := exec.LookPath("node")
+ if err != nil {
+ t.Skip("node unavailable: hive advice card rendering was not executed")
+ }
+ html := indexHTML(t)
+ var source strings.Builder
+ source.WriteString(`const assert = require('node:assert/strict');
+const nodes = { 'hive-advice-section': { style: {} }, 'hive-advice-card': { innerHTML: '' } };
+const document = { getElementById: (id) => nodes[id] || null };
+let _lastHiveAdvice = null;
+function refreshSectionCardShell() {}
+function setSectionSummary() {}
+function applySectionCollapse() {}
+function formatDashboardDate() { return '11/2'; }
+`)
+ for _, name := range []string{"escapeHtml", "renderHiveAdvice"} {
+ source.WriteString(jsFunc(t, html, name))
+ source.WriteByte('\n')
+ }
+ source.WriteString(`renderHiveAdvice({
+ epoch: { mode: 'BUSY', end: '2026-11-02T00:00:00Z' },
+ nextReviewInDays: 6,
+ recommendations: [{
+ id: 'clear-human-gate-prs', title: 'Clear the human gate on PRs',
+ rationale: '59 of 94 open PRs (62%) are waiting on a person; the hive cannot move them on its own.',
+ why: ['24 parked until a maintainer decides (20 in utah-packages, 3 in utah, 1 in testsuite).', '21 waiting for an approving review that branch protection requires (16 in common, 5 in actions).'],
+ help: 'answer the parked decisions in utah-packages and utah; approve PRs in common and actions',
+ items: [{ repo: 'actions', number: 675, url: 'https://github.com/projectbluefin/actions/pull/675', note: "REVIEW — Hive's review is parked for a person" }, { repo: 'actions', number: 544, note: 'REVIEW — Waiting on a person\'s review: a reviewer requested changes' }],
+ links: [{ label: 'Your-turn PRs (CSV)', url: '/api/overview/prs.csv?slice=your-turn' }],
+ signals: [{ name: 'needs_human', value: '59' }, { name: 'verbs', value: '1 INSPECT, 24 DECIDE, 34 REVIEW' }],
+ }, { id: 'widen-hive-repos', title: 'Widen HIVE_REPOS', rationale: 'No longer applies.', cleared: true }],
+});
+const out = nodes['hive-advice-card'].innerHTML;
+const order = ['59 of 94 open PRs', 'Why they wait', '24 parked until a maintainer decides', 'How to help:', 'First', 'actions#675', 'Full list:', 'Numbers behind this'];
+let at = -1;
+for (const want of order) {
+ const i = out.indexOf(want, at + 1);
+ assert.ok(i > at, 'expected ' + JSON.stringify(want) + ' after position ' + at + ' in:\n' + out);
+ at = i;
+}
+assert.match(out, /
- 24 parked until a maintainer decides[^<]*<\/li>
- 21 waiting for an approving review/);
+assert.match(out, /How to help:<\/strong> answer the parked decisions in utah-packages and utah; approve PRs in common and actions\.<\/div>/);
+assert.match(out, /
- ]*>actions#675<\/a> — REVIEW — Hive's review is parked for a person<\/li>
- actions#544 — /);
+assert.match(out, /
Numbers behind this<\/summary>needs_human=59, verbs=1 INSPECT, 24 DECIDE, 34 REVIEW<\/details>/);
+assert.ok(!out.includes('First: '), 'items are a list, not a comma run');
+assert.ok(!/class="crit-cmp">signals:/.test(out), 'raw signals are folded away');
+assert.match(out, /Widen HIVE_REPOS<\/s>/);
+assert.equal(out.match(/hive-advice-why/g).length, 1, 'only the needs-human advice carries a why list');
+`)
+ if out, err := exec.Command(node, "-e", source.String()).CombinedOutput(); err != nil {
+ t.Fatalf("hive advice card renderer failed: %v\n%s", err, out)
+ }
+}
diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html
index ce9fba7b2d..221ed95900 100644
--- a/src/pkg/dashboard/static/index.html
+++ b/src/pkg/dashboard/static/index.html
@@ -1870,6 +1870,13 @@
.hive-advice-band-list dt { margin: var(--sp-0); }
.hive-advice-band-list dd { margin: var(--sp-0); color: var(--muted); line-height: 1.35; }
.hive-advice-band-list .repo-issue-pill, .hive-advice-band-list .repo-pr-pill { display: inline-flex; align-items: center; max-width: none; font-size: var(--fs-xs); font-weight: 700; }
+ .hive-advice-sub { margin-top: var(--sp-3); color: var(--muted); font-size: var(--fs-2xs); font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
+ .hive-advice-why, .hive-advice-items { margin: var(--sp-1) var(--sp-0) var(--sp-0); padding-left: var(--sp-5); font-size: var(--fs-sm); line-height: 1.4; }
+ .hive-advice-why li + li, .hive-advice-items li + li { margin-top: var(--sp-1); }
+ .hive-advice-items { color: var(--muted); }
+ .hive-advice-help { margin-top: var(--sp-2); font-size: var(--fs-sm); }
+ .hive-advice-signals { margin-top: var(--sp-2); color: var(--muted); font-size: var(--fs-xs); }
+ .hive-advice-signals > summary { cursor: pointer; }
@media (max-width: 620px) { .hive-advice-counts { grid-template-columns: 1fr; } .hive-advice-band-list { grid-template-columns: 1fr; } }
.reco-ready { margin-top: 10px; padding: var(--sp-4) var(--sp-5); border-radius: var(--r); font-size: 0.8rem;
background: var(--green-bg); border: 1px solid var(--green-border); color: var(--text); }
@@ -27289,20 +27296,25 @@ Approval and audit records
${approval}`;
${escapeHtml(r.rationale || '')}
`;
}
+ // The raw signals are the evidence behind the numbers, not advice:
+ // folded away so the card reads as why / how to help / where to start.
const sigs = Array.isArray(r.signals) ? r.signals : [];
- const sigHtml = sigs.length ? `signals: ${sigs.map((s) => `${escapeHtml(s.name || '')}=${escapeHtml(s.value || '')}`).join(', ')}
` : '';
+ const sigHtml = sigs.length ? `Numbers behind this
${sigs.map((s) => `${escapeHtml(s.name || '')}=${escapeHtml(s.value || '')}`).join(', ')} ` : '';
+ const why = Array.isArray(r.why) ? r.why : [];
+ const whyHtml = why.length ? `Why they wait
${why.map((w) => `- ${escapeHtml(w)}
`).join('')}
` : '';
+ const helpHtml = r.help ? `How to help: ${escapeHtml(r.help)}.
` : '';
const its = Array.isArray(r.items) ? r.items : [];
- const itemHtml = its.length ? `First: ${its.map((it) => {
+ const itemHtml = its.length ? `
First
${its.map((it) => {
const ref = `${escapeHtml(it.repo || '')}#${escapeHtml(String(it.number || ''))}`;
const link = it.url ? `${ref}` : ref;
- return it.note ? `${link} (${escapeHtml(it.note)})` : link;
- }).join(', ')}
` : '';
+ return `- ${it.note ? `${link} — ${escapeHtml(it.note)}` : link}
`;
+ }).join('')}
` : '';
const links = Array.isArray(r.links) ? r.links : [];
const linkHtml = links.length ? `` : '';
return `-
${i + 1}
${escapeHtml(r.title || r.id || 'Recommendation')}
-
${escapeHtml(r.rationale || '')}
${itemHtml}${linkHtml}${sigHtml}
+ ${escapeHtml(r.rationale || '')}
${whyHtml}${helpHtml}${itemHtml}${linkHtml}${sigHtml}
`;
}).join('');
const bands = Array.isArray(dto.bands) ? dto.bands : [];
diff --git a/src/pkg/hiveadvisor/hiveadvisor.go b/src/pkg/hiveadvisor/hiveadvisor.go
index 791e75af3b..648d5cdcff 100644
--- a/src/pkg/hiveadvisor/hiveadvisor.go
+++ b/src/pkg/hiveadvisor/hiveadvisor.go
@@ -35,6 +35,12 @@ type Recommendation struct {
// origin) and Items name the first few issues/PRs the advice is about.
Links []Link `json:"links,omitempty"`
Items []Item `json:"items,omitempty"`
+ // Why says, in plain words, what the items behind the number wait for,
+ // largest group first ("24 parked until a maintainer decides (20 in
+ // utah-packages, …)."), and Help is the action for the largest few
+ // groups, without a leading label. Only the needs-human rules set them.
+ Why []string `json:"why,omitempty"`
+ Help string `json:"help,omitempty"`
// Bands lists the Overview band keys ("pr/blocked", "issue/waiting") the
// advice names, so a renderer can print each band's rule beside it.
Bands []string `json:"bands,omitempty"`
@@ -292,6 +298,7 @@ func copyRecommendations(in []Recommendation) []Recommendation {
out[i].Signals = append([]Signal(nil), out[i].Signals...)
out[i].Links = append([]Link(nil), out[i].Links...)
out[i].Items = append([]Item(nil), out[i].Items...)
+ out[i].Why = append([]string(nil), out[i].Why...)
out[i].Bands = append([]string(nil), out[i].Bands...)
}
return out
diff --git a/src/pkg/hiveadvisor/human_gate.go b/src/pkg/hiveadvisor/human_gate.go
new file mode 100644
index 0000000000..c08e27226c
--- /dev/null
+++ b/src/pkg/hiveadvisor/human_gate.go
@@ -0,0 +1,287 @@
+package hiveadvisor
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "unicode/utf8"
+)
+
+// Readable needs-human advice. clear-human-gate-prs and unblock-human-issues
+// used to say "59 of 94 open PRs (62%) are your turn (1 INSPECT, 24 DECIDE,
+// 34 REVIEW)" and list five items with their whole ownership reason, which for
+// a parked review is every reviewer perspective's summary run together. An
+// owner could not tell from it what was holding the queue or what to do. The
+// advice now groups the your-turn items by why they wait, in plain words and
+// with where they sit (Recommendation.Why), turns the biggest groups into one
+// "how to help" line (Recommendation.Help), and keeps each listed item's note
+// to one short clause (shortOwnershipNote).
+
+// gateReason is how the advice words one ownership reason code. why completes
+// " …" and reads for one item or many; help is the action, where a
+// trailing " in %s" names the group's main repositories, or is dropped when
+// the group is spread too thin for two names to be the place to start.
+type gateReason struct {
+ why string
+ help string
+}
+
+// gateReasons covers every reason code the dashboard's your-turn decision
+// produces (pkg/dashboard ownershipReasonVerbs; a dashboard test keeps the two
+// in step). review_decision_required is split further by gateReasonFor.
+var gateReasons = map[string]gateReason{
+ "sentinel_flagged": {"flagged by the sentinel, to inspect before anything merges", "inspect what the sentinel flagged"},
+ "parked_needs_human": {"parked for a person to review", "review the parked items in %s"},
+ "parked_needs_direction": {"parked until a maintainer gives direction", "give direction on the parked items in %s"},
+ "parked_needs_decision": {"parked until a maintainer decides", "answer the parked decisions in %s"},
+ "parked_needs_spec": {"parked until someone writes a spec", "write the missing specs in %s"},
+ "untrusted_reporter": {"from reporters the hive does not trust yet", "admit or close reports from new reporters"},
+ "reporter_confirmation_stale": {"waiting on a reporter who went quiet", "close reports whose reporter went quiet"},
+ "verified_done": {"verified done but not closed", "close what the hive verified done"},
+ "agent_filed_unacknowledged": {"filed by agents and not yet acknowledged", "acknowledge or close agent-filed issues in %s"},
+ "level_gate_hold": {"held by the ACMM level gate", "review the level-gate holds and release the ones you agree with"},
+ "outreach_hold": {"held by the outreach lane for review", "review the outreach holds"},
+ "policy_hold": {"held by hive policy for review", "review the policy holds in %s"},
+ "hold_source_unknown": {"held with no recorded reason", "release or explain the unexplained holds"},
+ "hive_pr_needs_human": {"hive PRs escalated for a person", "review the escalated hive PRs in %s"},
+ "adjudicator_wait_elapsed": {"escalated with no verdict after the adjudicator's wait", "decide the escalations the adjudicator never answered"},
+ "reviewer_unavailable": {"escalated with no adjudicator available", "decide the escalations no adjudicator could take"},
+ "superseded_recommended_close": {"superseded by another PR", "close the superseded PRs"},
+ "superseded_extra_files_review": {"superseded but adding files the replacement did not", "check the extra files on superseded PRs"},
+ "adjudicator_recommended_close": {"the adjudicator recommended closing", "close what the adjudicator recommended closing"},
+ "adjudicator_reescalated": {"re-escalated after the adjudicator's pass", "decide the re-escalated items"},
+ "adjudicator_handed_off": {"handed to a person by the adjudicator", "pick up what the adjudicator handed off"},
+ "person_pr_needs_human": {"people's PRs that need a human review", "review people's PRs in %s"},
+ "review_requires_human": {"handed to a person by the hive's review to decide", "read the hive's review and decide in %s"},
+ "review_rejected": {"rejected by the hive's review", "close or rework what the hive's review rejected"},
+ "review_dispatch_hold": {"with the hive's review parked for a person", "read the parked hive reviews in %s"},
+ "review_loop_cap": {"with the hive's review and fix loop stopped", "finish the PRs whose review and fix loop stopped"},
+ "stale_safety_net": {"untouched long enough that the safety net handed them to a person", "look at the long-untouched items in %s"},
+ "blocked_unexplained": {"labelled blocked with no blocker named", "name the blocker or lift the label"},
+ "pr_gate_label": {"held by a gate label", "act on the gate labels or remove them"},
+ "hand_merge_required": {"that only a person can merge", "merge the reviewed PRs by hand in %s"},
+ "review_decision_required": {"waiting for an approving review that branch protection requires", "approve PRs in %s"},
+ "run_checkpoint": {"runs waiting at a checkpoint", "answer the run checkpoints"},
+ "run_stale_plan": {"runs with a stale plan", "re-plan, approve or abandon the stale runs"},
+ "run_stage_exhausted": {"runs whose stage used up its retries", "reset or abandon the stuck runs"},
+ "approval_pending": {"waiting in Approvals", "approve or reject what waits in Approvals"},
+}
+
+// review_decision_required splits by the rule GitHub is waiting on
+// (github.PullRequest.HumanReviewBlocksMerge): a reviewer's requested
+// changes and a code owner's review ask different people for different things.
+// The reason says "changes requested by @a" when it knows who asked, and "a
+// reviewer requested changes" when it does not; both are the changes group.
+var (
+ gateChangesRequested = gateReason{"with changes a reviewer requested", "follow up on the requested changes in %s"}
+ gateCodeOwnerReview = gateReason{"waiting for a code owner's review", "code owners: review PRs in %s"}
+)
+
+// DescribesReasonCode reports whether the advice has its own wording for an
+// ownership reason code. Unknown codes still group, under their reason text.
+func DescribesReasonCode(code string) bool {
+ _, ok := gateReasons[code]
+ return ok
+}
+
+// gateReasonFor returns the group key and wording for one your-turn item.
+func gateReasonFor(o Ownership) (string, gateReason) {
+ if o.ReasonCode == "review_decision_required" {
+ reason := strings.ToLower(o.Reason)
+ switch {
+ case strings.Contains(reason, "changes requested"), strings.Contains(reason, "requested changes"):
+ return o.ReasonCode + "/changes", gateChangesRequested
+ case strings.Contains(reason, "code-owner"), strings.Contains(reason, "code owner"):
+ return o.ReasonCode + "/code-owner", gateCodeOwnerReview
+ }
+ }
+ if g, ok := gateReasons[o.ReasonCode]; ok {
+ return o.ReasonCode, g
+ }
+ label := firstClause(o.Reason)
+ if label == "" {
+ label = nonEmpty(o.ReasonCode, o.Verb)
+ }
+ return "other/" + label, gateReason{why: "waiting for a person: " + strings.ToLower(label)}
+}
+
+// maxWhyGroups is how many groups the Why list names; the rest fold into one
+// "more" line, unless only one group is left over.
+const maxWhyGroups = 5
+
+// maxHelpActions is how many of the largest groups the Help line names.
+const maxHelpActions = 3
+
+type gateGroup struct {
+ key string
+ words gateReason
+ count int
+ repos map[string]int
+}
+
+// humanGateWhy groups the your-turn items by why they wait and returns the
+// Why lines (largest group first) and the Help actions for the largest few.
+func humanGateWhy(owns []Ownership, repos []string) (why []string, help string) {
+ groups := map[string]*gateGroup{}
+ for i, o := range owns {
+ key, words := gateReasonFor(o)
+ g := groups[key]
+ if g == nil {
+ g = &gateGroup{key: key, words: words, repos: map[string]int{}}
+ groups[key] = g
+ }
+ g.count++
+ g.repos[repos[i]]++
+ }
+ sorted := make([]*gateGroup, 0, len(groups))
+ for _, g := range groups {
+ sorted = append(sorted, g)
+ }
+ sort.Slice(sorted, func(i, j int) bool {
+ if sorted[i].count != sorted[j].count {
+ return sorted[i].count > sorted[j].count
+ }
+ return sorted[i].key < sorted[j].key
+ })
+
+ rest := 0
+ for i, g := range sorted {
+ if i >= maxWhyGroups && len(sorted) > maxWhyGroups+1 {
+ rest += g.count
+ continue
+ }
+ why = append(why, fmt.Sprintf("%d %s (%s).", g.count, g.words.why, whereText(g.repos, g.count)))
+ }
+ if rest > 0 {
+ why = append(why, fmt.Sprintf("%d more for other reasons; the full list has each one.", rest))
+ }
+
+ var actions []string
+ seen := map[string]bool{}
+ for _, g := range sorted {
+ if len(actions) == maxHelpActions {
+ break
+ }
+ if g.words.help == "" {
+ continue
+ }
+ action := g.words.help
+ if strings.HasSuffix(action, " in %s") {
+ top := topRepos(g.repos, 2)
+ held := 0
+ for _, name := range top {
+ held += g.repos[name]
+ }
+ if len(top) > 0 && 2*held > g.count {
+ action = fmt.Sprintf(action, joinAnd(top))
+ } else {
+ action = strings.TrimSuffix(action, " in %s")
+ }
+ }
+ if !seen[action] {
+ seen[action] = true
+ actions = append(actions, action)
+ }
+ }
+ return why, strings.Join(actions, "; ")
+}
+
+// topRepos returns up to n repositories by item count, ties by name.
+func topRepos(counts map[string]int, n int) []string {
+ names := make([]string, 0, len(counts))
+ for name := range counts {
+ if strings.TrimSpace(name) != "" {
+ names = append(names, name)
+ }
+ }
+ sort.Slice(names, func(i, j int) bool {
+ if counts[names[i]] != counts[names[j]] {
+ return counts[names[i]] > counts[names[j]]
+ }
+ return names[i] < names[j]
+ })
+ if len(names) > n {
+ names = names[:n]
+ }
+ return names
+}
+
+// whereText says where a group's items sit: "all in common", or the three
+// largest repositories with counts and how many are elsewhere.
+func whereText(counts map[string]int, total int) string {
+ top := topRepos(counts, 3)
+ if len(top) == 0 {
+ return "repository unknown"
+ }
+ if len(top) == 1 && counts[top[0]] == total {
+ return "all in " + top[0]
+ }
+ parts := make([]string, 0, len(top)+1)
+ listed := 0
+ for _, name := range top {
+ parts = append(parts, fmt.Sprintf("%d in %s", counts[name], name))
+ listed += counts[name]
+ }
+ if other := total - listed; other > 0 {
+ parts = append(parts, fmt.Sprintf("%d elsewhere", other))
+ }
+ return strings.Join(parts, ", ")
+}
+
+// joinAnd renders "a", "a and b".
+func joinAnd(names []string) string {
+ switch len(names) {
+ case 0:
+ return "your repositories"
+ case 1:
+ return names[0]
+ }
+ return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
+}
+
+// maxNoteRunes bounds an item note. A longer reason is cut to its first
+// clause ("Hive's review is parked for a person: correctness requires …"
+// becomes "Hive's review is parked for a person"); the full reason stays in
+// the inbox and the CSV.
+const maxNoteRunes = 100
+
+// noteDetailOnly are the reason codes whose reason opens with a clause the
+// verb already says ("Waiting on a person's review: " beside REVIEW), so the
+// item note keeps only what follows it: "REVIEW — a reviewer requested
+// changes".
+var noteDetailOnly = map[string]bool{"review_decision_required": true}
+
+// shortOwnershipNote is the item note: the verb and a reason short enough to
+// read in a list.
+func shortOwnershipNote(o Ownership) string {
+ reason := strings.TrimSpace(o.Reason)
+ if head := firstClause(reason); noteDetailOnly[o.ReasonCode] && head != reason {
+ reason = strings.TrimSpace(strings.TrimPrefix(reason, head+": "))
+ }
+ if utf8.RuneCountInString(reason) > maxNoteRunes {
+ if head := firstClause(reason); head != "" && utf8.RuneCountInString(head) <= maxNoteRunes {
+ reason = head
+ } else {
+ reason = string([]rune(reason)[:maxNoteRunes-1]) + "…"
+ }
+ }
+ return ownershipNote(Ownership{Verb: o.Verb, Reason: reason})
+}
+
+// firstClause is the text before the first ": " (or the whole text).
+func firstClause(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.Index(s, ": "); i > 0 {
+ return strings.TrimSpace(s[:i])
+ }
+ return s
+}
+
+func nonEmpty(values ...string) string {
+ for _, v := range values {
+ if strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
diff --git a/src/pkg/hiveadvisor/human_gate_test.go b/src/pkg/hiveadvisor/human_gate_test.go
new file mode 100644
index 0000000000..19d501cb68
--- /dev/null
+++ b/src/pkg/hiveadvisor/human_gate_test.go
@@ -0,0 +1,197 @@
+package hiveadvisor
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+ "unicode/utf8"
+)
+
+func hasSignal(sigs []Signal, name, value string) bool {
+ for _, s := range sigs {
+ if s.Name == name && s.Value == value {
+ return true
+ }
+ }
+ return false
+}
+
+// projectbluefinParkedReview is actions#675's ownership reason on
+// 2026-10-26: every reviewer perspective's summary, with the same decision
+// request five times. The advice printed all of it.
+const projectbluefinParkedReview = "Hive's review is parked for a person: correctness requires human: Read the workflow at head: all four run blocks now reference quoted env vars; remaining ${{ inputs.* }} are in name:/env: only.; " +
+ "maintainer decision requested: @castrojo should decide whether to supply/waive the consumer-validation evidence for this fork PR, and whether #544 or #675 lands first.; " +
+ "maintainer decision requested: @castrojo should decide whether to supply/waive the consumer-validation evidence for this fork PR, and whether #544 or #675 lands first.; " +
+ "maintainer decision requested: @castrojo should decide whether to supply/waive the consumer-validation evidence for this fork PR, and whether #544 or #675 lands first."
+
+// humanGatePR is one your-turn PR in repo with the dashboard's reason code
+// and text.
+func humanGatePR(repo string, n int, verb, code, reason string) PRItem {
+ return PRItem{Repo: repo, Number: n, Band: PRBandWaiting, Ownership: Ownership{YourTurn: true, Verb: verb, ReasonCode: code, Reason: reason}}
+}
+
+const approvingReview = "Waiting on a person's review: an approving review is required by branch protection (0 given)"
+
+// The shape of the projectbluefin queue on 2026-10-26, scaled down: decisions
+// parked in utah-packages, approvals owed in common and actions, one
+// reviewer's requested changes, and one parked hive review whose reason runs
+// to a paragraph.
+func TestHumanGateAdviceSaysWhyAndHowToHelp(t *testing.T) {
+ var prs []PRItem
+ for i := 0; i < 4; i++ {
+ prs = append(prs, humanGatePR("utah-packages", 600+i, "DECIDE", "parked_needs_decision", "Parked until a maintainer decides"))
+ }
+ prs = append(prs, humanGatePR("utah", 919, "DECIDE", "parked_needs_decision", "Parked until a maintainer decides"))
+ for i := 0; i < 3; i++ {
+ prs = append(prs, humanGatePR("common", 1550+i, "REVIEW", "review_decision_required", approvingReview))
+ }
+ prs = append(prs, humanGatePR("actions", 665, "REVIEW", "review_decision_required", approvingReview))
+ prs = append(prs, humanGatePR("actions", 544, "REVIEW", "review_decision_required", "Waiting on a person's review: a reviewer requested changes"))
+ prs = append(prs, humanGatePR("actions", 675, "REVIEW", "review_dispatch_hold", projectbluefinParkedReview))
+ prs = append(prs, mkPRs(5, "open", nil)...)
+
+ r := find(t, queueCandidates(Queue{PRs: prs}, Thresholds{}), "clear-human-gate-prs")
+ if r.Rationale != "11 of 16 open PRs (68%) are waiting on a person; the hive cannot move them on its own." {
+ t.Fatalf("rationale = %q", r.Rationale)
+ }
+ wantWhy := []string{
+ "5 parked until a maintainer decides (4 in utah-packages, 1 in utah).",
+ "4 waiting for an approving review that branch protection requires (3 in common, 1 in actions).",
+ "1 with changes a reviewer requested (all in actions).",
+ "1 with the hive's review parked for a person (all in actions).",
+ }
+ if !reflect.DeepEqual(r.Why, wantWhy) {
+ t.Fatalf("why =\n%s", strings.Join(r.Why, "\n"))
+ }
+ if want := "answer the parked decisions in utah-packages and utah; approve PRs in common and actions; follow up on the requested changes in actions"; r.Help != want {
+ t.Fatalf("help = %q\nwant %q", r.Help, want)
+ }
+ if !hasSignal(r.Signals, "verbs", "5 DECIDE, 6 REVIEW") {
+ t.Fatalf("signals = %+v", r.Signals)
+ }
+ for _, it := range r.Items {
+ if utf8.RuneCountInString(it.Note) > maxNoteRunes+utf8.RuneCountInString("REVIEW — ") {
+ t.Fatalf("item note too long to read in a list: %q", it.Note)
+ }
+ if it.Number == 675 && it.Note != "REVIEW — Hive's review is parked for a person" {
+ t.Fatalf("parked review note = %q", it.Note)
+ }
+ if it.Number == 544 && it.Note != "REVIEW — a reviewer requested changes" {
+ t.Fatalf("the review-decision note drops the clause REVIEW already says: %q", it.Note)
+ }
+ if it.Number == 665 && it.Note != "REVIEW — an approving review is required by branch protection (0 given)" {
+ t.Fatalf("approval note = %q", it.Note)
+ }
+ }
+ if got := NeedsHumanItems(Queue{PRs: prs}); got[len(got)-1].Note != "REVIEW — "+projectbluefinParkedReview {
+ t.Fatalf("the inbox list keeps the full reason: %q", got[len(got)-1].Note)
+ }
+}
+
+// Past five groups the smallest fold into one line, so the list stays short.
+func TestHumanGateWhyFoldsSmallGroups(t *testing.T) {
+ codes := []string{"parked_needs_decision", "parked_needs_human", "person_pr_needs_human", "review_requires_human", "sentinel_flagged", "hand_merge_required", "review_rejected"}
+ var owns []Ownership
+ var repos []string
+ for i, code := range codes {
+ for j := 0; j < len(codes)-i; j++ {
+ owns = append(owns, Ownership{YourTurn: true, Verb: "REVIEW", ReasonCode: code})
+ repos = append(repos, "octo/demo")
+ }
+ }
+ why, help := humanGateWhy(owns, repos)
+ if len(why) != maxWhyGroups+1 || why[maxWhyGroups] != "3 more for other reasons; the full list has each one." {
+ t.Fatalf("why =\n%s", strings.Join(why, "\n"))
+ }
+ if strings.Count(help, ";") != maxHelpActions-1 {
+ t.Fatalf("help names %d actions, want %d: %q", strings.Count(help, ";")+1, maxHelpActions, help)
+ }
+}
+
+func TestHumanGateWhyWordsUnknownCodesFromTheirReason(t *testing.T) {
+ why, _ := humanGateWhy([]Ownership{{YourTurn: true, Verb: "REVIEW", ReasonCode: "brand_new_code", Reason: "Waiting on something new: details"}}, []string{"a", "b"}[:1])
+ if !reflect.DeepEqual(why, []string{"1 waiting for a person: waiting on something new (all in a)."}) {
+ t.Fatalf("why = %q", why)
+ }
+}
+
+// Each wording github.BranchProtectionBlockReason gives a review decision
+// lands in its own group: "approve PRs" is the wrong advice for a PR whose
+// reviewer asked for changes, named or not.
+func TestHumanGateWhySplitsEveryReviewDecisionWording(t *testing.T) {
+ reasons := []string{
+ "changes requested by @alice",
+ "changes requested by @alice, @bob",
+ "a reviewer requested changes",
+ "code-owner review required by branch protection",
+ "blocked by a branch rule Hive does not read (ruleset requires code-owner review)",
+ "an approving review is required by branch protection (0 given)",
+ }
+ var owns []Ownership
+ var repos []string
+ for _, reason := range reasons {
+ owns = append(owns, Ownership{YourTurn: true, Verb: "REVIEW", ReasonCode: "review_decision_required", Reason: "Waiting on a person's review: " + reason})
+ repos = append(repos, "octo/demo")
+ }
+ why, help := humanGateWhy(owns, repos)
+ want := []string{
+ "3 with changes a reviewer requested (all in octo/demo).",
+ "2 waiting for a code owner's review (all in octo/demo).",
+ "1 waiting for an approving review that branch protection requires (all in octo/demo).",
+ }
+ if !reflect.DeepEqual(why, want) {
+ t.Fatalf("why =\n%s", strings.Join(why, "\n"))
+ }
+ if want := "follow up on the requested changes in octo/demo; code owners: review PRs in octo/demo; approve PRs in octo/demo"; help != want {
+ t.Fatalf("help = %q\nwant %q", help, want)
+ }
+ if got := shortOwnershipNote(owns[0]); got != "REVIEW — changes requested by @alice" {
+ t.Fatalf("note = %q", got)
+ }
+}
+
+func TestWhereTextNamesTheLargestRepos(t *testing.T) {
+ got := whereText(map[string]int{"a": 5, "b": 3, "c": 2, "d": 1, "e": 1}, 12)
+ if got != "5 in a, 3 in b, 2 in c, 2 elsewhere" {
+ t.Fatalf("where = %q", got)
+ }
+}
+
+func TestShortOwnershipNoteCutsAClauselessReason(t *testing.T) {
+ note := shortOwnershipNote(Ownership{Verb: "REVIEW", Reason: strings.Repeat("x", 150)})
+ if utf8.RuneCountInString(note) != utf8.RuneCountInString("REVIEW — ")+maxNoteRunes || !strings.HasSuffix(note, "…") {
+ t.Fatalf("note = %q (%d runes)", note, utf8.RuneCountInString(note))
+ }
+ if got := shortOwnershipNote(Ownership{Verb: "DECIDE", Reason: "Parked until a maintainer decides"}); got != "DECIDE — Parked until a maintainer decides" {
+ t.Fatalf("a short reason is kept whole: %q", got)
+ }
+}
+
+// One group left over is shown, not folded into "1 more".
+func TestHumanGateWhyShowsASingleLeftoverGroup(t *testing.T) {
+ var owns []Ownership
+ for _, code := range []string{"parked_needs_decision", "parked_needs_human", "person_pr_needs_human", "review_requires_human", "sentinel_flagged", "hand_merge_required"} {
+ owns = append(owns, Ownership{YourTurn: true, ReasonCode: code})
+ }
+ why, _ := humanGateWhy(owns, make([]string, len(owns)))
+ if len(why) != 6 || strings.Contains(strings.Join(why, "\n"), "more for other reasons") {
+ t.Fatalf("why =\n%s", strings.Join(why, "\n"))
+ }
+}
+
+// A spread-out group's action names no repositories: two of six is not where
+// to start.
+func TestHumanGateHelpNamesReposOnlyWhereMostItemsAre(t *testing.T) {
+ owns := make([]Ownership, 6)
+ for i := range owns {
+ owns[i] = Ownership{YourTurn: true, ReasonCode: "review_requires_human"}
+ }
+ _, help := humanGateWhy(owns, []string{"common", "common", "chairlift", "dakota-iso", "utah", "utah-packages"})
+ if help != "read the hive's review and decide" {
+ t.Fatalf("help = %q", help)
+ }
+ _, help = humanGateWhy(owns, []string{"common", "common", "common", "chairlift", "utah", "utah-packages"})
+ if help != "read the hive's review and decide in common and chairlift" {
+ t.Fatalf("help = %q", help)
+ }
+}
diff --git a/src/pkg/hiveadvisor/queue.go b/src/pkg/hiveadvisor/queue.go
index 571841a71a..885abe503e 100644
--- a/src/pkg/hiveadvisor/queue.go
+++ b/src/pkg/hiveadvisor/queue.go
@@ -286,6 +286,8 @@ type prBreakdown struct {
blocked, blockedCI, blockedConflict, verdict int
yourTurn, toClose int
yourTurnVerbs map[string]int
+ yourTurnOwn []Ownership
+ yourTurnRepos []string
staleBlocked int
oldestBlockedDays int
checks, verdicts, lanes, authors, repos []bucket
@@ -296,6 +298,8 @@ type issueBreakdown struct {
open int
yourTurn, toClose int
yourTurnVerbs map[string]int
+ yourTurnOwn []Ownership
+ yourTurnRepos []string
done, agentFiled int
yourTurnItems, doneItems []Item
}
@@ -322,7 +326,8 @@ func summarizePRs(prs []PRItem) prBreakdown {
b.yourTurn++
}
b.yourTurnVerbs[own.Verb]++
- b.yourTurnItems = append(b.yourTurnItems, Item{Repo: pr.Repo, Number: pr.Number, URL: pr.URL, Note: ownershipNote(own)})
+ b.yourTurnOwn, b.yourTurnRepos = append(b.yourTurnOwn, own), append(b.yourTurnRepos, pr.Repo)
+ b.yourTurnItems = append(b.yourTurnItems, Item{Repo: pr.Repo, Number: pr.Number, URL: pr.URL, Note: shortOwnershipNote(own)})
}
switch pr.Band {
case PRBandBlocked:
@@ -379,7 +384,8 @@ func summarizeIssues(issues []IssueItem) issueBreakdown {
b.yourTurn++
}
b.yourTurnVerbs[own.Verb]++
- b.yourTurnItems = append(b.yourTurnItems, Item{Repo: issue.Repo, Number: issue.Number, URL: issue.URL, Note: ownershipNote(own)})
+ b.yourTurnOwn, b.yourTurnRepos = append(b.yourTurnOwn, own), append(b.yourTurnRepos, issue.Repo)
+ b.yourTurnItems = append(b.yourTurnItems, Item{Repo: issue.Repo, Number: issue.Number, URL: issue.URL, Note: shortOwnershipNote(own)})
}
switch issue.Band {
case IssueBandDone:
@@ -491,10 +497,11 @@ func queueCandidates(q Queue, th Thresholds) []Recommendation {
if meetsPct(prs.yourTurn+prs.toClose, prs.open, th.NeedsHumanPRPct) {
n := prs.yourTurn + prs.toClose
r := rec("clear-human-gate-prs", 84, "Clear the human gate on PRs",
- fmt.Sprintf("%d of %d open PRs (%d%%) are your turn (%s) — release, review, inspect or close them.",
- n, prs.open, pct(n, prs.open), verbSummary(prs.yourTurnVerbs)),
- sig("overview_open_prs", prs.open), sig("needs_human", n), sig("your_turn", prs.yourTurn), sig("to_close", prs.toClose))
+ fmt.Sprintf("%d of %d open PRs (%d%%) are waiting on a person; the hive cannot move them on its own.",
+ n, prs.open, pct(n, prs.open)),
+ sig("overview_open_prs", prs.open), sig("needs_human", n), sig("your_turn", prs.yourTurn), sig("to_close", prs.toClose), sig("verbs", verbSummary(prs.yourTurnVerbs)))
r.Links = []Link{exportLink("Your-turn PRs (CSV)", prExportPath, "slice", "your-turn,to-close")}
+ r.Why, r.Help = humanGateWhy(prs.yourTurnOwn, prs.yourTurnRepos)
r.Items = capItems(prs.yourTurnItems)
out = append(out, r)
}
@@ -502,10 +509,11 @@ func queueCandidates(q Queue, th Thresholds) []Recommendation {
if meetsPct(issues.yourTurn+issues.toClose, issues.open, th.NeedsHumanIssuePct) {
n := issues.yourTurn + issues.toClose
r := rec("unblock-human-issues", 82, "Unblock the human queue",
- fmt.Sprintf("%d of %d open issues (%d%%) are your turn (%s) — decide the oldest first.",
- n, issues.open, pct(n, issues.open), verbSummary(issues.yourTurnVerbs)),
- sig("overview_open_issues", issues.open), sig("needs_human", n), sig("your_turn", issues.yourTurn), sig("to_close", issues.toClose))
+ fmt.Sprintf("%d of %d open issues (%d%%) are waiting on a person; the oldest are listed first.",
+ n, issues.open, pct(n, issues.open)),
+ sig("overview_open_issues", issues.open), sig("needs_human", n), sig("your_turn", issues.yourTurn), sig("to_close", issues.toClose), sig("verbs", verbSummary(issues.yourTurnVerbs)))
r.Links = []Link{exportLink("Your-turn issues (CSV)", issueExportPath, "slice", "your-turn,to-close")}
+ r.Why, r.Help = humanGateWhy(issues.yourTurnOwn, issues.yourTurnRepos)
r.Items = capItems(issues.yourTurnItems)
out = append(out, r)
}
diff --git a/src/pkg/hiveadvisor/queue_test.go b/src/pkg/hiveadvisor/queue_test.go
index d47b971401..25d5ddca3c 100644
--- a/src/pkg/hiveadvisor/queue_test.go
+++ b/src/pkg/hiveadvisor/queue_test.go
@@ -241,9 +241,19 @@ func TestIssueScenarioLeadsWithBlockedPRs(t *testing.T) {
t.Fatalf("throttle-lane = %+v", lane)
}
human := find(t, got.Recommendations, "unblock-human-issues")
- if !strings.Contains(human.Rationale, "117 of 295 open issues (39%) are your turn (59 DECIDE, 58 STEER)") {
+ if !strings.Contains(human.Rationale, "117 of 295 open issues (39%) are waiting on a person") {
t.Fatalf("unblock-human-issues = %q", human.Rationale)
}
+ wantWhy := []string{
+ "59 parked until a maintainer decides (all in octo/demo).",
+ "58 parked until a maintainer gives direction (all in octo/demo).",
+ }
+ if !reflect.DeepEqual(human.Why, wantWhy) || human.Help != "answer the parked decisions in octo/demo; give direction on the parked items in octo/demo" {
+ t.Fatalf("unblock-human-issues why=%q help=%q", human.Why, human.Help)
+ }
+ if !hasSignal(human.Signals, "verbs", "59 DECIDE, 58 STEER") {
+ t.Fatalf("the per-verb split moved to the signals: %+v", human.Signals)
+ }
done := find(t, got.Recommendations, "confirm-and-close")
if !strings.Contains(done.Rationale, "92 issues are agent-marked done; confirming them shrinks the backlog by 31%") || done.Links[0].URL != "/api/overview/issues.csv?band=done" {
t.Fatalf("confirm-and-close = %+v", done)
@@ -307,8 +317,8 @@ func TestYourTurnPRsAreCountedFromTheDecisionOldestFirst(t *testing.T) {
}
recs := queueCandidates(Queue{PRs: prs}, Thresholds{})
r := find(t, recs, "clear-human-gate-prs")
- if !strings.Contains(r.Rationale, "3 of 5 open PRs (60%) are your turn (2 REVIEW, 1 CLOSE)") {
- t.Fatalf("rationale = %q", r.Rationale)
+ if !strings.Contains(r.Rationale, "3 of 5 open PRs (60%) are waiting on a person") || !hasSignal(r.Signals, "verbs", "2 REVIEW, 1 CLOSE") {
+ t.Fatalf("rationale = %q signals = %+v", r.Rationale, r.Signals)
}
gotOrder := []int{r.Items[0].Number, r.Items[1].Number, r.Items[2].Number}
if !reflect.DeepEqual(gotOrder, []int{2, 3, 1}) || len(r.Items) != 3 {