Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixed-15202-human-pr-adoption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Keep human-authored and hive owner/merger PRs with their authors: reviewer questions return to Needs author, and Let hive decide cannot adopt or supersede them, even when adoption was explicitly requested ([#15202](https://github.com/hivecommons/hive/issues/15202)).
14 changes: 9 additions & 5 deletions src/cmd/hive/merge_eligibility.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package main

import (
Expand Down Expand Up @@ -125,11 +125,15 @@
HiveID: cfg.HiveID,
AuthorKindLabels: cfg.Governor.Labels.Author.Names(),
RelayKnown: contributorRelayKnown,
SharedCIIncident: stageSharedCIIncident,
BaseBranchRed: stageBaseBranchRed,
ForkGuard: stageForkGuard,
DCORequired: stage.DCO.RequiredFor,
SquashOnly: cfg.AutoMerge.SquashOnlyFor,
OwnerAuthor: func(login string) bool {
role, ok := cfg.Dashboard.AuthorizedRole(login)
return ok && config.RoleAtLeast(role, config.RoleMerger)
},
SharedCIIncident: stageSharedCIIncident,
BaseBranchRed: stageBaseBranchRed,
ForkGuard: stageForkGuard,
DCORequired: stage.DCO.RequiredFor,
SquashOnly: cfg.AutoMerge.SquashOnlyFor,
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/docs/adr/0025-central-stage-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ hub read that store and nothing else (`src/pkg/dashboard/stage_tracker.go:957-96
`SyncIssueStageLabels` (`src/pkg/github/issue_stage_labels.go:205`,
`src/pkg/github/issue_stage_labels.go:337`) from the enumeration bucket. PRs
are staged by the automerge engine: each lane calls `recordStage`
(`src/pkg/github/automerge/automerge_stage_labels.go:365`), observations are
(`src/pkg/github/automerge/automerge_stage_labels.go:442`), observations are
sorted by `stageLanePrecedence` (self 3 > trusted 2 > contributor 1,
`src/pkg/github/automerge/automerge_stage_labels.go:177`), each is classified
`src/pkg/github/automerge/automerge_stage_labels.go:212`), each is classified
by `ClassifyStage` (`src/pkg/stageengine/derive_pr_classify.go:261`)
into a ranked opinion (`prstage.RankNone..prstage.RankOwned`,
`src/pkg/stageengine/prstage/types.go:145-148`), and
Expand Down
2 changes: 2 additions & 0 deletions src/internal/testutil/dashboard_import_allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ github.com/hivecommons/hive/crux/watchdog
github.com/hivecommons/hive/pkg/acmmadvisor
github.com/hivecommons/hive/pkg/advisory
github.com/hivecommons/hive/pkg/agentaudit
# Shared author classification keeps delegated adoption aligned with the stage engine.
github.com/hivecommons/hive/pkg/authorkind
github.com/hivecommons/hive/pkg/beads
github.com/hivecommons/hive/pkg/celtrigger
github.com/hivecommons/hive/pkg/claims
Expand Down
27 changes: 26 additions & 1 deletion src/pkg/dashboard/api_repo_item_decide_delegated.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package dashboard

import (
Expand All @@ -15,9 +15,11 @@
"time"

gh "github.com/google/go-github/v72/github"
"github.com/hivecommons/hive/pkg/authorkind"
"github.com/hivecommons/hive/pkg/config"
"github.com/hivecommons/hive/pkg/delegatedecision"
ghpkg "github.com/hivecommons/hive/pkg/github"
"github.com/hivecommons/hive/pkg/review"
"github.com/hivecommons/hive/pkg/stagelabels"
)

Expand Down Expand Up @@ -597,10 +599,33 @@
in.Author = author
in.HiveAuthored = client.IsHiveAuthorLogin(author)
in.TrustedAuthor = in.HiveAuthored
var names authorkind.Names
if s.deps.Config != nil {
names = s.deps.Config.Governor.Labels.Author.Names()
role, ok := s.deps.Config.Dashboard.AuthorizedRole(author)
in.OwnerAuthor = ok && config.RoleAtLeast(role, config.RoleMerger)
}
relayKnown := false
if presence := s.stageTrackerPresence(); presence != nil {
p, ok := presence(author)
relayKnown = ok && p.Registered
}
in.HumanAuthor = !in.HiveAuthored && (authorkind.FromLabels(in.Labels, names) == authorkind.Human ||
authorkind.Detect(authorkind.Input{Author: author, AppLogin: client.AppBotLogin(), Labels: in.Labels, Body: pr.GetBody(), RelayKnown: relayKnown}) == authorkind.Human)
if artifact, err := review.LoadArtifact(""); err == nil {
if verdict, ok := stageTrackerVerdictLookup(&artifact)(repoFull, number, pr.GetHead().GetSHA()); ok {
in.Question = strings.TrimSpace(verdict.DecisionQuestion)
in.ReviewApproved = verdict.Verdict == review.VerdictApprove && verdict.MergeEligible && !verdict.DecisionRequested
if in.Question == "" && !in.ReviewApproved {
in.Question = strings.Join(verdict.HumanSummaries, "; ")
}
}
}
if err := s.gatherDelegatedPRContext(ctx, repoFull, pr, &in); err != nil {
return in, err
}
state := strings.ToLower(pr.GetMergeableState())
in.Mergeable = pr.GetMergeable()
in.Conflicting = state == "dirty" || (pr.Mergeable != nil && !pr.GetMergeable())
green, err := delegatedHeadCIGreen(ctx, api, owner, repoName, pr, in.Conflicting, state)
if err != nil {
Expand Down Expand Up @@ -776,7 +801,7 @@
}
notes = append(notes, fmt.Sprintf("cross-linked #%d", a.Target))
case delegatedecision.ActionAdoptSupersede:
if in.Kind != delegatedecision.KindPR || in.HiveAuthored {
if in.Kind != delegatedecision.KindPR || in.HiveAuthored || in.ProtectedAuthor() {
return notes, fmt.Errorf("supersede adoption requires a contributor PR")
}
if !s.stageAdoptionEnabled() {
Expand Down
40 changes: 38 additions & 2 deletions src/pkg/dashboard/api_repo_item_decide_delegated_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ func pinDelegatedClock(t *testing.T, at string) {
func TestDelegatedDecisionStuckReportsNoEffect(t *testing.T) {
pinDelegatedClock(t, "2026-10-28T14:23:00Z")
prior := delegatedecision.Decision{Verdict: delegatedecision.VerdictAdoptSupersede, Rule: "contributor-offline", Text: "supersede now: adopt #8 in supersede mode", Rationale: "offline"}
mock := &repoItemDecisionMock{prForeign: true, prRedCI: true, prMergeState: "blocked", prLabels: []string{"stage/085-needs-decision", "needs-human"}}
mock := &repoItemDecisionMock{prForeign: true, prBody: "β€” hive: backend=codex", prRedCI: true, prMergeState: "blocked", prLabels: []string{"stage/085-needs-decision", "needs-human"}}
mock.priorComments = []string{delegatedecision.CommentBody(prior, "h1", "writer")}
srv := newDelegatedServer(t, mock)
srv.deps.Config.Governor.Labels.Stage.Adoption.Enabled = boolPtr(true)
Expand Down Expand Up @@ -602,7 +602,7 @@ func TestDelegatedStuckWhyReadsTheLane(t *testing.T) {
// The head conflicts and adoption is off on this hive, so the hive can
// neither approve nor adopt it (hivecommons/hive#15173).
func TestDelegatedDecisionReadyForHumanMergeAnswersWithMergeOptions(t *testing.T) {
mock := &repoItemDecisionMock{prForeign: true, prMergeState: "dirty", prLabels: []string{"stage/090-ready-for-human-merge", "role/newcomer"}}
mock := &repoItemDecisionMock{prForeign: true, prBody: "β€” hive: backend=codex", prMergeState: "dirty", prLabels: []string{"stage/090-ready-for-human-merge", "role/newcomer"}}
srv := newDelegatedServer(t, mock)
srv.deps.Config.Governor.Labels.Stage.Adoption.Enabled = boolPtr(false)
body := `{"type":"pr","reason":"contributor-lane:author-association","caption":"Ready for human merge Β· untrusted author"}`
Expand Down Expand Up @@ -684,3 +684,39 @@ func TestDelegatedAsyncFanOutSettlesOperatorAction(t *testing.T) {
}
}
}

func TestLetHiveDecideReturnsHumanAndOwnerPRs(t *testing.T) {
for _, author := range []string{"human", "owner clanker", "merger clanker"} {
for _, stage := range []string{"stage/085-needs-decision", "stage/090-ready-for-human-merge"} {
t.Run(author+"/"+stage, func(t *testing.T) {
mock := &repoItemDecisionMock{prForeign: true, prMergeState: "dirty", prLabels: []string{stage}}
srv := newDelegatedServer(t, mock)
if author == "human" {
mock.prLabels = append(mock.prLabels, "author/human")
} else {
mock.prBody = "β€” hive: backend=codex"
role := "owner"
if author == "merger clanker" {
role = "merger"
}
srv.deps.Config.Dashboard.AuthorizedUsers = []string{"someone:" + role}
}
w := postDelegated(t, srv, 8, "writer", `{"type":"pr","reason":"contributor-lane:author-association"}`, map[string]string{"X-Hive-Role": "merger"})
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
job := decodeDelegatedJob(t, w)
if job.Status != delegatedJobStatusDone || job.Decision == nil || job.Decision.Rule != "return-to-author" {
t.Fatalf("job: %+v", job)
}
added := strings.Join(mock.added, ",")
if strings.Contains(added, delegatedecision.LabelAdoptSupersede) || !strings.Contains(added, "stage/071-needs-author") || len(mock.reviews) != 0 || len(mock.edits) != 0 || len(mock.merges) != 0 {
t.Fatalf("unexpected writes: added=%v reviews=%v edits=%v", mock.added, mock.reviews, mock.edits)
}
if len(mock.comments) != 1 || !strings.Contains(mock.comments[0], "needs rebase by @someone") || strings.Contains(mock.comments[0], "author agent") {
t.Fatalf("author direction: %v", mock.comments)
}
})
}
}
}
2 changes: 1 addition & 1 deletion src/pkg/dashboard/api_repo_item_decide_orphan.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type orphanedAdoption struct {
// marker naming a copy that is closed without merging. Read failures read
// as "not orphaned": the ordinary delegate path then runs.
func (s *Server) delegatedOrphanedAdoption(ctx context.Context, repoFull string, number int, in delegatedecision.Input, now time.Time) (orphanedAdoption, bool) {
if in.Kind != delegatedecision.KindPR || in.HiveAuthored || s.deps == nil || s.deps.GHClient == nil || s.deps.GHClient.GoGitHub() == nil {
if in.Kind != delegatedecision.KindPR || in.HiveAuthored || in.ProtectedAuthor() || s.deps == nil || s.deps.GHClient == nil || s.deps.GHClient.GoGitHub() == nil {
return orphanedAdoption{}, false
}
owner, repoName, _ := strings.Cut(repoFull, "/")
Expand Down
6 changes: 3 additions & 3 deletions src/pkg/dashboard/api_repo_item_decide_orphan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func orphanPriorDecision() string {
// The recorded contributor-offline decision has a closed, unmerged copy
// behind it: not "no change" but a fresh adoption request, said plainly.
func TestDelegatedDecisionOrphanedAdoptionReadopts(t *testing.T) {
mock := &repoItemDecisionMock{prForeign: true, prRedCI: true, prMergeState: "blocked", supersederState: "closed"}
mock := &repoItemDecisionMock{prForeign: true, prBody: "β€” hive: backend=codex", prRedCI: true, prMergeState: "blocked", supersederState: "closed"}
mock.priorComments = []string{orphanPriorDecision(), orphanAdoptedMarker}
srv := newDelegatedServer(t, mock)
w := postDelegated(t, srv, 8, "writer", `{"type":"pr","reason":"contributor-lane:decision-requested","caption":"Needs human decision"}`, nil)
Expand Down Expand Up @@ -62,7 +62,7 @@ func TestDelegatedDecisionAdoptionWithLiveCopyIsNoop(t *testing.T) {
merged bool
}{{"open", "open", false}, {"merged", "closed", true}} {
t.Run(tc.name, func(t *testing.T) {
mock := &repoItemDecisionMock{prForeign: true, prRedCI: true, prMergeState: "blocked", supersederState: tc.state, supersederMerged: tc.merged}
mock := &repoItemDecisionMock{prForeign: true, prBody: "β€” hive: backend=codex", prRedCI: true, prMergeState: "blocked", supersederState: tc.state, supersederMerged: tc.merged}
mock.priorComments = []string{orphanPriorDecision(), orphanAdoptedMarker}
srv := newDelegatedServer(t, mock)
w := postDelegated(t, srv, 8, "writer", `{"type":"pr","reason":"contributor-lane:decision-requested"}`, nil)
Expand All @@ -83,7 +83,7 @@ func TestDelegatedDecisionAdoptionWithLiveCopyIsNoop(t *testing.T) {
// The store says the copies were closed twice within the hour: no third
// copy; a recommendation names the loop and leaves the park.
func TestDelegatedDecisionOrphanedAdoptionLoopedRecommends(t *testing.T) {
mock := &repoItemDecisionMock{prForeign: true, prRedCI: true, prMergeState: "blocked", supersederState: "closed"}
mock := &repoItemDecisionMock{prForeign: true, prBody: "β€” hive: backend=codex", prRedCI: true, prMergeState: "blocked", supersederState: "closed"}
mock.priorComments = []string{orphanPriorDecision(), orphanAdoptedMarker}
srv := newDelegatedServer(t, mock)
store := stagelabels.NewStore("", nil)
Expand Down
3 changes: 2 additions & 1 deletion src/pkg/dashboard/api_repo_item_decision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type repoItemDecisionMock struct {
edits []map[string]any
// prForeign makes PR #8 look like a human opened it; prLabels are the
// labels pulls/8 reports.
prBody string
prForeign bool
prLabels []string
// prMergeState is PR #8's mergeable_state ("" = clean); prRedCI and
Expand Down Expand Up @@ -101,7 +102,7 @@ func newRepoItemDecisionServer(t *testing.T, m *repoItemDecisionMock) *Server {
for _, label := range m.prLabels {
prLabels = append(prLabels, map[string]string{"name": label})
}
_ = json.NewEncoder(w).Encode(map[string]any{"number": n, "state": "open", "labels": prLabels, "user": author, "mergeable": true, "mergeable_state": mergeState, "head": map[string]any{"sha": "feedfacefeedfacefeedfacefeedfacefeedface"}, "base": map[string]any{"ref": "main"}})
_ = json.NewEncoder(w).Encode(map[string]any{"number": n, "state": "open", "labels": prLabels, "body": m.prBody, "user": author, "mergeable": true, "mergeable_state": mergeState, "head": map[string]any{"sha": "feedfacefeedfacefeedfacefeedfacefeedface"}, "base": map[string]any{"ref": "main"}})
case r.Method == http.MethodPut && strings.HasSuffix(path, "/pulls/8/merge"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
Expand Down
3 changes: 3 additions & 0 deletions src/pkg/dashboard/delegated_decision_human_merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ func (s *Server) delegatedHumanMergeDecision(in delegatedecision.Input, req dele
if in.HiveAuthored || in.SentinelAlert || strings.EqualFold(strings.TrimSpace(in.Author), strings.TrimSpace(user)) {
return delegatedecision.Decision{}, nil, false
}
if in.ProtectedAuthor() {
return delegatedecision.Run(in), nil, true
}
ref := "#" + strconv.Itoa(in.Number)
adopt := func(rule, why string) (delegatedecision.Decision, []string, bool) {
if !s.stageAdoptionEnabled() {
Expand Down
1 change: 1 addition & 0 deletions src/pkg/dashboard/delegated_decision_human_merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func TestDelegatedDecisionUntrustedReadyForHumanMergeDecides(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
mock := tc.mock
mock.prForeign = true
mock.prBody = "β€” hive: backend=codex"
mock.prLabels = []string{"stage/090-ready-for-human-merge", "role/newcomer"}
srv := newDelegatedServer(t, mock)
srv.deps.Config.Governor.Labels.Stage.Adoption.Enabled = boolPtr(true)
Expand Down
2 changes: 1 addition & 1 deletion src/pkg/dashboard/delegated_pr_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func delegatedCompetingNumbers(items []StageTrackerItem, repo string, pr *gh.Pul
func (s *Server) gatherDelegatedPRContext(ctx context.Context, repo string, pr *gh.PullRequest, in *delegatedecision.Input) error {
owner, name, _ := strings.Cut(repo, "/")
now := time.Now().UTC()
if !in.HiveAuthored {
if !in.HiveAuthored && !in.ProtectedAuthor() {
window := config.DefaultPresenceWindow
if s.deps.Config != nil {
window = s.deps.Config.Governor.Labels.Stage.EffectivePresenceWindow()
Expand Down
2 changes: 1 addition & 1 deletion src/pkg/dashboard/delegated_pr_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func TestGatherDelegatedPresenceIgnoresParkCaption(t *testing.T) {
case strings.HasSuffix(r.URL.Path, "/issues/8"):
_, _ = w.Write([]byte(`{"number":8,"state":"open","pull_request":{},"user":{"login":"alice"}}`))
case strings.HasSuffix(r.URL.Path, "/pulls/8"):
_ = json.NewEncoder(w).Encode(map[string]any{"number": 8, "state": "open", "user": map[string]string{"login": "alice"}, "created_at": time.Now().Add(-time.Hour), "mergeable_state": "dirty"})
_ = json.NewEncoder(w).Encode(map[string]any{"number": 8, "state": "open", "user": map[string]string{"login": "alice"}, "created_at": time.Now().Add(-time.Hour), "body": "β€” hive: backend=codex", "mergeable_state": "dirty"})
case strings.Contains(r.URL.Path, "/permission"):
_, _ = w.Write([]byte(`{"permission":"read"}`))
default:
Expand Down
4 changes: 3 additions & 1 deletion src/pkg/dashboard/row_action_result.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package dashboard

import (
Expand Down Expand Up @@ -146,7 +146,9 @@
case d.Verdict == delegatedecision.VerdictDirection:
out.Action = rowActionDirected
out.Outcome = "Direction recorded" + ruleTag + ": " + strings.TrimSuffix(strings.TrimSpace(d.Text), ".") + "."
if in.Kind == delegatedecision.KindPR {
if d.Rule == "return-to-author" {
out.Next = "The author answers the reviewer or pushes a new head; the row moves when it lands."
} else if in.Kind == delegatedecision.KindPR {
out.Next = "The author agent pushes a new head; the row moves when it lands."
} else {
out.Next = "The hive carries on with that direction; nothing is merged."
Expand Down
6 changes: 3 additions & 3 deletions src/pkg/dashboard/stage_tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -1803,9 +1803,9 @@ func (s *Server) buildStageTracker(status *StatusPayload, store *stagelabels.Sto
it.Modifiers = slices.DeleteFunc(it.Modifiers, func(m string) bool { return m == modNeedsTriage })
}
it.Caption, it.Actor = stageTrackerCaption(stage, it.Reason, it.Author, it.Kind, it.Mirrored, rec, hasRec, it.LinkedPR, it.Head)
// Adoption is clanker-only (a human's PR is never adopted), so a
// human-authored 072 row keeps its plain caption.
if stage == stagelabels.ReturnedToContributor && it.Kind == "pr" && it.AuthorKind != authorKindHuman {
// Adoption is clanker-only. Protected authors show any recorded
// refusal on their own stage so the tracker explains why adoption stopped.
if it.Kind == "pr" && (stage == stagelabels.ReturnedToContributor && it.AuthorKind != authorKindHuman || protectedAuthorAdoptionBlocked(rec.AdoptionBlocked(it.Head))) {
if it.Adoption = stageTrackerAdoption(*it, rec, hasRec, presence, maintainerPolicy, now); it.Adoption != nil {
it.Caption = stageTrackerAdoptionCaption(it.Caption, it.Adoption)
}
Expand Down
13 changes: 11 additions & 2 deletions src/pkg/dashboard/stage_tracker_adoption.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ func (s *Server) stageTrackerMaintainerPolicy() string {
// when the row was never nudged at its current head (nothing to adopt
// yet) or is already adopted.
func stageTrackerAdoption(it StageTrackerItem, rec stagelabels.Record, hasRec bool, presence func(login string) (automerge.ContributorPresence, bool), policy string, now time.Time) *StageTrackerAdoption {
if it.Kind != "pr" || !hasRec || rec.NudgeCount == 0 || rec.NudgeStart().IsZero() {
blocked := rec.AdoptionBlocked(it.Head)
protected := protectedAuthorAdoptionBlocked(blocked)
if it.Kind != "pr" || !hasRec || !protected && (rec.NudgeCount == 0 || rec.NudgeStart().IsZero()) {
return nil
}
if it.Head != "" && rec.NudgeHead != "" && rec.NudgeHead != it.Head {
if !protected && it.Head != "" && rec.NudgeHead != "" && rec.NudgeHead != it.Head {
// The head moved since the nudge: the 072 clock restarts on the
// next sweep; nothing to say about adoption yet.
return nil
Expand Down Expand Up @@ -140,6 +142,9 @@ func stageTrackerAdoptionText(author string, a StageTrackerAdoption, now time.Ti
if author != "" {
who = "@" + author
}
if protectedAuthorAdoptionBlocked(a.BlockedBy) {
return "waiting on " + who + " β€” not adopted: " + a.BlockedBy
}
var facts []string
if a.AuthorMaintainer {
facts = append(facts, "maintainer")
Expand Down Expand Up @@ -206,3 +211,7 @@ func stageTrackerAdoptionCaption(caption string, a *StageTrackerAdoption) string
}
return head + " β€” " + a.Text
}

func protectedAuthorAdoptionBlocked(reason string) bool {
return reason == stagelabels.AdoptBlockedHumanAuthor || reason == stagelabels.AdoptBlockedOwnerAuthor
}
23 changes: 23 additions & 0 deletions src/pkg/dashboard/stage_tracker_adoption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,26 @@ func TestStageTrackerAdoptionEndpointShape(t *testing.T) {
t.Fatalf("endpoint body lacks adoption: %s", rr.Body.String())
}
}

func TestProtectedAdoptionBlockedHoverWithoutNudge(t *testing.T) {
now := time.Now().UTC()
for _, reason := range []string{stagelabels.AdoptBlockedHumanAuthor, stagelabels.AdoptBlockedOwnerAuthor} {
rec := stagelabels.Record{AdoptBlockedHead: "aaa", AdoptBlockedBy: reason, AdoptBlockedAt: now}
a := stageTrackerAdoption(StageTrackerItem{Kind: "pr", Author: "alice", Head: "aaa"}, rec, true, nil, "ask", now)
if a == nil || !a.Due || a.Action != "" || !strings.Contains(a.Text, reason) || strings.Contains(a.Text, "offline") {
t.Fatalf("blocked hover: %+v", a)
}
}
}

func TestProtectedAdoptionBlockShownOutside072(t *testing.T) {
now := time.Now().UTC()
s, store, status := adoptionFixture(t, now, config.AdoptionMaintainerPolicyAsk, nil)
key := stagelabels.Key("hivecommons/hive", 14524)
store.Observe(key, stagelabels.NeedsAuthor, "contributor-lane:decision-requested")
store.NoteAdoptionBlocked(key, "feed", stagelabels.AdoptBlockedHumanAuthor, now)
it := trackerItem(t, s.buildStageTracker(status, store, now), "pr", 14524)
if it.Adoption == nil || it.Adoption.BlockedBy != stagelabels.AdoptBlockedHumanAuthor || !strings.Contains(it.Caption, "not adopted") {
t.Fatalf("071 hides adoption refusal: %+v", it)
}
}
Loading
Loading