diff --git a/changelog.d/changed-acmm-merge-queue-capability.md b/changelog.d/changed-acmm-merge-queue-capability.md new file mode 100644 index 0000000000..fba03004dd --- /dev/null +++ b/changelog.d/changed-acmm-merge-queue-capability.md @@ -0,0 +1 @@ +- The ACMM `acmm:merge-queue` check now tests the capability on the default branch instead of a file name ([#10891](https://github.com/hivecommons/hive/issues/10891), part of [#10884](https://github.com/hivecommons/hive/issues/10884)). It passes, and counts toward Level 6, when GitHub's merge queue is on ("satisfied by GitHub merge queue") or when the repo's `merge_strategy` is `hive-serialized` and the branch has a known, non-empty set of required checks ("satisfied by Hive serialized merge lane"). A pass that rests only on a marker file (`.github/workflows/merge-queue.yml`, `.github/merge-queue.yml`, `.prow.yaml`, `tide.yaml`) keeps passing, labelled "file only, not verified", so no repo loses readiness. A failing row offers an owner the action to use the serialized merge lane. On personal-account repositories, where GitHub's merge queue is unavailable, the gap ticket (single or "Open Issues for L6") explains the serialized merge lane and required checks instead of asking for a file and no longer carries `ai-fix-requested`; organization repositories get the same ticket as before. The tooltip and the Level 6 description now read "a merge queue or equivalent serialized merging". diff --git a/src/docs/acmm-waivers.md b/src/docs/acmm-waivers.md index 42784928aa..577f41d0b9 100644 --- a/src/docs/acmm-waivers.md +++ b/src/docs/acmm-waivers.md @@ -114,6 +114,31 @@ In the multi-repo aggregate a criterion is marked waived only when *no* repo detected the real thing — one repo holding the file makes the fleet-wide claim true on its own. +## Hive-verified credit is not a waiver + +Some criteria Hive can verify itself, from evidence a repository cannot +author. That credit lands in the result's `satisfied_by` field (not +`waiver_satisfied_by`), the row reads "satisfied by …", and unlike a waiver it +**does** count toward the level: + +- `acmm:github-actions-ai` — a managed repo with Hive agent activity in the + last 30 days ("satisfied by Hive agent loop"). +- `acmm:merge-queue` — tested as a capability on the default branch, not a + file name: GitHub's merge queue is on ("satisfied by GitHub merge queue"), + or the repo's `merge_strategy` is `hive-serialized` and the branch has a + known, non-empty set of required checks ("satisfied by Hive serialized + merge lane"). A repo that passes only because one of the marker files + exists keeps passing and keeps scoring, but the row is labelled + "file only, not verified". On a failing row the dashboard offers an owner + the action to use the serialized merge lane (it sets the repo's + `merge_strategy` to `hive-serialized`). For a repository owned by a personal account, + where GitHub's merge queue is unavailable, the gap ticket explains the + serialized merge lane and required checks instead of asking for a file, and + carries only the `acmm` label (no `ai-fix-requested`: the fix is an owner + setting). Organization repositories get the same ticket as before. The + check costs three read-only calls per repo per evaluation (the repository, + classic branch protection and the branch's rules). + ## Cost Effectively zero for repos that do not use it. `prefetchDirectories` already diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md index 51b8dec993..8e165e94ce 100644 --- a/src/docs/api-reference.md +++ b/src/docs/api-reference.md @@ -72,7 +72,7 @@ with `run/spec`. | Method | Path | Auth | Description | Source | | --- | --- | --- | --- | --- | -| `POST` | `/api/runs/{key}/abandon` | Owner only | Terminally abandon a staged run. Body `{"reason":""}`, reason required. Atomically removes its stage leases and persists a non-expiring admission tombstone; old generations cannot resume. Records an owner audit and lifecycle event, and retained timeline detail shows `state=abandoned`. Automatic run triage, design admission, and explicit owner starts cannot restart this run key; no abandonment reversal operation is provided. Does not close the source issue. Returns `{ok,key,state,reason}`. 400 for invalid/missing reason, 403 for an unverified owner, 404 for no live staged lease, 409 for a concurrent generation change, 500 for a persistence failure (leases remain unchanged). | `handleRunAbandon`, `pkg/dashboard/api.go:456` | +| `POST` | `/api/runs/{key}/abandon` | Owner only | Terminally abandon a staged run. Body `{"reason":""}`, reason required. Atomically removes its stage leases and persists a non-expiring admission tombstone; old generations cannot resume. Records an owner audit and lifecycle event, and retained timeline detail shows `state=abandoned`. Automatic run triage, design admission, and explicit owner starts cannot restart this run key; no abandonment reversal operation is provided. Does not close the source issue. Returns `{ok,key,state,reason}`. 400 for invalid/missing reason, 403 for an unverified owner, 404 for no live staged lease, 409 for a concurrent generation change, 500 for a persistence failure (leases remain unchanged). | `handleRunAbandon`, `pkg/dashboard/api.go:458` | ## Snapshots and style @@ -177,9 +177,9 @@ with `run/spec`. | `POST` | `/api/config/governor/repos/check-access` | Dashboard auth/session | Governor Repo Check Access | `pkg/dashboard/api.go:291` | | `PUT` | `/api/config/github` | Owner only | Config GitHub | `pkg/dashboard/api.go:292` | | `GET` | `/api/config/github/forge-apps` | Dashboard auth/session | Config GitHub Forge Apps | `pkg/dashboard/api.go:295` | -| `GET` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Get | `pkg/dashboard/api.go:328` | -| `PUT` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Set | `pkg/dashboard/api.go:329` | -| `GET` | `/api/config/backends` | Dashboard auth/session | Backends | `pkg/dashboard/api.go:330` | +| `GET` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Get | `pkg/dashboard/api.go:330` | +| `PUT` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Set | `pkg/dashboard/api.go:331` | +| `GET` | `/api/config/backends` | Dashboard auth/session | Backends | `pkg/dashboard/api.go:332` | | `GET` | `/api/config/governor/threshold-scaling` | Owner only | Governor Threshold Scaling Get | `pkg/dashboard/api.go:205` | | `PUT` | `/api/config/governor/threshold-scaling` | Owner only | Governor Threshold Scaling Set | `pkg/dashboard/api.go:206` | @@ -187,8 +187,8 @@ with `run/spec`. | `PUT` | `/api/config/governor/watchdog` | Owner only | Governor Watchdog | `pkg/dashboard/api.go:214` | | `GET` | `/api/config/escalation` | Owner only | Escalation Config Get | `pkg/dashboard/api.go:217` | | `PUT` | `/api/config/escalation` | Owner only | Escalation Config Set | `pkg/dashboard/api.go:218` | -| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:454` | -| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:455` | +| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:456` | +| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:457` | | `GET` | `/api/config/review` | Dashboard auth/session | Review Config Get | `pkg/dashboard/api.go:221` | | `PUT` | `/api/config/review` | Owner only | Review Config Set | `pkg/dashboard/api.go:224` | | `PUT` | `/api/config/governor/features` | Owner only | Governor Features | `pkg/dashboard/api.go:230` | @@ -232,7 +232,7 @@ with `run/spec`. | `GET` | `/api/model-advisor` | Dashboard auth/session | Model Advisor | `pkg/dashboard/api.go:166` | | `GET` | `/api/governor/pr-models` | Dashboard auth/session | Agent-authored PR distribution by normalized attribution model/backend for `window=7d`, `30d`, or `all`, including per-model rework stats and the top 10 most-reworked PRs | `pkg/dashboard/api.go:167` | | `GET` | `/api/reviewer/accuracy` | Dashboard auth/session | Reviewer verdict calibration for the configured window: false-approve/false-block rates and confidence buckets per perspective and reviewer model | `pkg/dashboard/api.go:223` | -| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:451` | +| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:453` | | `GET` | `/api/agents` | Dashboard auth/session | Agents List | `pkg/dashboard/api.go:297` | | `POST` | `/api/agents` | Owner only | Agent Create | `pkg/dashboard/api.go:298` | | `POST` | `/api/agents/import` | Owner only | Agent Import | `pkg/dashboard/api.go:299` | @@ -273,12 +273,12 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of ` | `GET` | `/api/repos/pauses` | Dashboard auth/session | Every repo pause with its provenance. Read-only — the same state is already on the repository cards | `pkg/dashboard/api_repo_pause.go` | -| `GET` | `/api/repos/{owner}/{repo}/hold-permission` | Dashboard auth/session | Reports whether the signed-in user can toggle hold chips for a repository card. Owners are allowed; otherwise the server checks GitHub `repos/{owner}/{repo}/collaborators/{user}/permission` and briefly caches the effective permission | `pkg/dashboard/api.go:319`, `pkg/dashboard/api_repo_hold.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/hold` | Owner or repo write/maintain/admin | Add or remove a dashboard hold chip for an issue or PR. Body `{"held": true}` adds the hive's exact-match canonical `hive-pause/` label; `{"held": false}` removes exactly the labels causing the hold (`hive-pause/` and/or generic hold labels) and never removes `hive/` provenance. Every toggle writes an audit-log entry; the #8851 upgrade migration writes `/data/hive-hold-migration-.json` with audit-backed, provenance-only, and ambiguous legacy `hive/` classifications. | `pkg/dashboard/api.go:320`, `pkg/dashboard/api_repo_hold.go` | -| `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:322`, `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:323` | -| `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:324` | -| `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:326`, `pkg/dashboard/api_hive_advice.go` | +| `GET` | `/api/repos/{owner}/{repo}/hold-permission` | Dashboard auth/session | Reports whether the signed-in user can toggle hold chips for a repository card. Owners are allowed; otherwise the server checks GitHub `repos/{owner}/{repo}/collaborators/{user}/permission` and briefly caches the effective permission | `pkg/dashboard/api.go:321`, `pkg/dashboard/api_repo_hold.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/hold` | Owner or repo write/maintain/admin | Add or remove a dashboard hold chip for an issue or PR. Body `{"held": true}` adds the hive's exact-match canonical `hive-pause/` label; `{"held": false}` removes exactly the labels causing the hold (`hive-pause/` and/or generic hold labels) and never removes `hive/` provenance. Every toggle writes an audit-log entry; the #8851 upgrade migration writes `/data/hive-hold-migration-.json` with audit-backed, provenance-only, and ambiguous legacy `hive/` classifications. | `pkg/dashboard/api.go:322`, `pkg/dashboard/api_repo_hold.go` | +| `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:324`, `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:325` | +| `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:326` | +| `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:328`, `pkg/dashboard/api_hive_advice.go` | ## Cost, tokens, telemetry @@ -300,44 +300,44 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of ` | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/knowledge` | Dashboard auth/session | Knowledge List | `pkg/dashboard/api.go:333` | -| `GET` | `/api/knowledge/export` | Dashboard auth/session | Knowledge Export | `pkg/dashboard/api.go:334` | -| `GET` | `/api/knowledge/search` | Dashboard auth/session | Knowledge Search | `pkg/dashboard/api.go:335` | -| `GET` | `/api/knowledge/health` | Dashboard auth/session | Knowledge Health | `pkg/dashboard/api.go:341` | -| `GET` | `/api/knowledge/stats` | Dashboard auth/session | Knowledge Stats | `pkg/dashboard/api.go:342` | -| `GET` | `/api/knowledge/graph` | Dashboard auth/session | Knowledge Graph | `pkg/dashboard/api.go:343` | -| `GET` | `/api/knowledge/fact-history` | Dashboard auth/session | Fact History | `pkg/dashboard/api.go:344` | -| `POST` | `/api/knowledge/create` | Dashboard auth/session | Knowledge Create | `pkg/dashboard/api.go:345` | -| `POST` | `/api/knowledge/import` | Dashboard auth/session | Knowledge Import | `pkg/dashboard/api.go:346` | -| `POST` | `/api/knowledge/promote` | Dashboard auth/session | Knowledge Promote | `pkg/dashboard/api.go:352` | -| `GET` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs List | `pkg/dashboard/api.go:353` | -| `POST` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Add | `pkg/dashboard/api.go:354` | -| `DELETE` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Remove | `pkg/dashboard/api.go:355` | -| `PUT` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Update | `pkg/dashboard/api.go:356` | -| `DELETE` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Delete | `pkg/dashboard/api.go:357` | -| `GET` | `/api/knowledge/{layer}` | Dashboard auth/session | Knowledge Layer | `pkg/dashboard/api.go:358` | -| `GET` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Fact | `pkg/dashboard/api.go:359` | -| `PUT` | `/api/knowledge/enabled` | Owner only | Knowledge Toggle | `pkg/dashboard/api.go:360` | -| `GET` | `/api/knowledge/bead-synthesizer` | Dashboard auth/session | Bead Synth Status | `pkg/dashboard/api.go:361` | -| `PUT` | `/api/knowledge/bead-synthesizer/enabled` | Owner only | Bead Synth Toggle | `pkg/dashboard/api.go:362` | -| `GET` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults List | `pkg/dashboard/api.go:363` | -| `POST` | `/api/knowledge/vaults` | Owner only | Vaults Connect | `pkg/dashboard/api.go:364` | -| `DELETE` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults Disconnect | `pkg/dashboard/api.go:365` | -| `POST` | `/api/knowledge/vaults/reindex` | Dashboard auth/session | Vaults Reindex | `pkg/dashboard/api.go:366` | -| `GET` | `/api/knowledge/vaults/{name}/facts` | Dashboard auth/session | Vault Facts | `pkg/dashboard/api.go:367` | -| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:368` | -| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:369` | -| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:370` | -| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:371` | -| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:372` | -| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:373` | -| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:374` | -| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:375` | -| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:376` | -| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:377` | -| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:378` | -| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:350` | -| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:351` | +| `GET` | `/api/knowledge` | Dashboard auth/session | Knowledge List | `pkg/dashboard/api.go:335` | +| `GET` | `/api/knowledge/export` | Dashboard auth/session | Knowledge Export | `pkg/dashboard/api.go:336` | +| `GET` | `/api/knowledge/search` | Dashboard auth/session | Knowledge Search | `pkg/dashboard/api.go:337` | +| `GET` | `/api/knowledge/health` | Dashboard auth/session | Knowledge Health | `pkg/dashboard/api.go:343` | +| `GET` | `/api/knowledge/stats` | Dashboard auth/session | Knowledge Stats | `pkg/dashboard/api.go:344` | +| `GET` | `/api/knowledge/graph` | Dashboard auth/session | Knowledge Graph | `pkg/dashboard/api.go:345` | +| `GET` | `/api/knowledge/fact-history` | Dashboard auth/session | Fact History | `pkg/dashboard/api.go:346` | +| `POST` | `/api/knowledge/create` | Dashboard auth/session | Knowledge Create | `pkg/dashboard/api.go:347` | +| `POST` | `/api/knowledge/import` | Dashboard auth/session | Knowledge Import | `pkg/dashboard/api.go:348` | +| `POST` | `/api/knowledge/promote` | Dashboard auth/session | Knowledge Promote | `pkg/dashboard/api.go:354` | +| `GET` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs List | `pkg/dashboard/api.go:355` | +| `POST` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Add | `pkg/dashboard/api.go:356` | +| `DELETE` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Remove | `pkg/dashboard/api.go:357` | +| `PUT` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Update | `pkg/dashboard/api.go:358` | +| `DELETE` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Delete | `pkg/dashboard/api.go:359` | +| `GET` | `/api/knowledge/{layer}` | Dashboard auth/session | Knowledge Layer | `pkg/dashboard/api.go:360` | +| `GET` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Fact | `pkg/dashboard/api.go:361` | +| `PUT` | `/api/knowledge/enabled` | Owner only | Knowledge Toggle | `pkg/dashboard/api.go:362` | +| `GET` | `/api/knowledge/bead-synthesizer` | Dashboard auth/session | Bead Synth Status | `pkg/dashboard/api.go:363` | +| `PUT` | `/api/knowledge/bead-synthesizer/enabled` | Owner only | Bead Synth Toggle | `pkg/dashboard/api.go:364` | +| `GET` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults List | `pkg/dashboard/api.go:365` | +| `POST` | `/api/knowledge/vaults` | Owner only | Vaults Connect | `pkg/dashboard/api.go:366` | +| `DELETE` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults Disconnect | `pkg/dashboard/api.go:367` | +| `POST` | `/api/knowledge/vaults/reindex` | Dashboard auth/session | Vaults Reindex | `pkg/dashboard/api.go:368` | +| `GET` | `/api/knowledge/vaults/{name}/facts` | Dashboard auth/session | Vault Facts | `pkg/dashboard/api.go:369` | +| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:370` | +| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:371` | +| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:372` | +| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:373` | +| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:374` | +| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:375` | +| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:376` | +| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:377` | +| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:378` | +| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:379` | +| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:380` | +| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:352` | +| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:353` | ## Contribute @@ -445,63 +445,63 @@ always resolved server-side from the validated token. | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status | `pkg/dashboard/api.go:413` | -| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:414` | -| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:415` | -| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:416` | -| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:417` | -| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:418` | -| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:419` | -| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:420` | -| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:421` | -| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:422` | -| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:423` | -| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:424` | -| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:425` | -| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals | `pkg/dashboard/api.go:426` | -| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos | `pkg/dashboard/api.go:427` | -| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:428` | -| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:429` | -| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:430` | -| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:431` | -| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:432` | -| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:433` | +| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status | `pkg/dashboard/api.go:415` | +| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:416` | +| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:417` | +| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:418` | +| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:419` | +| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:420` | +| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:421` | +| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:422` | +| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:423` | +| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:424` | +| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:425` | +| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:426` | +| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:427` | +| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals | `pkg/dashboard/api.go:428` | +| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos | `pkg/dashboard/api.go:429` | +| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:430` | +| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:431` | +| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:432` | +| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:433` | +| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:434` | +| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:435` | ## Inception | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:383` | -| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:384` | -| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:385` | -| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:386` | -| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:387` | -| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:388` | -| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:389` | -| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:390` | -| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:391` | -| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:392` | -| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:393` | -| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:394` | -| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:395` | -| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:396` | +| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:385` | +| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:386` | +| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:387` | +| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:388` | +| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:389` | +| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:390` | +| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:391` | +| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:392` | +| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:393` | +| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:394` | +| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:395` | +| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:396` | +| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:397` | +| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:398` | ## Beads | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:435` | -| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:436` | -| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:437` | -| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:438` | -| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:439` | +| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:437` | +| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:438` | +| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:439` | +| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:440` | +| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:441` | ## Dashboard miscellaneous | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| | `GET` | `/api/audit` | Read-write role | Audit Log — `{"entries": [...]}` envelope, newest first, capped at 200; response shape and the serve-time `user_name` field in [audit-log.md](audit-log.md#get-apiaudit) | `pkg/dashboard/api.go:51` | -| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) — total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe → Heal `promotion` hint; see [agent-watchdog.md](agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:446` | +| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) — total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe → Heal `promotion` hint; see [agent-watchdog.md](agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:448` | | `POST` | `/api/presence` | Dashboard auth/session | Presence | `pkg/dashboard/api.go:60` | | `GET` | `/api/prompt-history` | Dashboard auth/session | Prompt History | `pkg/dashboard/api.go:61` | | `POST` | `/api/self-upgrade` | Owner only | Self Upgrade | `pkg/dashboard/api.go:62` | @@ -515,11 +515,11 @@ always resolved server-side from the validated token. | `GET` | `/api/convergence/soak` | Owner only | Convergence Soak Status | `pkg/dashboard/api.go:244` | -| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:401` | -| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:404` | -| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:405` | -| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:407` | -| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:408` | +| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:403` | +| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:406` | +| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:407` | +| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:409` | +| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:410` | | `POST` | `/api/linear/agent/install` | Owner only | Linear Agent Install (OAuth start) | `pkg/dashboard/api_linear_agent.go:219` | | `GET` | `/api/linear/agent/status` | Owner only | Linear Agent Status | `pkg/dashboard/api_linear_agent.go:220` | | `POST` | `/api/linear/agent/disconnect` | Owner only | Linear Agent Disconnect | `pkg/dashboard/api_linear_agent.go:221` | @@ -533,11 +533,11 @@ always resolved server-side from the validated token. | `POST` | `/api/budget-ignore` | Owner only | Budget Ignore Set | `pkg/dashboard/api.go:169` | | `GET` | `/api/summaries` | Dashboard auth/session | Summaries | `pkg/dashboard/api.go:181` | | `POST` | `/api/prs/{owner}/{repo}/{number}/queue-automerge` | Merger/owner role | Queue PRAuto Merge | `pkg/dashboard/api.go:182` | -| `GET` | `/api/inference/models/{backend}` | Dashboard auth/session | Inference Models | `pkg/dashboard/api.go:331` | -| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:380` | -| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:381` | -| `POST` | `/api/chat` | Dashboard auth/session | Chat | `pkg/dashboard/api.go:410` | -| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:441` | +| `GET` | `/api/inference/models/{backend}` | Dashboard auth/session | Inference Models | `pkg/dashboard/api.go:333` | +| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:382` | +| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:383` | +| `POST` | `/api/chat` | Dashboard auth/session | Chat | `pkg/dashboard/api.go:412` | +| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:443` | | `GET` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:248` | | `POST` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:249` | | `GET` | `/api/docs` | Dashboard auth/session | APIDocs | `pkg/dashboard/api_contribute.go:250` | diff --git a/src/pkg/config/repo_merge_strategy_test.go b/src/pkg/config/repo_merge_strategy_test.go index 889004f944..189b405852 100644 --- a/src/pkg/config/repo_merge_strategy_test.go +++ b/src/pkg/config/repo_merge_strategy_test.go @@ -74,6 +74,7 @@ func TestInvalidMergeStrategyFailsValidationAndStopsAutoMerge(t *testing.T) { cfg.Project.Org = "acme" cfg.GitHub.Token = "x" cfg.ACMMLevel = &level + cfg.Agents = map[string]AgentConfig{"scanner": {Backend: "claude"}} cfg.Project.RepoPolicies = []RepoPolicy{{Repo: "console", MergeStrategy: "hive-serialised"}} err := cfg.Validate() diff --git a/src/pkg/dashboard/acmm_merge_queue.go b/src/pkg/dashboard/acmm_merge_queue.go new file mode 100644 index 0000000000..b8a54155e2 --- /dev/null +++ b/src/pkg/dashboard/acmm_merge_queue.go @@ -0,0 +1,146 @@ +package dashboard + +import ( + "context" + "fmt" + "strings" + + gh "github.com/google/go-github/v72/github" + "github.com/hivecommons/hive/pkg/config" + "github.com/hivecommons/hive/pkg/github" +) + +// acmm:merge-queue is credited from the capability, not a file name (#10891). +// The criterion ID is shared with the upstream ACMM scanner and never changes. +const ( + acmmMergeQueueID = "acmm:merge-queue" + // SatisfiedBy values; the dashboard renders them as "satisfied by ". + acmmMergeQueueProviderNative = "GitHub merge queue" + acmmMergeQueueProviderLane = "Hive serialized merge lane" + // acmmOwnerTypeUser is GitHub's owner.type for a personal account. + acmmOwnerTypeUser = "User" +) + +// acmmMergeQueueCapability decides capability credit for acmm:merge-queue on +// the default branch: GitHub's native merge queue, or the hive-serialized +// strategy together with a known, non-empty required-check set. +func acmmMergeQueueCapability(strategy, branch string, rules github.BranchRulesResult) (provider, reason string, ok bool) { + if rules.MergeQueueKnown && rules.MergeQueue { + return acmmMergeQueueProviderNative, fmt.Sprintf("GitHub merge queue is on for %s", branch), true + } + if strategy == config.MergeStrategyHiveSerialized && rules.Known && len(rules.Required) > 0 { + return acmmMergeQueueProviderLane, fmt.Sprintf("merge_strategy %s with required checks on %s: %s", + config.MergeStrategyHiveSerialized, branch, strings.Join(rules.SortedRequired(), ", ")), true + } + return "", "", false +} + +// acmmMergeQueueCredit reads the repo's default branch and its rules and +// returns capability credit for acmm:merge-queue. Any read failure means no +// credit; the file check (and waivers) still apply as before. +func (s *Server) acmmMergeQueueCredit(ctx context.Context, client *gh.Client, owner, repo string) (provider, reason string, ok bool) { + if client == nil || s.deps == nil || s.deps.Config == nil { + return "", "", false + } + info, _, err := client.Repositories.Get(github.WithRESTCaller(ctx, "hive:acmm_merge_queue"), owner, repo) + if err != nil || info == nil { + return "", "", false + } + branch := info.GetDefaultBranch() + if branch == "" { + return "", "", false + } + set, known := s.deps.Config.AutoMerge.RequiredCheckSet() + rules := github.ReadBranchRules(ctx, client, owner, repo, branch, set, known) + return acmmMergeQueueCapability(s.deps.Config.RepoMergeStrategy(repo), branch, rules) +} + +// applyMergeQueueCredit sets capability credit on res, or marks a pass that +// rests only on a marker file. A file-only pass keeps passing (no readiness +// regression); it is only labelled. +func (s *Server) applyMergeQueueCredit(ctx context.Context, client *gh.Client, owner, repo string, res *CriterionResult) { + if provider, reason, ok := s.acmmMergeQueueCredit(ctx, client, owner, repo); ok { + res.Passed = true + res.SatisfiedBy = provider + res.SatisfiedReason = reason + return + } + if res.Passed { + res.FileOnly = true + } +} + +// acmmRepoOwnedByUser reports whether owner/repo belongs to a personal +// account. Any failure to tell answers false, so the ticket stays the one +// organization repositories get today. +func (s *Server) acmmRepoOwnedByUser(ctx context.Context, owner, repo string) bool { + if s.deps == nil || s.deps.GHClient == nil { + return false + } + client := s.deps.GHClient.GoGitHub() + if client == nil { + return false + } + info, _, err := client.Repositories.Get(github.WithRESTCaller(ctx, "hive:acmm_owner_type"), owner, repo) + if err != nil || info == nil { + return false + } + return info.GetOwner().GetType() == acmmOwnerTypeUser +} + +// acmmIssueLabels returns the labels a GitHub gap issue carries. The +// personal-account merge-queue ticket drops ai-fix-requested: its fix is an +// owner setting, not code an agent can write. +func acmmIssueLabels(personalMergeQueue bool) []string { + if personalMergeQueue { + return []string{acmmIssueLabelName} + } + return []string{acmmIssueLabelName, "ai-fix-requested"} +} + +// acmmMergeQueuePersonalIssueContent is the gap ticket for acmm:merge-queue on +// a personal-account repository. GitHub's merge queue is not available there, +// so the ticket points at the serialized merge lane and required checks +// instead of asking for a file. +func acmmMergeQueuePersonalIssueContent(criterion *ACMMCriterion, repo string) (title, body string) { + levelName := acmmLevelNames[criterion.Level] + if levelName == "" { + levelName = "Unknown" + } + title = fmt.Sprintf("[ACMM L%d] Turn on serialized merging (%s)", criterion.Level, criterion.Name) + body = fmt.Sprintf("## ACMM Gap: %s\n\n"+ + "**Level:** L%d %s\n"+ + "**Category:** %s\n"+ + "**Criterion ID:** `%s`\n\n"+ + "### What's needed\n\n"+ + "This repository is owned by a personal account. GitHub's merge queue is only "+ + "available to repositories owned by an organization, so it cannot be turned on here.\n\n"+ + "Hive's serialized merge lane, together with required checks, satisfies this criterion "+ + "instead: when this repository's merge strategy is `%s` and its default branch has at "+ + "least one required status check, the ACMM evaluation credits the criterion as "+ + "\"satisfied by %s\" and it counts toward ACMM Level %d (%s).\n\n"+ + "### Why it matters\n\n"+ + "Serialized merging lands one pull request at a time and validates it against the "+ + "current base first, so two agent pull requests that pass on their own cannot break "+ + "the default branch together.\n\n"+ + "### How to fix\n\n"+ + "This is a setting a repository owner changes, not code:\n\n"+ + "1. In the Hive dashboard's ACMM evaluation, open the %s row for `%s` and choose "+ + "**Use serialized merge lane** (or `POST /api/repos/merge-strategy` with "+ + "`{\"repo\": \"%s\", \"merge_strategy\": \"%s\"}`).\n"+ + "2. Make sure the default branch requires at least one status check, in a branch "+ + "ruleset or in branch protection.\n\n"+ + "---\n"+ + "*Opened by Hive ACMM Evaluation*", + criterion.Name, + criterion.Level, levelName, + criterion.Category, + criterion.ID, + config.MergeStrategyHiveSerialized, + acmmMergeQueueProviderLane, + criterion.Level, levelName, + criterion.Name, repo, + repo, config.MergeStrategyHiveSerialized, + ) + return title, body +} diff --git a/src/pkg/dashboard/acmm_merge_queue_test.go b/src/pkg/dashboard/acmm_merge_queue_test.go new file mode 100644 index 0000000000..90fc39ba0a --- /dev/null +++ b/src/pkg/dashboard/acmm_merge_queue_test.go @@ -0,0 +1,552 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/config" + ghpkg "github.com/hivecommons/hive/pkg/github" +) + +const ( + mqClassicNotProtected = `{"message":"Branch not protected"}` + mqRulesetRequired = `[{"type":"required_status_checks","ruleset_id":1,"parameters":{"strict_required_status_checks_policy":false,"required_status_checks":[{"context":"test"}]}}]` + mqRulesetMergeQueue = `[{"type":"merge_queue","ruleset_id":2,"parameters":{"check_response_timeout_minutes":60,"grouping_strategy":"ALLGREEN","max_entries_to_build":5,"max_entries_to_merge":5,"merge_method":"MERGE","min_entries_to_merge":1,"min_entries_to_merge_wait_minutes":5}}]` +) + +// mqFixture describes one repo as the GitHub API fixture serves it. +type mqFixture struct { + repoStatus int // GET /repos/myorg/repo1; 0 = 200 + ownerType string // owner.type in that response + rules string // GET /repos/myorg/repo1/rules/branches/main body + rootFiles []string +} + +func mqGitHubServer(t *testing.T, f mqFixture, captured *map[string]interface{}) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/repos/myorg/repo1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if f.repoStatus != 0 && f.repoStatus != http.StatusOK { + w.WriteHeader(f.repoStatus) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "name": "repo1", "default_branch": "main", + "owner": map[string]interface{}{"login": "myorg", "type": f.ownerType}, + }) + }) + mux.HandleFunc("/repos/myorg/repo1/branches/main/protection/required_status_checks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(mqClassicNotProtected)) + }) + mux.HandleFunc("/repos/myorg/repo1/rules/branches/main", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + rules := f.rules + if rules == "" { + rules = `[]` + } + _, _ = w.Write([]byte(rules)) + }) + mux.HandleFunc("/repos/myorg/repo1/contents/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/repos/myorg/repo1/contents/") + w.Header().Set("Content-Type", "application/json") + if path == "" && len(f.rootFiles) > 0 { + entries := make([]map[string]interface{}, 0, len(f.rootFiles)) + for _, name := range f.rootFiles { + entries = append(entries, map[string]interface{}{"name": name, "type": "file"}) + } + _ = json.NewEncoder(w).Encode(entries) + return + } + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + }) + mux.HandleFunc("/repos/myorg/repo1/labels", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"name": "acmm"}) + }) + mux.HandleFunc("/repos/myorg/repo1/issues", func(w http.ResponseWriter, r *http.Request) { + if captured != nil { + _ = json.NewDecoder(r.Body).Decode(captured) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "number": 9, "html_url": "https://github.com/myorg/repo1/issues/9", + }) + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return ts +} + +func mqServer(t *testing.T, f mqFixture, strategy string, captured *map[string]interface{}) *Server { + t.Helper() + ts := mqGitHubServer(t, f, captured) + s := NewServer(0, acmmEvalTestLogger()) + deps := testDeps(t) + if strategy != "" { + deps.Config.Project.RepoPolicies = []config.RepoPolicy{{Repo: "repo1", MergeStrategy: strategy}} + } + off := false + deps.Config.Governor.AttributionTrailer = &off + deps.GHClient = ghpkg.NewClientForTest(ts.URL, "myorg", []string{"repo1"}, acmmEvalTestLogger()) + s.RegisterAPI(deps) + return s +} + +func mqFind(t *testing.T, results []CriterionResult) CriterionResult { + t.Helper() + for _, r := range results { + if r.ID == acmmMergeQueueID { + return r + } + } + t.Fatalf("%s missing from results", acmmMergeQueueID) + return CriterionResult{} +} + +func mqLevel(t *testing.T, levels []ACMMLevelScore, lvl int) ACMMLevelScore { + t.Helper() + for _, l := range levels { + if l.Level == lvl { + return l + } + } + t.Fatalf("level %d missing", lvl) + return ACMMLevelScore{} +} + +func TestACMMMergeQueueCapability(t *testing.T) { + known := ghpkg.BranchRulesResult{Known: true, Required: map[string]bool{"test": true, "build": true}, MergeQueueKnown: true} + cases := []struct { + name string + strategy string + rules ghpkg.BranchRulesResult + want string + }{ + {"native queue on direct repo", config.MergeStrategyDirect, ghpkg.BranchRulesResult{MergeQueue: true, MergeQueueKnown: true}, acmmMergeQueueProviderNative}, + {"native queue wins over lane", config.MergeStrategyHiveSerialized, ghpkg.BranchRulesResult{MergeQueue: true, MergeQueueKnown: true, Known: true, Required: map[string]bool{"x": true}}, acmmMergeQueueProviderNative}, + {"queue flag without a readable rules endpoint", config.MergeStrategyDirect, ghpkg.BranchRulesResult{MergeQueue: true}, ""}, + {"lane with required checks", config.MergeStrategyHiveSerialized, known, acmmMergeQueueProviderLane}, + {"lane with unknown checks", config.MergeStrategyHiveSerialized, ghpkg.BranchRulesResult{Required: map[string]bool{"x": true}}, ""}, + {"lane with empty known set", config.MergeStrategyHiveSerialized, ghpkg.BranchRulesResult{Known: true, Required: map[string]bool{}}, ""}, + {"direct with required checks", config.MergeStrategyDirect, known, ""}, + {"unset strategy", "", known, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + provider, reason, ok := acmmMergeQueueCapability(tc.strategy, "main", tc.rules) + if provider != tc.want || ok != (tc.want != "") { + t.Fatalf("got (%q, %v), want %q", provider, ok, tc.want) + } + if ok && !strings.Contains(reason, "main") { + t.Fatalf("reason %q does not name the branch", reason) + } + }) + } + _, reason, _ := acmmMergeQueueCapability(config.MergeStrategyHiveSerialized, "main", known) + if !strings.Contains(reason, "build, test") { + t.Fatalf("lane reason should list required checks in order, got %q", reason) + } +} + +// AC27: the lane with a known, non-empty required-check set satisfies the +// criterion through SatisfiedBy and counts toward Level 6. +func TestACMMMergeQueue_LaneCreditCountsTowardLevel(t *testing.T) { + s := mqServer(t, mqFixture{ownerType: "User", rules: mqRulesetRequired}, config.MergeStrategyHiveSerialized, nil) + eval := s.evaluateAllRepos() + if len(eval.RepoResults) != 1 { + t.Fatalf("RepoResults = %d", len(eval.RepoResults)) + } + rr := eval.RepoResults[0] + res := mqFind(t, rr.CriteriaResults) + if !res.Passed || res.SatisfiedBy != acmmMergeQueueProviderLane || res.FileOnly || res.Waived { + t.Fatalf("lane credit = %+v", res) + } + if !strings.Contains(res.SatisfiedReason, "test") { + t.Fatalf("reason should name the required check: %q", res.SatisfiedReason) + } + if got := mqLevel(t, rr.Levels, 6).Detected; got != 1 { + t.Fatalf("L6 detected = %d, want 1 (the credit counts toward the level)", got) + } + if rr.MergeStrategy != config.MergeStrategyHiveSerialized { + t.Fatalf("MergeStrategy = %q", rr.MergeStrategy) + } + agg := mqFind(t, eval.CriteriaResults) + if agg.SatisfiedBy != acmmMergeQueueProviderLane || agg.Repo != "repo1" { + t.Fatalf("aggregate row = %+v", agg) + } + raw, _ := json.Marshal(res) + if !strings.Contains(string(raw), `"satisfied_by":"Hive serialized merge lane"`) { + t.Fatalf("serialized result: %s", raw) + } +} + +// AC28: GitHub's merge queue satisfies the criterion with no marker file. +func TestACMMMergeQueue_NativeQueueCredit(t *testing.T) { + s := mqServer(t, mqFixture{ownerType: "Organization", rules: mqRulesetMergeQueue}, "", nil) + eval := s.evaluateAllRepos() + res := mqFind(t, eval.RepoResults[0].CriteriaResults) + if !res.Passed || res.SatisfiedBy != acmmMergeQueueProviderNative || res.FileOnly { + t.Fatalf("native credit = %+v", res) + } + if got := mqLevel(t, eval.RepoResults[0].Levels, 6).Detected; got != 1 { + t.Fatalf("L6 detected = %d, want 1", got) + } +} + +// AC29: a pass resting only on a marker file keeps passing and keeps +// scoring, labelled file only. +func TestACMMMergeQueue_FileOnlyKeepsPassing(t *testing.T) { + for _, tc := range []struct { + name string + f mqFixture + }{ + {"rules read, no capability", mqFixture{ownerType: "Organization", rootFiles: []string{"tide.yaml"}}}, + {"repo unreadable", mqFixture{repoStatus: http.StatusNotFound, rootFiles: []string{"tide.yaml"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := mqServer(t, tc.f, "", nil) + eval := s.evaluateAllRepos() + res := mqFind(t, eval.RepoResults[0].CriteriaResults) + if !res.Passed || !res.FileOnly || res.SatisfiedBy != "" || res.Waived { + t.Fatalf("file-only result = %+v", res) + } + if got := mqLevel(t, eval.RepoResults[0].Levels, 6).Detected; got != 1 { + t.Fatalf("L6 detected = %d, want 1 (no readiness regression)", got) + } + agg := mqFind(t, eval.CriteriaResults) + if !agg.Passed || !agg.FileOnly { + t.Fatalf("aggregate row = %+v", agg) + } + raw, _ := json.Marshal(res) + if !strings.Contains(string(raw), `"file_only":true`) { + t.Fatalf("serialized result: %s", raw) + } + }) + } +} + +// A verified capability takes precedence over the marker file, so the pass is +// not labelled file only. +func TestACMMMergeQueue_CapabilityBeatsFileLabel(t *testing.T) { + s := mqServer(t, mqFixture{ownerType: "Organization", rules: mqRulesetMergeQueue, rootFiles: []string{"tide.yaml"}}, "", nil) + res := mqFind(t, s.evaluateAllRepos().RepoResults[0].CriteriaResults) + if !res.Passed || res.FileOnly || res.SatisfiedBy != acmmMergeQueueProviderNative { + t.Fatalf("result = %+v", res) + } +} + +// The lane without required checks earns nothing, and direct repos with no +// queue and no file still fail as before. +func TestACMMMergeQueue_NoCreditWithoutCapability(t *testing.T) { + for _, tc := range []struct { + name string + strategy string + rules string + }{ + {"lane without required checks", config.MergeStrategyHiveSerialized, `[]`}, + {"direct with required checks", "", mqRulesetRequired}, + } { + t.Run(tc.name, func(t *testing.T) { + s := mqServer(t, mqFixture{ownerType: "User", rules: tc.rules}, tc.strategy, nil) + eval := s.evaluateAllRepos() + res := mqFind(t, eval.RepoResults[0].CriteriaResults) + if res.Passed || res.SatisfiedBy != "" || res.FileOnly { + t.Fatalf("result = %+v", res) + } + want := tc.strategy + if want == "" { + want = config.MergeStrategyDirect + } + if eval.RepoResults[0].MergeStrategy != want { + t.Fatalf("MergeStrategy = %q, want %q", eval.RepoResults[0].MergeStrategy, want) + } + }) + } +} + +func TestACMMMergeQueueCredit_NilGuards(t *testing.T) { + s := NewServer(0, acmmEvalTestLogger()) + if _, _, ok := s.acmmMergeQueueCredit(t.Context(), nil, "o", "r"); ok { + t.Fatal("nil client must not credit") + } + if s.acmmRepoOwnedByUser(t.Context(), "o", "r") { + t.Fatal("nil deps must not report a personal account") + } + deps := testDeps(t) + deps.GHClient = nil + s.RegisterAPI(deps) + if s.acmmRepoOwnedByUser(t.Context(), "o", "r") { + t.Fatal("nil GitHub client must not report a personal account") + } +} + +// acmmOrgMergeQueueGolden is the merge-queue gap ticket body as filed before +// #10891. Organization repositories must keep it byte for byte (AC32). +const acmmOrgMergeQueueGolden = "## ACMM Gap: Merge queue\n\n" + + "**Level:** L6 Fully Autonomous\n" + + "**Category:** autonomy\n" + + "**Criterion ID:** `acmm:merge-queue`\n\n" + + "### What's needed\n\n" + + "This repository is missing one of the following files or directories:\n\n" + + "- `.github/workflows/merge-queue.yml`\n" + + "- `.github/merge-queue.yml`\n" + + "- `.prow.yaml`\n" + + "- `tide.yaml`\n\n" + + "Adding any one of these will satisfy this criterion and help the repository advance to ACMM Level 6 (Fully Autonomous).\n\n" + + "### Why it matters\n\n" + + "Autonomy criteria enable agents to operate independently — generating issues, orchestrating multi-agent workflows, and managing merge queues.\n\n" + + "### How to fix\n\n" + + "Create one of the files listed above. The ACMM evaluation checks for file existence — the content can follow your project's conventions.\n\n" + + "---\n" + + "*Opened by Hive ACMM Evaluation*" + +func mqIssueLabels(t *testing.T, captured map[string]interface{}) []string { + t.Helper() + raw, ok := captured["labels"].([]interface{}) + if !ok { + t.Fatalf("labels missing: %v", captured["labels"]) + } + out := make([]string, 0, len(raw)) + for _, l := range raw { + out = append(out, l.(string)) + } + return out +} + +// AC32: organization-owned repos (and repos whose owner type cannot be read) +// keep today's ticket: same title, body and labels. +func TestACMMIssue_MergeQueueOrgTicketUnchanged(t *testing.T) { + for _, tc := range []struct { + name string + f mqFixture + }{ + {"organization", mqFixture{ownerType: "Organization"}}, + {"owner type unreadable", mqFixture{repoStatus: http.StatusNotFound}}, + } { + t.Run(tc.name, func(t *testing.T) { + var captured map[string]interface{} + s := mqServer(t, tc.f, "", &captured) + rec := doPost(s, "/api/acmm/issue", map[string]interface{}{"repo": "repo1", "criterion_id": acmmMergeQueueID}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + if got := captured["title"]; got != "[ACMM L6] Add Merge queue" { + t.Fatalf("title = %q", got) + } + if got := captured["body"]; got != acmmOrgMergeQueueGolden { + t.Fatalf("body changed:\n%s", got) + } + if got := mqIssueLabels(t, captured); !reflect.DeepEqual(got, []string{"acmm", "ai-fix-requested"}) { + t.Fatalf("labels = %v", got) + } + }) + } + var c ACMMCriterion + for _, uc := range universalCriteria { + if uc.ID == acmmMergeQueueID { + c = uc + } + } + if title, body := acmmIssueContent(&c); title != "[ACMM L6] Add Merge queue" || body != acmmOrgMergeQueueGolden { + t.Fatalf("acmmIssueContent drifted: %q\n%s", title, body) + } +} + +func mqAssertPersonalTicket(t *testing.T, captured map[string]interface{}) { + t.Helper() + title, _ := captured["title"].(string) + body, _ := captured["body"].(string) + for _, forbidden := range []string{"missing one of the following files", "tide.yaml", ".prow.yaml", "merge-queue.yml"} { + if strings.Contains(body, forbidden) { + t.Fatalf("personal ticket must not contain %q:\n%s", forbidden, body) + } + } + for _, want := range []string{ + "personal account", + "GitHub's merge queue is only available to repositories owned by an organization", + "serialized merge lane", + "required status check", + "`hive-serialized`", + "**Criterion ID:** `acmm:merge-queue`", + } { + if !strings.Contains(body, want) { + t.Fatalf("personal ticket missing %q:\n%s", want, body) + } + } + if strings.Contains(title, "Add Merge queue") || !strings.Contains(title, "serialized merging") { + t.Fatalf("title = %q", title) + } + if got := mqIssueLabels(t, captured); !reflect.DeepEqual(got, []string{"acmm"}) { + t.Fatalf("labels = %v, want [acmm] (no ai-fix-requested)", got) + } +} + +// AC31: the single "Open Issue" on a personal-account repo. +func TestACMMIssue_MergeQueuePersonalTicket(t *testing.T) { + var captured map[string]interface{} + s := mqServer(t, mqFixture{ownerType: "User"}, "", &captured) + rec := doPost(s, "/api/acmm/issue", map[string]interface{}{"repo": "repo1", "criterion_id": acmmMergeQueueID, "criterion_level": 6}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + mqAssertPersonalTicket(t, captured) + var resp struct { + Labels []string `json:"labels"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(resp.Labels, []string{"acmm"}) { + t.Fatalf("response labels = %v", resp.Labels) + } +} + +// AC31: "Open Issues for L6" posts one request per failing L6 criterion. Only +// the merge-queue ticket changes on a personal-account repo; the rest keep +// their labels. +func TestACMMIssue_MergeQueuePersonalTicketInBatch(t *testing.T) { + var captured map[string]interface{} + s := mqServer(t, mqFixture{ownerType: "User"}, "", &captured) + sawMergeQueue := false + for _, c := range universalCriteria { + if c.Level != 6 { + continue + } + captured = nil + rec := doPost(s, "/api/acmm/issue", map[string]interface{}{"repo": "repo1", "criterion_id": c.ID, "criterion_level": 6}) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status = %d; body=%s", c.ID, rec.Code, rec.Body.String()) + } + if c.ID == acmmMergeQueueID { + sawMergeQueue = true + mqAssertPersonalTicket(t, captured) + continue + } + if got := mqIssueLabels(t, captured); !reflect.DeepEqual(got, []string{"acmm", "ai-fix-requested"}) { + t.Fatalf("%s labels = %v", c.ID, got) + } + } + if !sawMergeQueue { + t.Fatal("batch never reached acmm:merge-queue") + } +} + +func TestACMMMergeQueuePersonalIssueContent_NeverCallsLaneAMergeQueue(t *testing.T) { + c := &ACMMCriterion{ID: acmmMergeQueueID, Level: 6, Category: "autonomy", Name: "Merge queue"} + _, body := acmmMergeQueuePersonalIssueContent(c, "repo1") + for _, bad := range []string{"merge queue lane", "serialized merge queue", "Hive merge queue", "hive merge queue"} { + if strings.Contains(body, bad) { + t.Fatalf("body calls the lane a merge queue (%q):\n%s", bad, body) + } + } + if !strings.Contains(body, `{"repo": "repo1", "merge_strategy": "hive-serialized"}`) { + t.Fatalf("body should show the owner API call:\n%s", body) + } + unknown := &ACMMCriterion{ID: acmmMergeQueueID, Level: 99, Category: "autonomy", Name: "Merge queue"} + if _, b := acmmMergeQueuePersonalIssueContent(unknown, "r"); !strings.Contains(b, "L99 Unknown") { + t.Fatalf("unknown level fallback missing:\n%s", b) + } +} + +// AC33: the tooltip and the Level 6 description describe the capability and +// name no file. +func TestACMMMergeQueueDescriptions(t *testing.T) { + html := indexHTML(t) + tipLine := mqLineContaining(t, html, "'acmm:merge-queue':") + descLine := mqLineContaining(t, html, " 6: 'Auto issue generation") + for name, line := range map[string]string{"tip": tipLine, "level 6": descLine} { + if !strings.Contains(strings.ToLower(line), "a merge queue or equivalent serialized merging") { + t.Fatalf("%s does not read 'a merge queue or equivalent serialized merging': %s", name, line) + } + for _, bad := range []string{"file", ".yml", ".yaml", "tide", "prow"} { + if strings.Contains(strings.ToLower(line), bad) { + t.Fatalf("%s mentions %q: %s", name, bad, line) + } + } + } +} + +func mqLineContaining(t *testing.T, html, needle string) string { + t.Helper() + for _, line := range strings.Split(html, "\n") { + if strings.Contains(line, needle) { + return line + } + } + t.Fatalf("index.html has no line containing %q", needle) + return "" +} + +// AC27–AC30 on the panel: the satisfied-by and file-only chips, and the +// owner's lane action on a failing row. +func TestACMMMergeQueueRowRendering(t *testing.T) { + html := indexHTML(t) + for _, want := range []string{ + `acmmCreditChip(c) +`, + `acmmMergeLaneAction(c, `, + `fetch('/api/repos/merge-strategy'`, + `merge_strategy: 'hive-serialized'`, + } { + if !strings.Contains(html, want) { + t.Fatalf("index.html missing %q", want) + } + } + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not on PATH — the merge-queue row rendering was NOT executed by this run") + } + script := "const window = {_hiveRole: 'owner'};\nlet _acmmEvalData = null;\n" + + jsFunc(t, html, "escapeHtml") + "\n" + + jsFunc(t, html, "acmmCreditChip") + "\n" + + jsFunc(t, html, "acmmMergeLaneAction") + "\n" + + jsFunc(t, html, "acmmLabelNote") + "\n" + mqRowAssertions + path := filepath.Join(t.TempDir(), "mq.js") + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(node, path).CombinedOutput(); err != nil { + t.Fatalf("merge-queue row rendering check failed:\n%s", strings.TrimSpace(string(out))) + } +} + +const mqRowAssertions = ` +let fails = 0; +function check(name, cond) { if (!cond) { fails++; console.log('FAIL ' + name); } } + +check('lane credit chip', acmmCreditChip({passed: true, satisfied_by: 'Hive serialized merge lane'}).includes('satisfied by Hive serialized merge lane')); +check('native credit chip', acmmCreditChip({passed: true, satisfied_by: 'GitHub merge queue'}).includes('satisfied by GitHub merge queue')); +check('file-only chip', acmmCreditChip({passed: true, file_only: true}).includes('file only, not verified')); +check('plain detection has no chip', acmmCreditChip({passed: true}) === ''); +check('failing row has no chip', acmmCreditChip({passed: false, file_only: true}) === ''); + +const failing = {id: 'acmm:merge-queue', passed: false}; +_acmmEvalData = {repo_results: [{repo: 'repo1', merge_strategy: 'direct'}]}; +const action = acmmMergeLaneAction(failing, 'repo1'); +check('owner is offered the lane', action.includes('acmmUseSerializedLane') && action.includes('Use serialized merge lane')); +check('lane is never called a merge queue', !/merge queue/i.test(action.replace('acmm:merge-queue', ''))); +check('passing row offers nothing', acmmMergeLaneAction({id: 'acmm:merge-queue', passed: true}, 'repo1') === ''); +check('other criteria offer nothing', acmmMergeLaneAction({id: 'acmm:claude-md', passed: false}, 'repo1') === ''); +check('no repo offers nothing', acmmMergeLaneAction(failing, '') === ''); +_acmmEvalData = {repo_results: [{repo: 'repo1', merge_strategy: 'hive-serialized'}]}; +check('lane already on explains the gap', acmmMergeLaneAction(failing, 'repo1').includes('needs required checks')); +window._hiveRole = 'read'; +check('non-owner is not offered the lane', acmmMergeLaneAction(failing, 'repo1') === ''); + +check('label note from server', acmmLabelNote(['acmm'], 'X') === ' with acmm label.'); +check('label note two labels', acmmLabelNote(['acmm', 'ai-fix-requested'], 'X').includes('ai-fix-requested labels.')); +check('label note fallback', acmmLabelNote(null, 'X') === 'X'); +check('label note none', acmmLabelNote([], 'X') === '.'); + +if (fails) process.exit(1); +` diff --git a/src/pkg/dashboard/api_acmm_eval.go b/src/pkg/dashboard/api_acmm_eval.go index e734f506f1..521857d0c0 100644 --- a/src/pkg/dashboard/api_acmm_eval.go +++ b/src/pkg/dashboard/api_acmm_eval.go @@ -76,6 +76,9 @@ type RepoEvaluation struct { CriteriaPassed int `json:"criteria_passed"` Levels []ACMMLevelScore `json:"levels"` CriteriaResults []CriterionResult `json:"criteria_results"` + // MergeStrategy is the repo's effective merge strategy, so the merge-queue + // row can offer an owner the serialized merge lane only when it is off. + MergeStrategy string `json:"merge_strategy,omitempty"` } // ACMMLevelScore summarizes pass/fail for a single ACMM level. @@ -135,6 +138,11 @@ type CriterionResult struct { // Unlike waivers, this is not repository-authored and can advance a level. SatisfiedBy string `json:"satisfied_by,omitempty"` SatisfiedReason string `json:"satisfied_reason,omitempty"` + // FileOnly is true when a criterion that Hive can verify (today only + // acmm:merge-queue) passed solely because a marker file exists. It still + // counts toward the level; the dashboard labels it "file only, not + // verified". + FileOnly bool `json:"file_only,omitempty"` } // ACMMIssueRequest is the payload for creating an ACMM gap issue. @@ -266,7 +274,18 @@ func (s *Server) handleACMMCreateIssue(w http.ResponseWriter, r *http.Request) { return } + ctx, cancel := context.WithTimeout(context.Background(), acmmEvalTimeout) + defer cancel() + title, body := acmmIssueContent(criterion) + // A personal-account repo cannot turn on GitHub's merge queue, so its + // merge-queue ticket explains the serialized merge lane instead. Only this + // criterion pays for the owner lookup; organization repos keep today's + // ticket, as does any repo whose owner type cannot be read. + personalMergeQueue := criterion.ID == acmmMergeQueueID && s.acmmRepoOwnedByUser(ctx, owner, req.Repo) + if personalMergeQueue { + title, body = acmmMergeQueuePersonalIssueContent(criterion, req.Repo) + } // Invocation-attribution trail: this issue is created by the hive on an // operator's dashboard action — stamp the (config-gated) visible trailer @@ -277,9 +296,6 @@ func (s *Server) handleACMMCreateIssue(w http.ResponseWriter, r *http.Request) { body = github.AppendTrailer(body, meta) } - ctx, cancel := context.WithTimeout(context.Background(), acmmEvalTimeout) - defer cancel() - if s.acmmIssueDestination(tracker) == acmmIssueDestinationLinear { s.createACMMLinearIssue(ctx, w, r, req.Repo, title, body, meta) return @@ -306,8 +322,10 @@ func (s *Server) handleACMMCreateIssue(w http.ResponseWriter, r *http.Request) { Title: gh.Ptr(title), Body: gh.Ptr(body), } + var labels []string if labelExists { - issueReq.Labels = &[]string{acmmIssueLabelName, "ai-fix-requested"} + labels = acmmIssueLabels(personalMergeQueue) + issueReq.Labels = &labels } issue, _, err := ghClient.Issues.Create(ctx, owner, req.Repo, issueReq) @@ -328,6 +346,7 @@ func (s *Server) handleACMMCreateIssue(w http.ResponseWriter, r *http.Request) { "tracker": acmmIssueDestinationGitHub, "issue_number": issue.GetNumber(), "issue_url": issue.GetHTMLURL(), + "labels": labels, }) } @@ -533,6 +552,9 @@ func (s *Server) evaluateAllRepos() ACMMEvaluation { aggDetected := make(map[string]bool) aggWaiver := make(map[string]CriterionResult) aggHiveCredit := make(map[string]CriterionResult) + // aggFileOnly: passed somewhere only on a marker file Hive could not + // verify. Weaker than Hive credit, stronger than a waiver (it scores). + aggFileOnly := make(map[string]bool) activitySnap, activityReady := collect.ActivitySnapshot{}, false if s.deps != nil && s.deps.Activity != nil { activitySnap, activityReady = s.deps.Activity.Snapshot() @@ -559,7 +581,10 @@ func (s *Server) evaluateAllRepos() ACMMEvaluation { // A waiver is only consulted when detection failed, so a repo // that later adds the real file stops depending on it silently // and the declaration becomes a no-op rather than a lie. - if !passed { + if c.ID == acmmMergeQueueID { + s.applyMergeQueueCredit(ctx, ghClient, owner, repo, &res) + } + if !res.Passed { if w, ok := waivers[c.ID]; ok { res.Passed = true res.Waived = true @@ -586,6 +611,8 @@ func (s *Server) evaluateAllRepos() ACMMEvaluation { if _, seen := aggHiveCredit[c.ID]; !seen { aggHiveCredit[c.ID] = res } + } else if res.FileOnly { + aggFileOnly[c.ID] = true } else { aggDetected[c.ID] = true } @@ -603,6 +630,7 @@ func (s *Server) evaluateAllRepos() ACMMEvaluation { CriteriaPassed: scored.CriteriaPassed, Levels: scored.Levels, CriteriaResults: scored.CriteriaResults, + MergeStrategy: s.deps.Config.RepoMergeStrategy(repo), }) } @@ -625,6 +653,8 @@ func (s *Server) evaluateAllRepos() ACMMEvaluation { row.SatisfiedBy = h.SatisfiedBy row.SatisfiedReason = h.SatisfiedReason row.Repo = h.Repo + } else if aggFileOnly[c.ID] { + row.FileOnly = true } else if w, ok := aggWaiver[c.ID]; ok { row.Waived = true row.WaiverSatisfiedBy = w.WaiverSatisfiedBy diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index 2cf9650adb..c7898c9f32 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -26456,7 +26456,7 @@

Configure LiteLLM

3: 'PR acceptance metrics, review rubrics, quality dashboards, and safety model enforcement are in place.', 4: 'Self-tuning QA, nightly compliance, automated review application, AI-fix workflows, and session continuity.', 5: 'GitHub Actions AI integration, auto-QA self-tuning, public metrics, policy-as-code, and reflection logs.', - 6: 'Auto issue generation, multi-agent orchestration, strategic dashboards, merge queues, and full governance.' + 6: 'Auto issue generation, multi-agent orchestration, strategic dashboards, a merge queue or equivalent serialized merging, and full governance.' }; const ACMM_CRITERION_TIPS = { 'acmm:prereq-test-suite': 'A test framework (Jest, Vitest, pytest, Go test) so agents can validate their changes before submitting.', @@ -26500,7 +26500,7 @@

Configure LiteLLM

'acmm:auto-issue-gen': 'Automated issue generation — agents create issues from detected problems or improvements.', 'acmm:multi-agent-orchestration': 'Multi-agent orchestration — a dispatcher coordinates multiple specialized agents.', 'acmm:strategic-dashboard': 'A strategic dashboard showing ACMM progress, agent health, and maturity trends.', - 'acmm:merge-queue': 'A merge queue that serializes and validates agent PRs before merging to main.', + 'acmm:merge-queue': 'A merge queue or equivalent serialized merging that validates agent PRs against the current base before they land on the default branch.', 'acmm:risk-assessment-config': 'Risk assessment config that gates high-risk changes for human review.', 'acmm:observability-runbook': 'An observability runbook documenting how to monitor and debug agent operations.', 'aef:task-traceability': 'Task traceability — every agent action is linked back to a tracked issue or task.', @@ -26615,6 +26615,71 @@

Configure LiteLLM

''; } + // acmmCreditChip names what satisfied a criterion when it was not a plain + // file detection: Hive-verified evidence (satisfied_by, which counts toward + // the level) or a marker file Hive could not verify (file_only). + function acmmCreditChip(c) { + if (!c || !c.passed) return ''; + if (c.satisfied_by) { + return '✓ satisfied by ' + escapeHtml(c.satisfied_by) + ''; + } + if (c.file_only) { + return 'file only, not verified'; + } + return ''; + } + + // acmmMergeLaneAction offers an owner the serialized merge lane on a + // failing acmm:merge-queue row (POST /api/repos/merge-strategy). When the + // lane is already on, the row says what is still missing instead. + function acmmMergeLaneAction(c, repo) { + if (!c || c.passed || c.id !== 'acmm:merge-queue' || !repo) return ''; + if (window._hiveRole !== 'owner') return ''; + const rr = ((_acmmEvalData && _acmmEvalData.repo_results) || []).find(r => r.repo === repo); + if (rr && rr.merge_strategy === 'hive-serialized') { + return 'Lane on — needs required checks'; + } + return ''; + } + + // acmmLabelNote describes the labels a created GitHub issue carries, from + // the server's response when it reports them. + function acmmLabelNote(labels, fallback) { + if (!Array.isArray(labels)) return fallback; + if (!labels.length) return '.'; + return ' with ' + labels.map(l => '' + escapeHtml(l) + '').join(' + ') + (labels.length === 1 ? ' label.' : ' labels.'); + } + + async function acmmUseSerializedLane(repo, btn) { + const origText = btn.textContent; + btn.textContent = '⏳ Saving...'; + btn.disabled = true; + try { + const r = await fetch('/api/repos/merge-strategy', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({repo: repo, merge_strategy: 'hive-serialized'}) + }); + if (!r.ok) { + btn.textContent = '⚠ Failed'; + btn.title = (await r.text()).trim(); + setTimeout(() => { btn.textContent = origText; btn.disabled = false; }, 3000); + return; + } + const rr = ((_acmmEvalData && _acmmEvalData.repo_results) || []).find(x => x.repo === repo); + if (rr) rr.merge_strategy = 'hive-serialized'; + btn.textContent = '✅ Serialized merge lane on'; + btn.title = 'Re-evaluate once the default branch has required checks'; + } catch (e) { + btn.textContent = '⚠ Error'; + setTimeout(() => { btn.textContent = origText; btn.disabled = false; }, 3000); + } + } + function acmmCriterionRow(c, repoName) { const icon = c.passed ? '✅' : '❌'; const tip = ACMM_CRITERION_TIPS[c.id] || ''; @@ -26649,11 +26714,14 @@

Configure LiteLLM

'' + icon + '' + '' + escapeHtml(c.name) + '' + waivedChip + + acmmCreditChip(c) + + acmmMergeLaneAction(c, repoName || _acmmSelectedRepo || ((_acmmEvalData && (_acmmEvalData.repo_results || []).length === 1) ? _acmmEvalData.repo_results[0].repo : '')) + issueBtn + '' + ''; @@ -26735,7 +26803,7 @@

Configure LiteLLM

const opened = acmmRememberIssue(repo + ':' + criterionId, data); const where = opened.tracker === 'linear' ? ' in Linear (team ' + escapeHtml(data.team || '') + ') for ' + escapeHtml(repo) + '.' - : ' created on ' + escapeHtml(repo) + ' with acmm and ai-fix-requested labels.'; + : ' created on ' + escapeHtml(repo) + '' + acmmLabelNote(data.labels, ' with acmm and ai-fix-requested labels.'); acmmShowDialog('Issue Created', '

Issue ' + escapeHtml(acmmIssueRef(opened)) + '' + where + '

'); } catch (e) { acmmShowDialog('Issue Creation Failed', '

Network error: ' + escapeHtml(e.message) + '

'); @@ -26796,6 +26864,10 @@

Configure LiteLLM

// per failing repo:criterion instead, deduped, exactly as the // single-issue path already shows it. const failures = new Map(); // reason -> [repo:criterion, ...] + // Labels the server actually applied: a personal-account merge-queue + // ticket carries acmm without ai-fix-requested. + const batchLabels = new Set(); + const noteLabels = (data) => { (data && data.labels || []).forEach(l => batchLabels.add(l)); return data; }; const recordFailure = async (where, r) => { failed++; let reason; @@ -26820,7 +26892,7 @@

Configure LiteLLM

body: JSON.stringify(acmmIssuePayload(targetRepo, c.id, level)) }); if (r.ok) { - acmmRememberIssue(issueKey, await r.json()); + acmmRememberIssue(issueKey, noteLabels(await r.json())); created++; } else { await recordFailure(issueKey, r); } } catch (e) { await recordFailure(targetRepo + ':' + c.id, e); } @@ -26836,7 +26908,7 @@

Configure LiteLLM

body: JSON.stringify(acmmIssuePayload(rr.repo, c.id, level)) }); if (r.ok) { - acmmRememberIssue(issueKey, await r.json()); + acmmRememberIssue(issueKey, noteLabels(await r.json())); created++; } else { await recordFailure(issueKey, r); } } catch (e) { await recordFailure(issueKey, e); } @@ -26848,7 +26920,7 @@

Configure LiteLLM

} // The label note is a description of what a SUCCESSFUL create did, not a // diagnosis — show it only when something was actually created (#5873). - const labelNote = acmmEffectiveTracker() === 'linear' ? ' in Linear.' : ' with acmm + ai-fix-requested labels.'; + const labelNote = acmmEffectiveTracker() === 'linear' ? ' in Linear.' : acmmLabelNote(batchLabels.size ? Array.from(batchLabels) : null, ' with acmm + ai-fix-requested labels.'); let html = '

' + created + ' issue(s) created' + (skipped > 0 ? ', ' + skipped + ' already existed' : '') + (created > 0 ? labelNote : '.') + '

'; if (failed > 0) { html += '

' + failed + ' failed:

';