From d62e94df13e7f781736e6474d49cdb31b1d90112 Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Sat, 17 Oct 2026 21:38:37 -0400 Subject: [PATCH 01/59] =?UTF-8?q?=F0=9F=90=9B=20Cover=20self-authored=20de?= =?UTF-8?q?cision=20stage=20resolution=20(#12848)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Andy Anderson --- ...self-authored-decision-stage-regression.md | 1 + .../automerge/automerge_stage_labels_test.go | 23 +++++++++++++++++++ src/pkg/review/review_test.go | 2 ++ 3 files changed, 26 insertions(+) create mode 100644 changelog.d/fixed-self-authored-decision-stage-regression.md diff --git a/changelog.d/fixed-self-authored-decision-stage-regression.md b/changelog.d/fixed-self-authored-decision-stage-regression.md new file mode 100644 index 0000000000..d7d8d5d3c1 --- /dev/null +++ b/changelog.d/fixed-self-authored-decision-stage-regression.md @@ -0,0 +1 @@ +- Add regression coverage that resolved Hive-authored design questions leave the needs-decision stage label. diff --git a/src/pkg/github/automerge/automerge_stage_labels_test.go b/src/pkg/github/automerge/automerge_stage_labels_test.go index 5ffc4deb52..ba47c540e2 100644 --- a/src/pkg/github/automerge/automerge_stage_labels_test.go +++ b/src/pkg/github/automerge/automerge_stage_labels_test.go @@ -1648,6 +1648,29 @@ func TestApplyStageLabelsDCOFromCommits(t *testing.T) { } } +func TestApplyStageLabelsResolvedSelfAuthoredDecisionDropsNeedsDecision(t *testing.T) { + api := &stageAPI{} + srv := newStageAPI(t, api) + defer srv.Close() + clock := &contributorLaneClock{at: time.Date(2026, 10, 17, 21, 30, 0, 0, time.UTC)} + e, _, _ := newStageEngine(t, srv.URL, clock) + ctx := context.Background() + + const number = 12808 + pr := stagePR(number, "author/hive", "role/trusted", "stage/085-needs-decision") + pr.User = &gh.User{Login: gh.Ptr(testHiveAppBotLogin)} + e.recordStage(stageLaneSelf, "acme/widget", "acme", "widget", pr, "required-check-pending:build", false, nil) + e.noteStageRole("acme", "widget", number, stagelabels.RoleTrusted) + e.noteStageVerdict("acme", "widget", number, ContributorLaneVerdict{Configured: true, Approved: true, Verdict: "approve"}) + + if _, err := e.ApplyStageLabels(ctx); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(api.added, [][]string{{"stage/060-reviewed"}}) || !reflect.DeepEqual(api.removed, []string{"stage/085-needs-decision"}) || len(api.comments) != 0 { + t.Fatalf("resolved self-authored decision should leave 085: added=%v removed=%v comments=%v", api.added, api.removed, api.comments) + } +} + // A repo without a DCO gate (no `dco`/`DCO` context required or reported) // still gets the `no-dco` label but keeps the lane's stage and is not asked // to rebase; `governor.labels.stage.dco.required` overrides detection both diff --git a/src/pkg/review/review_test.go b/src/pkg/review/review_test.go index 1015687771..09be3286bd 100644 --- a/src/pkg/review/review_test.go +++ b/src/pkg/review/review_test.go @@ -99,6 +99,8 @@ func TestAggregateReportsCarriesDecisionQuestion(t *testing.T) { func TestAggregateReportsResolvesNonRiskHiveAuthoredDecisionQuestions(t *testing.T) { cases := []string{ + // Exact live questions from #12808 and #12790: Hive-authored + // sequencing/config-shape decisions resolved by the reviewer. "@hanthor should decide whether the contribution-preset loader should land before any hivectl command consumes it", "accept `self_experiments.scheduler` as a new top-level config namespace vs nesting, and a fixed ledger path?", } From 7f17aafa24b03969bdffef2e0747694f47e923ba Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Sat, 17 Oct 2026 21:38:55 -0400 Subject: [PATCH 02/59] =?UTF-8?q?=E2=9C=A8=20feat:=20auto-promote=20clean?= =?UTF-8?q?=20contributor=20histories=20(#12841)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Andy Anderson --- .../added-contributor-tier-auto-promote.md | 1 + src/cmd/hive/contributor_lane_wiring_test.go | 5 +- src/cmd/hive/merge_eligibility.go | 18 ++-- src/docs/acmm-policy-matrix.md | 2 +- src/docs/labels-and-control-signals.md | 2 +- src/docs/running-at-level-6.md | 2 +- src/pkg/config/automerge_trusted_bot_test.go | 15 +++ src/pkg/config/config.go | 31 ++++++ src/pkg/config/validate.go | 3 + .../dashboard/settings_parity_allowlist.txt | 1 + .../automerge/automerge_contributor_lane.go | 95 +++++++++++++++++++ .../automerge_contributor_lane_test.go | 73 ++++++++++++++ 12 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 changelog.d/added-contributor-tier-auto-promote.md diff --git a/changelog.d/added-contributor-tier-auto-promote.md b/changelog.d/added-contributor-tier-auto-promote.md new file mode 100644 index 0000000000..c7c4a881e3 --- /dev/null +++ b/changelog.d/added-contributor-tier-auto-promote.md @@ -0,0 +1 @@ +- Auto-promote contributor-lane trust after a clean merged-PR history so reviewed green contributor PRs can merge at L6. diff --git a/src/cmd/hive/contributor_lane_wiring_test.go b/src/cmd/hive/contributor_lane_wiring_test.go index 9cd02a87e7..955ad05ce2 100644 --- a/src/cmd/hive/contributor_lane_wiring_test.go +++ b/src/cmd/hive/contributor_lane_wiring_test.go @@ -15,7 +15,7 @@ func TestContributorLanePolicyFuncReadsConfig(t *testing.T) { } cfg := &config.Config{HiveID: "hive-prod"} policy := contributorLanePolicyFunc(cfg)() - if !policy.Enabled || policy.HiveID != "hive-prod" || policy.Soak != time.Duration(config.DefaultContributorLaneSoakMinutes)*time.Minute || !policy.RequireReviewerVerdict { + if !policy.Enabled || policy.HiveID != "hive-prod" || policy.Soak != time.Duration(config.DefaultContributorLaneSoakMinutes)*time.Minute || !policy.RequireReviewerVerdict || policy.AutoPromoteAfterMergedPR != config.DefaultContributorTierAutoPromoteAfterMerged { t.Fatalf("default policy = %+v", policy) } for _, label := range config.DefaultContributorLaneExcludeLabels { @@ -28,8 +28,9 @@ func TestContributorLanePolicyFuncReadsConfig(t *testing.T) { } off := false cfg.AutoMerge.ContributorLane = config.ContributorLaneAutoMergeConfig{Enabled: &off, SoakMinutes: 15, RequireReviewerVerdict: &off, Repos: []string{"Widget"}, RequireTiers: []string{"Advisor"}} + cfg.AutoMerge.ContributorTier = config.ContributorTierAutoMergeConfig{AutoPromoteAfterMerged: 9} policy = contributorLanePolicyFunc(cfg)() - if policy.Enabled || policy.Soak != 15*time.Minute || policy.RequireReviewerVerdict || !policy.Repos["widget"] { + if policy.Enabled || policy.Soak != 15*time.Minute || policy.RequireReviewerVerdict || !policy.Repos["widget"] || policy.AutoPromoteAfterMergedPR != 9 { t.Fatalf("explicit policy = %+v", policy) } if !policy.RequireTiers["advisor"] || len(policy.RequireTiers) != 1 { diff --git a/src/cmd/hive/merge_eligibility.go b/src/cmd/hive/merge_eligibility.go index f4cb429859..1fa9f9d4df 100644 --- a/src/cmd/hive/merge_eligibility.go +++ b/src/cmd/hive/merge_eligibility.go @@ -78,15 +78,17 @@ func contributorLanePolicyFunc(cfg *config.Config) func() automerge.ContributorL return automerge.ContributorLanePolicy{} } p := cfg.AutoMerge.ContributorLane + tier := cfg.AutoMerge.ContributorTier return automerge.ContributorLanePolicy{ - Enabled: p.IsEnabled(), - HiveID: cfg.HiveID, - Soak: time.Duration(p.EffectiveSoakMinutes()) * time.Minute, - RequireReviewerVerdict: p.EffectiveRequireReviewerVerdict(), - Repos: p.RepoSet(), - ExcludeLabels: p.ExcludeLabelSet(), - RequireTiers: p.RequireTierSet(), - RegistryTiersSufficient: p.EffectiveRegistryTiersSufficient(), + Enabled: p.IsEnabled(), + HiveID: cfg.HiveID, + Soak: time.Duration(p.EffectiveSoakMinutes()) * time.Minute, + RequireReviewerVerdict: p.EffectiveRequireReviewerVerdict(), + Repos: p.RepoSet(), + ExcludeLabels: p.ExcludeLabelSet(), + RequireTiers: p.RequireTierSet(), + RegistryTiersSufficient: p.EffectiveRegistryTiersSufficient(), + AutoPromoteAfterMergedPR: tier.EffectiveAutoPromoteAfterMerged(), } } } diff --git a/src/docs/acmm-policy-matrix.md b/src/docs/acmm-policy-matrix.md index 260260e712..5afabb9b93 100644 --- a/src/docs/acmm-policy-matrix.md +++ b/src/docs/acmm-policy-matrix.md @@ -98,7 +98,7 @@ Agents open issues AND pull requests. Agent PRs get literal `hold` from the leve ### L6 — Fully Autonomous (15 agents) -Existing autonomous lanes can open issues, create PRs, and auto-merge on green CI. Auto-merge is off below L6; switching to L6 turns it on for every active repository, after which owners can toggle repositories individually. L6 PRs do not get the level hold, except outreach PRs into a repository the hive does not manage, which are still held for human review; outreach PRs into the hive's own repos auto-merge like any other lane's. Outreach handles community engagement. Reviewer stays advisory unless the self-authored review gate is enforcing: `review.require_approval: true` (or `governor.review_gate.mode: enforce`) makes the App self-authored sweep require same-head aggregate approval, while `requires_human`/incomplete verdicts can be cleared by a trusted human's normal GitHub Approve review on that exact head. For Hive-authored PRs, `review.self_authored_decision_policy: resolve` (the default) lets the reviewer resolve non-risk sequencing, naming, config-shape and scope questions itself; set `escalate` to park every decision question at 085. Swarm `changes_requested`/`reject` still requires a fix or direct human merge. Use actual holds or GitHub branch rules for a universal merge stop. Adjudicator is the reviewer lane: it repairs, de-escalates, or recommends closing (and may close) escalated `needs-human` hive PRs, and never merges. Dedupe posts the same daily consolidation checklist as at L5 and stays recommendation-only. Telemetry and operations remain paused and use `ISSUES_AND_PRS`, so they never merge their own PRs. +Existing autonomous lanes can open issues, create PRs, and auto-merge on green CI. Auto-merge is off below L6; switching to L6 turns it on for every active repository, after which owners can toggle repositories individually. L6 PRs do not get the level hold, except outreach PRs into a repository the hive does not manage, which are still held for human review; outreach PRs into the hive's own repos auto-merge like any other lane's. Contributor PRs can merge through the contributor lane when the author has an operator-trusted tier or the conservative `auto_merge.contributor_tier.auto_promote_after_merged` clean-history ratchet admits them; reviewer verdict, CI, diff fences, soak and branch protection still apply. Outreach handles community engagement. Reviewer stays advisory unless the self-authored review gate is enforcing: `review.require_approval: true` (or `governor.review_gate.mode: enforce`) makes the App self-authored sweep require same-head aggregate approval, while `requires_human`/incomplete verdicts can be cleared by a trusted human's normal GitHub Approve review on that exact head. For Hive-authored PRs, `review.self_authored_decision_policy: resolve` (the default) lets the reviewer resolve non-risk sequencing, naming, config-shape and scope questions itself; set `escalate` to park every decision question at 085. Swarm `changes_requested`/`reject` still requires a fix or direct human merge. Use actual holds or GitHub branch rules for a universal merge stop. Adjudicator is the reviewer lane: it repairs, de-escalates, or recommends closing (and may close) escalated `needs-human` hive PRs, and never merges. Dedupe posts the same daily consolidation checklist as at L5 and stays recommendation-only. Telemetry and operations remain paused and use `ISSUES_AND_PRS`, so they never merge their own PRs. | Agent | Mode | Template | |-------|------|----------| diff --git a/src/docs/labels-and-control-signals.md b/src/docs/labels-and-control-signals.md index 2ea061743a..3b6bcde11a 100644 --- a/src/docs/labels-and-control-signals.md +++ b/src/docs/labels-and-control-signals.md @@ -399,7 +399,7 @@ stage but written only on change: | Label | Source | |---|---| -| `role/trusted` | App-authored or self-authored lane; trusted-author lane allowed the author; registry/claim tier in the contributor lane's `require_tiers` (default `trusted`, `merger`) → auto-merge eligible | +| `role/trusted` | App-authored or self-authored lane; trusted-author lane allowed the author; registry/claim tier in the contributor lane's `require_tiers` (default `trusted`, `merger`); or contributor-lane auto-promotion after `auto_merge.contributor_tier.auto_promote_after_merged` clean merged PRs with no reverts or sentinel alerts → auto-merge eligible | | `role/contributor` | registry/claim tier `contributor` → needs human lgtm/approve | | `role/newcomer` | registry/claim tier `newcomer` | | `role/untrusted` | no claim and no registry profile (external PR), `revoked`, or not a trusted author | diff --git a/src/docs/running-at-level-6.md b/src/docs/running-at-level-6.md index b8841fe1ef..fa6199ee9e 100644 --- a/src/docs/running-at-level-6.md +++ b/src/docs/running-at-level-6.md @@ -22,7 +22,7 @@ All answers assume the repository participates in automatic merging, the PR is r | --- | --- | | Hive's agents | **Yes.** The App self-authored sweep needs no human queue approval. It checks author, labels, GitHub mergeability, CI, intent policy and the head again before merging. See `trySweepSelfAuthoredPR` in [sweep](../pkg/github/automerge/automerge_sweep.go). Outreach is an exception below. | | Dependency bot | **Yes**, for exact logins in `auto_merge.trusted_bot_authors` (unset means `dependabot[bot]`). This is the same sweep with the same gates. Others can enter the scanner/queued paths; removing a bot from this list is not a universal author denylist. See `TrustedBotAuthorSet` in [config](../pkg/config/config.go) and `sweepLaneForAuthor` in [sweep](../pkg/github/automerge/automerge_sweep.go). | -| Contributor relay | **Yes.** ClankeR is the external contributor relay, not a separate merge exception. Its PRs can enter the scanner's current merge-eligible list, without a human `lgtm`. The human queue is another route, not a requirement at L6. See `classifyMergeEligibility`, `bindMergeAuthz` in [eligibility](../cmd/hive/merge_eligibility.go). | +| Contributor relay | **Yes.** ClankeR is the external contributor relay, not a separate merge exception. Its PRs can enter the scanner's current merge-eligible list, without a human `lgtm`. The contributor-lane sweep can also merge green, reviewed PRs for operator-trusted contributors, or contributors auto-promoted by `auto_merge.contributor_tier.auto_promote_after_merged` after a clean merged-PR history. The human queue is another route, not a requirement at L6. See `classifyMergeEligibility`, `bindMergeAuthz` in [eligibility](../cmd/hive/merge_eligibility.go) and `trySweepContributorLanePR` in [contributor lane automerge](../pkg/github/automerge/automerge_contributor_lane.go). | | Person by hand, including an unknown person | **Yes.** The scanner eligibility classifier is not filtered by author. An unknown PR author is not itself a merge hold. Reporter trust concerns the issues motivating work, not a blanket PR-author restriction. See [eligibility](../cmd/hive/merge_eligibility.go) and [reporter-trust hold](../pkg/github/pr_reporter_trust.go). | The third route is the human-approved queue (normally `lgtm`): an authorized merger/owner queues **someone else's** PR; the sweep rechecks that authorization. See `handleQueuePRAutoMerge` in [dashboard API](../pkg/dashboard/api.go) and `trySweepQueuedPR` in [sweep](../pkg/github/automerge/automerge_sweep.go). A bare `lgtm` from an arbitrary account is not authorized queueing. diff --git a/src/pkg/config/automerge_trusted_bot_test.go b/src/pkg/config/automerge_trusted_bot_test.go index 69e85b9e40..bc3cd31825 100644 --- a/src/pkg/config/automerge_trusted_bot_test.go +++ b/src/pkg/config/automerge_trusted_bot_test.go @@ -162,3 +162,18 @@ func TestContributorLaneAutoMergeConfigRegistryTiersSufficient(t *testing.T) { t.Fatal("explicit registry_tiers_sufficient: false must turn the registry path off") } } + +func TestContributorTierAutoMergeConfigAutoPromoteDefault(t *testing.T) { + var cfg ContributorTierAutoMergeConfig + if got := cfg.EffectiveAutoPromoteAfterMerged(); got != DefaultContributorTierAutoPromoteAfterMerged { + t.Fatalf("default auto_promote_after_merged = %d, want %d", got, DefaultContributorTierAutoPromoteAfterMerged) + } + cfg.AutoPromoteAfterMerged = 7 + if got := cfg.EffectiveAutoPromoteAfterMerged(); got != 7 { + t.Fatalf("configured auto_promote_after_merged = %d, want 7", got) + } + cfg.AutoPromoteAfterMerged = -1 + if got := cfg.EffectiveAutoPromoteAfterMerged(); got != 0 { + t.Fatalf("disabled auto_promote_after_merged = %d, want 0", got) + } +} diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go index af897cc83b..e6d3013817 100644 --- a/src/pkg/config/config.go +++ b/src/pkg/config/config.go @@ -9090,6 +9090,9 @@ type AutoMergeConfig struct { // hold an operator-granted tier (require_tiers) and a GitHub login proven // against GitHub (#12041); everyone else's PRs wait for a human. ContributorLane ContributorLaneAutoMergeConfig `yaml:"contributor_lane,omitempty" json:"contributor_lane,omitempty"` + // ContributorTier controls automatic contributor trust ratchets used by + // the contributor lane. + ContributorTier ContributorTierAutoMergeConfig `yaml:"contributor_tier,omitempty" json:"contributor_tier,omitempty"` // HumanMergePaths maps owner/repo to glob patterns (same syntax as // intent.guardrail_path_patterns) for paths a person must merge. Unset // means no such paths. This key only declares the paths; enforcement @@ -9162,6 +9165,30 @@ type ContributorLaneAutoMergeConfig struct { RegistryTiersSufficient *bool `yaml:"registry_tiers_sufficient,omitempty" json:"registry_tiers_sufficient,omitempty"` } +// ContributorTierAutoMergeConfig is auto_merge.contributor_tier.*. +type ContributorTierAutoMergeConfig struct { + // AutoPromoteAfterMerged lets the contributor lane conservatively treat a + // contributor as trusted after this many recently merged PRs by the same + // author in the repository, provided that window has no reverts or sentinel + // alerts. 0/unset means DefaultContributorTierAutoPromoteAfterMerged; + // negative disables the ratchet. + AutoPromoteAfterMerged int `yaml:"auto_promote_after_merged,omitempty" json:"auto_promote_after_merged,omitempty"` +} + +// DefaultContributorTierAutoPromoteAfterMerged is the clean merged-PR count +// needed before the contributor lane trusts a contributor automatically. +const DefaultContributorTierAutoPromoteAfterMerged = 5 + +func (c ContributorTierAutoMergeConfig) EffectiveAutoPromoteAfterMerged() int { + if c.AutoPromoteAfterMerged < 0 { + return 0 + } + if c.AutoPromoteAfterMerged == 0 { + return DefaultContributorTierAutoPromoteAfterMerged + } + return c.AutoPromoteAfterMerged +} + // DefaultContributorLaneRequireTiers is the contributor-lane tier allow-list // when auto_merge.contributor_lane.require_tiers is unset. var DefaultContributorLaneRequireTiers = []string{"trusted", "merger"} @@ -9275,6 +9302,10 @@ func (c ContributorLaneAutoMergeConfig) Validate() error { return nil } +func (c ContributorTierAutoMergeConfig) Validate() error { + return nil +} + // DefaultAutoMergeMinHeadAge is the fail-closed post-push quiet period used // when auto_merge.min_head_age is unset. const DefaultAutoMergeMinHeadAge = 3 * time.Minute diff --git a/src/pkg/config/validate.go b/src/pkg/config/validate.go index c959e6a421..032c069b4d 100644 --- a/src/pkg/config/validate.go +++ b/src/pkg/config/validate.go @@ -161,6 +161,9 @@ func (c *Config) ValidateWithOptions(opts ValidateOptions) error { if err := c.AutoMerge.ContributorLane.Validate(); err != nil { return err } + if err := c.AutoMerge.ContributorTier.Validate(); err != nil { + return err + } if err := c.Compliance.Validate(); err != nil { return err } diff --git a/src/pkg/dashboard/settings_parity_allowlist.txt b/src/pkg/dashboard/settings_parity_allowlist.txt index 18032442ed..9e170b86b6 100644 --- a/src/pkg/dashboard/settings_parity_allowlist.txt +++ b/src/pkg/dashboard/settings_parity_allowlist.txt @@ -135,6 +135,7 @@ agents..variables..url # TODO #11854 agents..variables..value # TODO #11854 agents.github_api_hourly_cap # TODO #11854 auto_merge.allow_unprotected_base[] # TODO #11854 +auto_merge.contributor_tier.auto_promote_after_merged # contributor trust ratchet; yaml-only (#12839) auto_merge.human_merge_paths. # TODO #11854 auto_merge.no_ci_ok[] # TODO #11854 auto_merge.repos..required_checks[] # TODO #11854 diff --git a/src/pkg/github/automerge/automerge_contributor_lane.go b/src/pkg/github/automerge/automerge_contributor_lane.go index 2a236eae59..ac7296dd18 100644 --- a/src/pkg/github/automerge/automerge_contributor_lane.go +++ b/src/pkg/github/automerge/automerge_contributor_lane.go @@ -157,6 +157,8 @@ const ( // being observed and must not pin memory forever. const contributorSoakPruneAfter = 7 * 24 * time.Hour +const contributorAutoPromotionMarkerPrefix = "\n@%s is auto-promoted for contributor-lane merging on this PR: the last %d merged PRs by this author in this repository have no revert or sentinel-alert evidence. CI, reviewer verdict, diff fences, soak, holds and branch protection still apply.", + contributorAutoPromotionMarkerPrefix, strings.TrimPrefix(author, "@"), merged, strings.TrimPrefix(author, "@"), merged) + _, _, err = c.gh.Issues.CreateComment(ctx, owner, repo, number, &gh.IssueComment{Body: gh.Ptr(body)}) + return err +} + // verifyContributorClaim finds the linked issue whose App-authored // contributor claim names the PR author and this hive, with a tier in // policy.RequireTiers and verified=true. Reasons are ranked so the most @@ -730,6 +815,9 @@ func (c *Engine) trySweepContributorLanePR(ctx context.Context, displayRepo, own if claim.admittedByRegistry() { c.info("contributor-lane admitted", "repo", displayRepo, "pr", number, "author", author, "reason", contributorLaneReasonRegistryTrusted, "registry_tier", claim.RegistryTier) } + if claim.admittedByAutoPromotion() { + c.info("contributor-lane admitted", "repo", displayRepo, "pr", number, "author", author, "reason", "contributor-lane:auto-promoted", "merged_prs", claim.AutoPromoted) + } files, err := ListChangedFiles(caller, c.gh, owner, repo, pr) if err != nil { return AutoMergeSweepEvent{}, contributorLaneReasonFilesEvidence, err @@ -870,6 +958,9 @@ func contributorLaneAdmitPath(claim contributorClaimMatch) string { if claim.admittedByRegistry() { return contributorLaneReasonRegistryTrusted } + if claim.admittedByAutoPromotion() { + return "contributor-lane:auto-promoted" + } return "claim" } @@ -889,6 +980,10 @@ func contributorLaneMergeComment(author string, claim contributorClaimMatch, soa return fmt.Sprintf("Merged by hive contributor-lane auto-merge: CI green for ≥%dm, %s, @%s holds the %s tier in this hive's contributor registry.", int(soak.Minutes()), verdictText, login, claim.RegistryTier) } + if claim.admittedByAutoPromotion() { + return fmt.Sprintf("Merged by hive contributor-lane auto-merge: CI green for ≥%dm, %s, @%s was auto-promoted after %d clean merged PRs in this repository.", + int(soak.Minutes()), verdictText, login, claim.AutoPromoted) + } return fmt.Sprintf("Merged by hive contributor-lane auto-merge: CI green for ≥%dm, %s, claim by @%s on #%d verified.", int(soak.Minutes()), verdictText, login, claim.Issue) } diff --git a/src/pkg/github/automerge/automerge_contributor_lane_test.go b/src/pkg/github/automerge/automerge_contributor_lane_test.go index aa40d6393f..a6b1ae8744 100644 --- a/src/pkg/github/automerge/automerge_contributor_lane_test.go +++ b/src/pkg/github/automerge/automerge_contributor_lane_test.go @@ -54,6 +54,7 @@ type contributorLaneFixture struct { recheckHeadSHA string issueComments []map[string]any // comments on the linked issue prComments []map[string]any // comments on the PR + mergedHistory []map[string]any issueCommentsSt int reviews []map[string]any mergeStatus int @@ -116,6 +117,10 @@ func newContributorLaneAPI(t *testing.T, fx *contributorLaneFixture) *httptest.S http.Error(w, "list error", fx.listStatus) return } + if r.URL.Query().Get("state") == "closed" { + json.NewEncoder(w).Encode(fx.mergedHistory) + return + } json.NewEncoder(w).Encode([]map[string]any{pull("sha7")}) case r.Method == http.MethodGet && r.URL.Path == "/repos/acme/widget/pulls/7": fetches++ @@ -515,6 +520,21 @@ func registryTiers(tiers map[string]string) ContributorRegistryTierFunc { } } +func mergedHistoryPR(author, title string, labels ...string) map[string]any { + outLabels := make([]map[string]string, 0, len(labels)) + for _, label := range labels { + outLabels = append(outLabels, map[string]string{"name": label}) + } + return map[string]any{ + "number": 100, + "title": title, + "body": "Done.", + "merged_at": "2026-10-15T12:00:00Z", + "user": map[string]string{"login": author}, + "labels": outLabels, + } +} + // Regression for the contributor-lane registry admission path: an operator // marked mrbobbytables `trusted` in the contributor registry, yet #12045 // (`Part of #12002`, a reference not a closing keyword) skipped with @@ -699,6 +719,59 @@ func TestTrySweepContributorLanePRRegistryAdmission(t *testing.T) { } } +func TestTrySweepContributorLanePRAutoPromotion(t *testing.T) { + cleanHistory := func(n int) []map[string]any { + history := make([]map[string]any, 0, n) + for i := 0; i < n; i++ { + history = append(history, mergedHistoryPR(contributorLaneTestAuthor, "fix: clean change")) + } + return history + } + for _, tc := range []struct { + name string + history []map[string]any + threshold int + wantReason string + wantMerges int + wantComments int + }{ + {name: "five clean merged PRs admit and audit", history: cleanHistory(5), threshold: 5, wantMerges: 1, wantComments: 2}, + {name: "below threshold stays untrusted", history: cleanHistory(4), threshold: 5, wantReason: contributorLaneReasonClaimTier}, + {name: "revert blocks promotion", history: append(cleanHistory(2), mergedHistoryPR(contributorLaneTestAuthor, "Revert \"fix: clean change\"")), threshold: 3, wantReason: contributorLaneReasonClaimTier}, + {name: "sentinel blocks promotion", history: append(cleanHistory(2), mergedHistoryPR(contributorLaneTestAuthor, "fix: risky", defaultSentinelLabel)), threshold: 3, wantReason: contributorLaneReasonClaimTier}, + } { + t.Run(tc.name, func(t *testing.T) { + fx := validContributorFixture() + fx.issueComments[0]["body"] = contributorClaimMarkerTier(contributorLaneTestAuthor, "contributor", contributorLaneTestHive, " tier=contributor verified=true") + fx.mergedHistory = tc.history + api := newContributorLaneAPI(t, &fx) + defer api.Close() + policy := defaultContributorLanePolicy() + policy.AutoPromoteAfterMergedPR = tc.threshold + clock := &contributorLaneClock{at: time.Date(2026, 10, 15, 13, 0, 0, 0, time.UTC)} + engine := newContributorLaneEngine(api.URL, policy, clock, Options{ContributorSoak: soakedTracker(clock)}) + event, reason, err := engine.trySweepContributorLanePR(context.Background(), "widget", "acme", "widget", 7, policy) + if err != nil { + t.Fatalf("err = %v", err) + } + if reason != tc.wantReason { + t.Fatalf("reason = %q, want %q", reason, tc.wantReason) + } + if fx.merges != tc.wantMerges { + t.Fatalf("merges = %d, want %d", fx.merges, tc.wantMerges) + } + if fx.comments != tc.wantComments { + t.Fatalf("comments = %d, want %d last=%q", fx.comments, tc.wantComments, fx.lastComment) + } + if tc.wantMerges > 0 { + if event.Tier != contributorLaneTier || !strings.Contains(fx.lastComment, "auto-promoted after 5 clean merged PRs") { + t.Fatalf("event=%+v comment=%q", event, fx.lastComment) + } + } + }) + } +} + func TestContributorLaneAdmitPathAndRegistryTierHelpers(t *testing.T) { if got := contributorLaneAdmitPath(contributorClaimMatch{RegistryTier: "trusted"}); got != contributorLaneReasonRegistryTrusted { t.Fatalf("admit path = %q, want %q", got, contributorLaneReasonRegistryTrusted) From 2ecee907905d2647970a7d0d0294f88aa343cb72 Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Sat, 17 Oct 2026 21:55:15 -0400 Subject: [PATCH 03/59] =?UTF-8?q?=F0=9F=90=9B=20fix(hivectl):=20skip=20unn?= =?UTF-8?q?ormalizable=20legacy=20hubs=20in=20FindHive=20and=20correct=20H?= =?UTF-8?q?IVE=5FREPO=5FSCOPE=20docs=20(#12854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FindHive returned the first NormalizeHiveID error for any candidate, so a single legacy profile with a compose/k8s-style host (ws://hive_hub:8080) made EnsureHive fail for every unrelated onboarding target. Legacy profiles that cannot normalize can never match a canonical origin; skip them. hivectl.md described HIVE_REPO_SCOPE as ';'-joined IDs, but the projection joins per-hub entries with ',' (aligned with HIVE_HUB) and repositories within one hub with ';'. Document the real on-disk form with a valid four-segment example. Refs #12804 Signed-off-by: Andrew Anderson --- .../fixed-hivectl-findhive-legacy-hosts.md | 1 + src/docs/hivectl.md | 17 ++++++++++--- src/pkg/hivectl/profiles_ops.go | 7 +++++- src/pkg/hivectl/selection_test.go | 25 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixed-hivectl-findhive-legacy-hosts.md diff --git a/changelog.d/fixed-hivectl-findhive-legacy-hosts.md b/changelog.d/fixed-hivectl-findhive-legacy-hosts.md new file mode 100644 index 0000000000..d7ddadf606 --- /dev/null +++ b/changelog.d/fixed-hivectl-findhive-legacy-hosts.md @@ -0,0 +1 @@ +- `hivectl` Hive lookup by origin no longer fails for every onboarding target when a legacy profile uses a hub host the canonical identity form rejects (such as compose or Kubernetes service names with `_`); such profiles are skipped. The `HIVE_REPO_SCOPE` documentation now shows the format `hivectl` actually writes (`,` between hubs, `;` between repositories). (#12804) diff --git a/src/docs/hivectl.md b/src/docs/hivectl.md index 8459b3fe98..0bc6b6a566 100644 --- a/src/docs/hivectl.md +++ b/src/docs/hivectl.md @@ -878,9 +878,20 @@ projection does not own — across rather than dropped, and the previous file is kept at `contributor.env.bak`. -The projection also includes an aligned `HIVE_REPO_SCOPE`: `*` for a legacy -profile, otherwise sorted canonical IDs joined by `;`, with an empty entry for -no repositories. The value is shell-quoted so separators survive sourcing. +The projection also includes an aligned `HIVE_REPO_SCOPE` with one entry per +hub, in `HIVE_HUB` order and separated by `,`: `*` for a legacy profile, +otherwise that hub's sorted canonical repository IDs joined by `;`, with an +empty entry for no repositories. The whole value is wrapped in a literal pair +of single quotes so the separators survive `source`; the relay's env-file +parser strips them. For example, a legacy hub followed by a scoped hub with two +repositories projects as: + +```sh +HIVE_REPO_SCOPE='*,github/github.com/hivecommons/hive;github/github.com/hivecommons/promptargs' +``` + +Readers must split on `,` first (aligned with `HIVE_HUB`) and only then on `;` +within one entry; splitting the whole value on `;` would merge hubs. `HIVE_REPO_SCOPE_VERSION` is persisted as `repo_scope_version` and incremented on every profile-store commit (including retries after projection failure). These fields provide local state for the repository-scope protocol; they do diff --git a/src/pkg/hivectl/profiles_ops.go b/src/pkg/hivectl/profiles_ops.go index 9995579cd9..f352853ba0 100644 --- a/src/pkg/hivectl/profiles_ops.go +++ b/src/pkg/hivectl/profiles_ops.go @@ -205,6 +205,11 @@ func (s *ProfileStore) Commit(set *ProfileSet) error { // FindHive resolves a discovery target by origin, not profile name or path. // Legacy users can deliberately have multiple identities at one origin. Never // guess which credential to adopt in that case; the caller must ask the user. +// +// ValidateHubURL admits hosts the canonical form rejects (compose and k8s +// service names such as ws://hive_hub:8080). Such a legacy profile can never +// match a normalizable target, so it is skipped rather than failing every +// onboarding for the whole set. func (set *ProfileSet) FindHive(hub string) (*Profile, error) { id, err := NormalizeHiveID(hub) if err != nil { @@ -215,7 +220,7 @@ func (set *ProfileSet) FindHive(hub string) (*Profile, error) { candidate := &set.Profiles[i] candidateID, err := NormalizeHiveID(candidate.Hub) if err != nil { - return nil, err + continue } if candidateID != id { continue diff --git a/src/pkg/hivectl/selection_test.go b/src/pkg/hivectl/selection_test.go index a4515fd436..d13adde192 100644 --- a/src/pkg/hivectl/selection_test.go +++ b/src/pkg/hivectl/selection_test.go @@ -281,3 +281,28 @@ func TestSelectionAndProvenanceValidation(t *testing.T) { } } } + +func TestFindHiveSkipsLegacyHostsThatCannotNormalize(t *testing.T) { + legacy := Profile{Name: "compose", Hub: "ws://hive_hub:8080/contribute", RegistrationToken: "legacy-token"} + if err := ValidateHubURL(legacy.Hub); err != nil { + t.Fatalf("fixture must be a hub ValidateHubURL admits: %v", err) + } + if _, err := NormalizeHiveID(legacy.Hub); err == nil { + t.Fatal("fixture must be a hub the canonical form rejects") + } + set := &ProfileSet{Version: ProfilesVersion, Profiles: []Profile{ + legacy, + {Name: "saved", Hub: "wss://hive.example/contribute", RegistrationToken: "secret"}, + }} + found, err := set.FindHive("https://hive.example/other") + if err != nil || found == nil || found.Name != "saved" { + t.Fatalf("legacy host broke lookup for unrelated origin: found=%v err=%v", found, err) + } + target, added, err := set.EnsureHive(Profile{Name: "new", Hub: "wss://other.example/contribute"}, false) + if err != nil || !added || target.RegistrationToken != "" || len(set.Profiles) != 3 { + t.Fatalf("onboarding alongside legacy host: added=%v err=%v", added, err) + } + if set.Profiles[0].Hub != legacy.Hub || set.Profiles[0].RegistrationToken != legacy.RegistrationToken { + t.Fatal("legacy profile was altered") + } +} From 9017e8d37aae8b5676a6dba1cd6f70a9e70028d9 Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Sat, 17 Oct 2026 21:58:00 -0400 Subject: [PATCH 04/59] labels: issues park at stage/045-needs-decision; 085 is PR-only (#12856) Owner decision: issues must not park at stage/085-needs-decision any more. Add the issue-side stage stage/045-needs-decision (between 040 and the PR stages) and make 085 PR-only. - stagelabels: IssueNeedsDecision (045, issue-only); NeedsDecision (085) PR-only; Stage.For clamps 085 -> 045 for issues; leaving 045 is never a regression; StageBySlugFor for the shared slug. - issue derivation: needs-human and every park label derive 045, never 085; a stale 085 is swapped for 045 in the same write (NeedsDecisionRestaged). PR derivation untouched (still 085, never 045). - un-park (/hive approve, /hive decision) clears 045 and a pre-045 085, adds approved-direction, keeps needs-human (#12715). - one-shot startup sweep MigrateIssueNeedsDecisionStage re-stages open issues still wearing 085 (issues only; PRs and the 085 definition are left alone), marker /data/issue-needs-decision-stage-v1.done. - config default NeedsDecisionLabel -> 045, PRNeedsDecisionStageLabel -> 085; hard-suppress bucket, gfi exclusions, label relay (agents may add either park; reconcilers clamp by kind), dashboard bands/presets/ tracker (045 lights Actionable with 'Needs human decision', counts as needs-attention), docs and ADR-0019 amendment. Signed-off-by: Andrew Anderson --- .../changed-issue-stage-045-needs-decision.md | 1 + src/cmd/hive/main.go | 7 + src/docs/adr/0019-escalation-over-stalling.md | 14 ++ src/docs/dashboard-sections.md | 4 +- src/docs/dashboard.md | 10 +- src/docs/github-write-surface.md | 3 +- src/docs/good-first-issue.md | 4 +- src/docs/hive-open-issue.md | 9 +- src/docs/labels-and-control-signals.md | 17 +- src/docs/maintainer-commands.md | 21 ++- src/pkg/config/issue_filter.go | 28 ++- src/pkg/config/issue_filter_test.go | 2 +- src/pkg/dashboard/api_hive_advice.go | 2 +- src/pkg/dashboard/bands.go | 17 +- src/pkg/dashboard/label_presets.go | 3 +- src/pkg/dashboard/label_presets_test.go | 4 +- .../dashboard/overview_export_9102_test.go | 2 +- src/pkg/dashboard/overview_status.go | 2 +- src/pkg/dashboard/stage_tracker.go | 46 +++-- src/pkg/dashboard/stage_tracker_cache_test.go | 10 +- src/pkg/dashboard/stage_tracker_test.go | 25 ++- src/pkg/dashboard/stage_tracker_ui_test.go | 23 ++- src/pkg/dashboard/static/index.html | 45 ++--- src/pkg/gfi/gfi.go | 5 +- src/pkg/gfi/gfi_test.go | 3 +- src/pkg/github/agent_start_hook.go | 3 +- .../automerge/automerge_stage_labels_test.go | 5 + src/pkg/github/client.go | 16 +- src/pkg/github/covered_by_pr_label.go | 11 +- .../github/issue_needs_decision_migration.go | 160 ++++++++++++++++++ .../issue_needs_decision_migration_test.go | 88 ++++++++++ src/pkg/github/issue_request_label.go | 14 +- src/pkg/github/issue_request_label_test.go | 54 +++--- src/pkg/github/issue_stage_labels.go | 35 ++-- src/pkg/github/issue_stage_labels_test.go | 40 +++-- src/pkg/github/issue_unpark_command.go | 16 +- src/pkg/github/issue_unpark_command_test.go | 8 +- src/pkg/github/labels.go | 27 ++- src/pkg/github/legacy_decision_labels.go | 17 +- src/pkg/github/legacy_decision_labels_test.go | 24 +-- src/pkg/stagelabels/enforce_test.go | 32 ++-- src/pkg/stagelabels/stagelabels.go | 44 ++++- src/pkg/stagelabels/stagelabels_test.go | 45 ++++- src/pkg/stagelabels/store.go | 4 +- 44 files changed, 711 insertions(+), 239 deletions(-) create mode 100644 changelog.d/changed-issue-stage-045-needs-decision.md create mode 100644 src/pkg/github/issue_needs_decision_migration.go create mode 100644 src/pkg/github/issue_needs_decision_migration_test.go diff --git a/changelog.d/changed-issue-stage-045-needs-decision.md b/changelog.d/changed-issue-stage-045-needs-decision.md new file mode 100644 index 0000000000..310cd01601 --- /dev/null +++ b/changelog.d/changed-issue-stage-045-needs-decision.md @@ -0,0 +1 @@ +- Issues parked on a person now wear the new issue-side stage `stage/045-needs-decision`; `stage/085-needs-decision` is PR-only. `needs-human` on an issue implies 045, `/hive approve` / `/hive decision` still un-park to `approved-direction` and keep `needs-human`, the good-first-issue scorer and the dashboard "Needs human" bucket recognise 045, and open issues still carrying 085 are re-staged to 045 by the issue stage reconciler and a one-shot startup sweep (`/data/issue-needs-decision-stage-v1.done`). diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index 288afe7157..3d2d25be37 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -5948,6 +5948,13 @@ func (b *boot) runLoopWith(deps runLoopDeps) { if _, err := b.ghClient.MigrateOriginLabels(b.ctx, github.OriginLabelMigrationOptions{Logger: b.logger}); err != nil { b.logger.Warn("origin provenance label migration incomplete; open items may still carry retired names such as "+github.LegacyOriginReviewLabel+" or "+github.LegacyFromReviewLabel, "error", err) } + // Issues park at stage/045-needs-decision; stage/085 is PR-only + // (owner decision). Open issues still wearing 085 are re-staged + // once; the issue reconciler also does it per cycle for the issues + // it enumerates, so a failure here only delays the rest. + if _, err := b.ghClient.MigrateIssueNeedsDecisionStage(b.ctx, github.IssueNeedsDecisionMigrationOptions{Logger: b.logger}); err != nil { + b.logger.Warn("issue needs-decision stage migration incomplete; open issues may still carry "+github.PRNeedsDecisionStageLabel+" instead of "+github.NeedsDecisionStageLabel, "error", err) + } // The hive/ provenance label is retired (#12539): the origin // marker in every agent-written body carries the same fact. Runs // after the hold migration so that migration still sees the label diff --git a/src/docs/adr/0019-escalation-over-stalling.md b/src/docs/adr/0019-escalation-over-stalling.md index 3a618ed131..7e3499a1c6 100644 --- a/src/docs/adr/0019-escalation-over-stalling.md +++ b/src/docs/adr/0019-escalation-over-stalling.md @@ -88,3 +88,17 @@ are migrated by the issue stage reconciler (085 added, then the retired label removed) and the two label definitions are deleted from each repo once nothing open carries them. `needs-human` is unchanged: it means a human-held identity or consent is required, not that a ruling is owed. + +## Amendment (2026-10-17, owner decision: issue park is `stage/045-needs-decision`) + +Issues no longer park at `stage/085-needs-decision`; that stage is PR-only. +The issue-side park is `stage/045-needs-decision`, which sits between +`040-covered-by-pr` and the PR stages so an issue's stage never reads as +"past the merge gate". Everything in the amendment above holds with 045 in +place of 085 for issues: the relay's `--needs-decision` and `label` commands +apply 045, the retired spellings migrate to 045, and `needs-human` on an +issue now implies 045 (on a PR it still implies 085). `/hive approve` / +`/hive decision ` un-park to `approved-direction` as before and keep +`needs-human`. Open issues still wearing 085 are re-staged to 045 by the +issue stage reconciler and by a one-shot startup sweep; the 085 definition +is never deleted, PRs wear it. diff --git a/src/docs/dashboard-sections.md b/src/docs/dashboard-sections.md index 14f1159d2a..12f8ec842c 100644 --- a/src/docs/dashboard-sections.md +++ b/src/docs/dashboard-sections.md @@ -52,7 +52,7 @@ How many issues and pull requests Hive is tracking right now, and how many of th - The Outside tile exists on the v5 line only. On v6 the same items are part of the totals but have no tile of their own. - **Issues by band** groups tracked and held issues. The bands are Unclaimed, Claimed, Needs triage, Needs human and Confirm & close. - Unclaimed means nobody is assigned. Claimed means a person or a Hive worker took it. Needs triage means a Hive worker filed it and no person has approved it yet. -- Needs human means a label such as `blocked` or `needs-decision` asks a person to act. Confirm & close means a worker thinks the issue is already done. +- Needs human means a label such as `blocked`, `needs-human` or `stage/045-needs-decision` asks a person to act. Confirm & close means a worker thinks the issue is already done. - **PRs by band** groups open and held pull requests. The bands are Needs human, Merge-eligible, Blocked, In review, Open and Draft. - Merge-eligible means the pull request passed every check Hive uses before merging. Blocked means a failed check, a merge conflict, or a blocking review verdict. - Hover any band in a chart to see its exact rule. @@ -608,7 +608,7 @@ More about the line: A progress strip per open issue and pull request showing which step of the pipeline it is on and who must act next. -Fed by `GET /api/stage-tracker`. Every issue and pull request follows the same pipeline, named once in the column header above the list: `Open → Actionable (010) → Claimed (020) → PR in progress (030/040) → CI / Review (050/060) → Merge gate (080/100) → ✓ merged`. Each entry is a title line over a thin chevron strip. Only the current step is coloured: it is filled amber, carries the step name in words and pulses (static under `prefers-reduced-motion`); once the PR is merged the final ✓ chevron is the lit one, in green. Every other step is a low-contrast shape with a small grey glyph (circle, bolt, lock, branch, eye, shield, check) — passed steps a subtle neutral fill, steps ahead a faint outline. Side stages are not extra steps: `070 Fixing` (the author's move; the `needs-rebase` chip next to it says what is being fixed — the stage labeller always parks a needs-rebase PR at 070/071/072, never at CI / Review), `071 Needs author` (human-authored), `072 Returned to contributor` (clanker-authored) and `074 Adopted by hive` light *CI / Review* with their own name (an adopted PR stays on 074 through the hive's pushes going pending, green or red again, and moves on only when *Merge gate* takes it); `085 Needs human decision` and `090 Ready for human merge` light *Merge gate* with their own name (the 085 tooltip says what must be decided). The lit chevron ends with an ink-coloured icon of whose move it is — person (human), robot (clanker) or hexagon (hive): the claim holder on Claimed, the PR author on PR in progress and Returned to contributor, hive on CI / Review, Adopted by hive and Ready for auto merge, a human on Needs human decision and Ready for human merge. `040 Covered by PR` shares the *PR in progress* column with 030; `060 Reviewed`, `080 Awaiting merge admission` and `100 Merge queue` all light *Merge gate* as "Ready for auto merge" (the chevron tooltip says "not yet admitted — " for 060/080 and "admitted, merging" for 100); the chevron's own tooltip names the exact stage. Covering pull requests nest under their issue. An issue with no PR yet draws the whole strip like any other row, lit at Open, Actionable or Claimed with the steps ahead faint. Once exactly one PR covers it, issue and PR are a single entry: the issue's `#N title` on the left, `[author icon] #PR title` under the *PR in progress* header on the same title line, and one continuous strip — Open, Actionable and Claimed passed, the PR's stage lighting the chevron from PR in progress through CI / Review, Merge gate and ✓; the chip, tooltip and `since` are the PR's. With two or more covering PRs the issue's own strip stops at Claimed — all passed, nothing on the issue line lit. Directly beneath, tighter than the gap between entries, sits one indented sub-row per open covering PR, furthest along first: `[author icon] #N title` at the *PR in progress* column, then that PR's own strip from there on — `PR in progress → CI / Review → Merge gate → ✓` — with its own lit chevron, chip, tooltip and `since`. So an issue with two PRs, one at 030, shows "PR in progress" lit on that sub-row, not on the issue. A PR linked to an open issue in the list has no top-level row of its own; a PR with no linked issue keeps a full-width row and shows the first steps as passed. The *Pull requests* filter flattens the list — every PR as a top-level row; *Issues* and *All* show the nested view. Each row is named once on the left; a covering PR appears under the *PR in progress* header as `[author icon] #N title`. A pull request with no linked issue is named once: `[PR icon] [author icon] #N title` on the left over one continuous strip (first steps passed, its own step lit) and nothing in the *PR in progress* column, which is reserved for covering PRs — so a title never appears twice in a row. Pull requests are never issues here: when a number arrives through both the issues feed and the pulls feed the PR record wins and the issue copy is dropped on the server (pairing is keyed by repository, kind and number), and the *Issues* filter never lists a PR. The author icon is the one coloured legend: hive hexagon in blue, clanker robot in purple, human silhouette in teal (the `author/*` kind, inferred from the snapshot when the label is absent; hover for the login). The right-hand `since` column, on the title line, is how long the item has been on its step (red when `stalled`). At most one muted chip sits to its left on the title line: the most important health modifier (`stalled`, `regressed`, `hold`, `needs-human`, `needs-decision` — shown as "needs human decision" —, `needs-rebase`, `blocked`, `draft`) with a `+N` for the rest. Everything else — the reason sentence, who must act, nudges sent, the repository, all flags and the last stage hops — is in the row's hover tooltip. Title lines never wrap or push the strip out of alignment: the issue title is truncated with an ellipsis before the *PR in progress* column (a clear gap always separates it from the PR entry, which starts at the column edge) and the PR title before the chips and `since`; hovering a truncated title shows the full text. Rows are sorted furthest-along first by default — a covered issue ranks by its furthest-along PR — then longest on the step first; *Sort* also offers longest waiting, newest and number, and the arrow beside it reverses the order. Every open issue and pull request of the watched repositories is listed — actionable, held, claimed, in progress, covered by a PR, filtered by a `needs-*` label, reporter-triage, draft and exempt-labelled alike; an item without a stage label or journal entry sits at *Open*, and one in a scanner bucket with no label yet is placed from the bucket (claimed → 020, in progress → 030, covered → 040). Filters work on the cached payload in the browser and persist across reloads (the page number does not): kind chips, *Repo*, a search box (`#number` / number / title substring, also matching a covering PR), *Since* (any, over 1 hour / 1 day / 1 week on the current step, or past the stage's configured dwell — stalled items always qualify), *Author* kind, *Needs attention* (anything with a health modifier or on 071 / 072 / 085 / 090), and label chips built from the labels present (`stage/*`, `needs-*`, `author/*`, `role/*` always; other labels when two or more items share them; chips of one family OR, families AND; counts on each chip). *Clear filters* shows while anything is narrowed. The list is paged — 25 / 50 / 100 rows, default 50 — with Prev / Next and "showing X–Y of N (M filtered out)" above and below; the header count "N items · M need attention" is for the filtered set, the sidebar badge for everything. "updated Ns ago · cache" says how old the server-built payload is: the spoke rebuilds it at most every `dashboard.stage_tracker.cache_ttl_s` (default `governor.eval_interval_s`) from its existing snapshot and serves it with an `ETag`, so the browser's 30-second poll is a 304 and no request ever reaches GitHub (`/api/gh-rate-limits` lists the caller `dashboard:stage-tracker` with nothing charged). See [Stage labels](labels-and-control-signals.md#stage-labels-stagennn--the-pipeline-state-machine). +Fed by `GET /api/stage-tracker`. Every issue and pull request follows the same pipeline, named once in the column header above the list: `Open → Actionable (010) → Claimed (020) → PR in progress (030/040) → CI / Review (050/060) → Merge gate (080/100) → ✓ merged`. Each entry is a title line over a thin chevron strip. Only the current step is coloured: it is filled amber, carries the step name in words and pulses (static under `prefers-reduced-motion`); once the PR is merged the final ✓ chevron is the lit one, in green. Every other step is a low-contrast shape with a small grey glyph (circle, bolt, lock, branch, eye, shield, check) — passed steps a subtle neutral fill, steps ahead a faint outline. Side stages are not extra steps: `070 Fixing` (the author's move; the `needs-rebase` chip next to it says what is being fixed — the stage labeller always parks a needs-rebase PR at 070/071/072, never at CI / Review), `071 Needs author` (human-authored), `072 Returned to contributor` (clanker-authored) and `074 Adopted by hive` light *CI / Review* with their own name (an adopted PR stays on 074 through the hive's pushes going pending, green or red again, and moves on only when *Merge gate* takes it); `085 Needs human decision` (PR-only) and `090 Ready for human merge` light *Merge gate* with their own name (the 085 tooltip says what must be decided); an issue parked on a person wears `045 Needs human decision` and lights *Actionable* (the issue side) with that name — a pre-045 issue still wearing 085 is drawn the same way until the reconciler re-stages it. The lit chevron ends with an ink-coloured icon of whose move it is — person (human), robot (clanker) or hexagon (hive): the claim holder on Claimed, the PR author on PR in progress and Returned to contributor, hive on CI / Review, Adopted by hive and Ready for auto merge, a human on Needs human decision and Ready for human merge. `040 Covered by PR` shares the *PR in progress* column with 030; `060 Reviewed`, `080 Awaiting merge admission` and `100 Merge queue` all light *Merge gate* as "Ready for auto merge" (the chevron tooltip says "not yet admitted — " for 060/080 and "admitted, merging" for 100); the chevron's own tooltip names the exact stage. Covering pull requests nest under their issue. An issue with no PR yet draws the whole strip like any other row, lit at Open, Actionable or Claimed with the steps ahead faint. Once exactly one PR covers it, issue and PR are a single entry: the issue's `#N title` on the left, `[author icon] #PR title` under the *PR in progress* header on the same title line, and one continuous strip — Open, Actionable and Claimed passed, the PR's stage lighting the chevron from PR in progress through CI / Review, Merge gate and ✓; the chip, tooltip and `since` are the PR's. With two or more covering PRs the issue's own strip stops at Claimed — all passed, nothing on the issue line lit. Directly beneath, tighter than the gap between entries, sits one indented sub-row per open covering PR, furthest along first: `[author icon] #N title` at the *PR in progress* column, then that PR's own strip from there on — `PR in progress → CI / Review → Merge gate → ✓` — with its own lit chevron, chip, tooltip and `since`. So an issue with two PRs, one at 030, shows "PR in progress" lit on that sub-row, not on the issue. A PR linked to an open issue in the list has no top-level row of its own; a PR with no linked issue keeps a full-width row and shows the first steps as passed. The *Pull requests* filter flattens the list — every PR as a top-level row; *Issues* and *All* show the nested view. Each row is named once on the left; a covering PR appears under the *PR in progress* header as `[author icon] #N title`. A pull request with no linked issue is named once: `[PR icon] [author icon] #N title` on the left over one continuous strip (first steps passed, its own step lit) and nothing in the *PR in progress* column, which is reserved for covering PRs — so a title never appears twice in a row. Pull requests are never issues here: when a number arrives through both the issues feed and the pulls feed the PR record wins and the issue copy is dropped on the server (pairing is keyed by repository, kind and number), and the *Issues* filter never lists a PR. The author icon is the one coloured legend: hive hexagon in blue, clanker robot in purple, human silhouette in teal (the `author/*` kind, inferred from the snapshot when the label is absent; hover for the login). The right-hand `since` column, on the title line, is how long the item has been on its step (red when `stalled`). At most one muted chip sits to its left on the title line: the most important health modifier (`stalled`, `regressed`, `hold`, `needs-human`, `needs-decision` — shown as "needs human decision" —, `needs-rebase`, `blocked`, `draft`) with a `+N` for the rest. Everything else — the reason sentence, who must act, nudges sent, the repository, all flags and the last stage hops — is in the row's hover tooltip. Title lines never wrap or push the strip out of alignment: the issue title is truncated with an ellipsis before the *PR in progress* column (a clear gap always separates it from the PR entry, which starts at the column edge) and the PR title before the chips and `since`; hovering a truncated title shows the full text. Rows are sorted furthest-along first by default — a covered issue ranks by its furthest-along PR — then longest on the step first; *Sort* also offers longest waiting, newest and number, and the arrow beside it reverses the order. Every open issue and pull request of the watched repositories is listed — actionable, held, claimed, in progress, covered by a PR, filtered by a `needs-*` label, reporter-triage, draft and exempt-labelled alike; an item without a stage label or journal entry sits at *Open*, and one in a scanner bucket with no label yet is placed from the bucket (claimed → 020, in progress → 030, covered → 040). Filters work on the cached payload in the browser and persist across reloads (the page number does not): kind chips, *Repo*, a search box (`#number` / number / title substring, also matching a covering PR), *Since* (any, over 1 hour / 1 day / 1 week on the current step, or past the stage's configured dwell — stalled items always qualify), *Author* kind, *Needs attention* (anything with a health modifier or on 071 / 072 / 085 / 090), and label chips built from the labels present (`stage/*`, `needs-*`, `author/*`, `role/*` always; other labels when two or more items share them; chips of one family OR, families AND; counts on each chip). *Clear filters* shows while anything is narrowed. The list is paged — 25 / 50 / 100 rows, default 50 — with Prev / Next and "showing X–Y of N (M filtered out)" above and below; the header count "N items · M need attention" is for the filtered set, the sidebar badge for everything. "updated Ns ago · cache" says how old the server-built payload is: the spoke rebuilds it at most every `dashboard.stage_tracker.cache_ttl_s` (default `governor.eval_interval_s`) from its existing snapshot and serves it with an `ETag`, so the browser's 30-second poll is a 304 and no request ever reaches GitHub (`/api/gh-rate-limits` lists the caller `dashboard:stage-tracker` with nothing charged). See [Stage labels](labels-and-control-signals.md#stage-labels-stagennn--the-pipeline-state-machine). ## Agent activity diff --git a/src/docs/dashboard.md b/src/docs/dashboard.md index 84c37c2c93..e56edbe719 100644 --- a/src/docs/dashboard.md +++ b/src/docs/dashboard.md @@ -254,7 +254,7 @@ PRs`, and the operator glyphs between cards make the full strip read like BLOCKED / NEEDS-HUMAN + 22 OUTSIDE`. The `outside` tile is server-side and is broken down in `/api/status` as `actionableNow.outside.breakdown[]`, with each row naming the count, rule, current setting, and how to change it for this -hive. The rows cover hard-suppress labels (`stage/085-needs-decision`, +hive. The rows cover hard-suppress labels (`stage/045-needs-decision`, `needs-spec`; `needs-human` is counted under blocked/needs-human), exempt labels from `governor.labels.exempt` plus permanent exempt labels, reporter-trust triage from @@ -284,7 +284,7 @@ and exposes the editable rows. | Bucket | Definition | Rule/config source | How to change | | --- | --- | --- | --- | -| `needs-decision` (shown as "needs human decision") | Issue is parked for a maintainer ruling. | `stage/085-needs-decision` always, plus labels in `project.issue_filter.hard_suppress_labels.needs_decision` / `.needs_direction` and the retired `needs-decision` / `needs-direction` spellings until the reconciler migrates them ([#12683](https://github.com/hivecommons/hive/issues/12683)). | Settings → Labels → Triage buckets, or edit `hive.yaml`. | +| `needs-decision` (shown as "needs human decision") | Issue is parked for a maintainer ruling. | `stage/045-needs-decision` always (and the PR-only `stage/085-needs-decision`, which a pre-045 issue may still carry), plus labels in `project.issue_filter.hard_suppress_labels.needs_decision` / `.needs_direction` and the retired `needs-decision` / `needs-direction` spellings until the reconciler migrates them ([#12683](https://github.com/hivecommons/hive/issues/12683)). | Settings → Labels → Triage buckets, or edit `hive.yaml`. | | `needs-spec` | Issue needs specification or acceptance criteria. | Labels in `project.issue_filter.hard_suppress_labels.needs_spec`; default `needs-spec`. | Settings → Labels → Triage buckets, or edit `hive.yaml`. | | Exempt labels | Issue or PR carries a deny-list label and is never agent work. | `governor.labels.exempt` plus permanent labels such as `do-not-merge`. | Settings → Labels → Exempt labels, or edit `hive.yaml`. | | Reporter triage | Reporter trust is enabled and the issue author is not trusted yet. | `project.issue_filter.reporter_trust` trusted associations/logins and untrusted required labels. | Settings → Labels → Reporter trust. | @@ -356,8 +356,8 @@ in exactly one band, while non-winning states remain as badges on the pill: satisfies the PR-time gate and is not in the payload). Acknowledge it, or close it. Once acknowledged the issue falls through to Unclaimed/Claimed with the role badge still on the pill. -4. **Needs human** (`needs human`) — labels such as `blocked`, `stage/085-needs-decision`, - `2-discussing`, `Epic`, `needs-human`, or `needs-triage`. Note that the +4. **Needs human** (`needs human`) — labels such as `blocked`, `stage/045-needs-decision`, + `stage/085-needs-decision`, `2-discussing`, `Epic`, `needs-human`, or `needs-triage`. Note that the `needs-triage` *label* lands here, not in the Needs triage band. 5. **Confirm & close** (`close?`) — labels such as `hive/already-done`, `stage/040-covered-by-pr` (and any later mirrored PR stage; formerly @@ -375,7 +375,7 @@ Operators can tune only the display taxonomy under `dashboard.issue_bands`: ```yaml dashboard: issue_bands: - waiting_labels: [blocked, stage/085-needs-decision, needs-decision, 2-discussing, Epic, needs-human, needs-triage] + waiting_labels: [blocked, stage/045-needs-decision, stage/085-needs-decision, needs-decision, 2-discussing, Epic, needs-human, needs-triage] done_labels: [hive/already-done, stage/040-covered-by-pr, "🐝 covered by PR", hive/covered-by-pr, hive/likely-done] stale_days: 14 ``` diff --git a/src/docs/github-write-surface.md b/src/docs/github-write-surface.md index 1590d7eb46..93439e4889 100644 --- a/src/docs/github-write-surface.md +++ b/src/docs/github-write-surface.md @@ -84,7 +84,8 @@ as fact (`issue_request_label.go`): - the labels that record a person's decision or an escalation: `approved-direction`, `design-approved`, `needs-human`, `blocked`, the retired `needs-decision` / `needs-direction`, and `stage/*` (an agent may - only *add* `stage/085-needs-decision`, the park-for-a-ruling signal). + only *add* the park-for-a-ruling signals: `stage/045-needs-decision` on an + issue, `stage/085-needs-decision` on a PR). One reserved label refuses the whole request — nothing in it is applied — so a batch can never partially land. The refusal is a policy denial like any other: diff --git a/src/docs/good-first-issue.md b/src/docs/good-first-issue.md index eff15e41b2..d7f1b356ac 100644 --- a/src/docs/good-first-issue.md +++ b/src/docs/good-first-issue.md @@ -77,7 +77,7 @@ first failure is the `gfi skip reason=…` logged: | Pull request wearing the issue shape | `pull-request` | | Live claimant (`hive:claim`, assignee) | `claimed`, `assigned` | | An open PR already references the issue | `linked-pr` | -| Hard-suppress label (`needs-human`, `stage/085-needs-decision`, `needs-spec`, or the retired `needs-direction` / `needs-decision`) — always excluded, whatever `exclude_labels` says; `needs-human` next to `approved-direction` still excludes ([#12680](https://github.com/hivecommons/hive/issues/12680)) | `hard-suppress: