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, /