From 4ce2f22e052315ee685de2b8fb7a91c0c4346c6d Mon Sep 17 00:00:00 2001 From: scanner Date: Tue, 6 Oct 2026 13:38:29 -0400 Subject: [PATCH 1/2] feat(mergelane): serialized lane core with durable front record and pinned merge Signed-off-by: scanner --- changelog.d/added-mergelane-core.md | 1 + src/pkg/mergelane/lane.go | 766 ++++++++++++++++++++++++++++ src/pkg/mergelane/lane_test.go | 628 +++++++++++++++++++++++ src/pkg/mergelane/scenario_test.go | 605 ++++++++++++++++++++++ src/pkg/mergelane/store.go | 264 ++++++++++ 5 files changed, 2264 insertions(+) create mode 100644 changelog.d/added-mergelane-core.md create mode 100644 src/pkg/mergelane/lane.go create mode 100644 src/pkg/mergelane/lane_test.go create mode 100644 src/pkg/mergelane/scenario_test.go create mode 100644 src/pkg/mergelane/store.go diff --git a/changelog.d/added-mergelane-core.md b/changelog.d/added-mergelane-core.md new file mode 100644 index 0000000000..d99a96f8ef --- /dev/null +++ b/changelog.d/added-mergelane-core.md @@ -0,0 +1 @@ +- mergelane: core of the serialized merge lane for `hive-serialized` repositories ([#10888](https://github.com/hivecommons/hive/issues/10888), part of [#10884](https://github.com/hivecommons/hive/issues/10884)). A new `pkg/mergelane` keeps a durable, fenced front record per (repo, target branch) under `/data/mergelane/`, orders pull requests oldest-eligible first, times out a stuck front (60 minutes by default, per-repo override, restarted on every update) and drives the front pull request through compare-based staleness detection, a merge update pinned to the evaluated head, required checks on the exact head (unknown or empty set means no merge), a live final re-check, a head-pinned merge and a post-merge parent check that alerts when the merge did not land on the validated tip. Every front entry and exit, update, merge and refusal is audited. The lane is not yet called by any merge path, so nothing changes for `direct` repositories. diff --git a/src/pkg/mergelane/lane.go b/src/pkg/mergelane/lane.go new file mode 100644 index 0000000000..3bf8cdc7bd --- /dev/null +++ b/src/pkg/mergelane/lane.go @@ -0,0 +1,766 @@ +// Package mergelane is the serialized merge lane for repositories whose +// merge_strategy is hive-serialized (hivecommons/hive#10884). Per (repo, +// target branch) at most one pull request is at the front; only that pull +// request is brought up to date, checked on its exact head, re-checked live +// and merged with its head pinned. The front record is durable under /data +// and blocks regardless of the convergence ledger's mode. +// +// The lane is a gate the merge paths consult; it never decides whether Hive +// merges at all and nothing here runs for repositories left on direct. +package mergelane + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/hivecommons/hive/pkg/config" + ghub "github.com/hivecommons/hive/pkg/github" +) + +// DefaultFrontTimeout is the hive-wide front timeout (the relay's existing +// wait for CI); it restarts on every branch update. +const DefaultFrontTimeout = 60 * time.Minute + +// DefaultStaleAfter drops a waiter whose merge path stopped asking for it. +const DefaultStaleAfter = 15 * time.Minute + +// ReasonNotAtFront is the deferral reason every non-front caller gets. +const ReasonNotAtFront = "deferred: not at the front of the lane" + +// Audit actions. +const ( + ActionFrontEnter = "front-enter" + ActionFrontExit = "front-exit" + ActionDeferred = "deferred" + ActionUpdate = "branch-update" + ActionMerge = "merge" + ActionRefusal = "refusal" + ActionAlert = "alert" + ActionRestart = "restart-reset" +) + +// ErrHeadMoved is what a GitHub adapter returns when a pinned call (merge or +// branch update) is refused because the head is no longer the pinned SHA +// (HTTP 409 / 422 expected_head_sha mismatch). +var ErrHeadMoved = errors.New("mergelane: pull request head moved past the pinned SHA") + +// PullRequest is the live state the lane reads. +type PullRequest struct { + Number int + Head string + Base string + Open bool + Merged bool + Draft bool + MergeableState string + Mergeable *bool + Labels []string +} + +// CheckRun is one check result on a head, keyed by its required-check name. +type CheckRun struct { + Status string + Conclusion string +} + +// GitHub is everything the lane asks GitHub. Every read must be live (no +// cache), so the final re-check sees the state at that moment. +type GitHub interface { + PullRequest(ctx context.Context, repo string, number int) (PullRequest, error) + BranchTip(ctx context.Context, repo, branch string) (string, error) + // HeadContains compares tip...head with the compare API and reports + // whether head contains tip (behind_by == 0). GitHub's mergeable_state + // "behind" is never used: it is not reported without the up-to-date rule. + HeadContains(ctx context.Context, repo, tip, head string) (bool, error) + // RequiredChecks is the lane-only branch-rules reader (ReadBranchRules). + RequiredChecks(ctx context.Context, repo, branch string) ghub.BranchRulesResult + CheckRuns(ctx context.Context, repo, head string) (map[string]CheckRun, error) + // UpdateBranch merges the target branch into the PR branch, pinned to + // expectedHead; it never rebases or force-pushes. + UpdateBranch(ctx context.Context, repo string, number int, expectedHead string) error + // Merge is the REST merge with head pinned, no admin bypass; it returns + // the merge commit SHA. + Merge(ctx context.Context, repo string, number int, head string) (string, error) + CommitParents(ctx context.Context, repo, sha string) ([]string, error) + PullRequestsForCommit(ctx context.Context, repo, sha string) ([]int, error) +} + +// Authorizer re-checks the calling merge path's own authorization for head +// (for example: the lgtm approval still matches it). nil fails closed. +type Authorizer func(ctx context.Context, head string) error + +// Event is one audited lane decision (R31). +type Event struct { + At time.Time + Repo string + Branch string + PR int + Head string + Tip string + Action string + Reason string +} + +// Alert reports a merge that did not land directly on the validated tip. +type Alert struct { + Repo string + Branch string + PR int + MergeSHA string + ValidatedTip string + UnexpectedCommit string + UnexpectedPRs []int + Reason string +} + +// Options configures a Lane. Policy callbacks left nil fail closed. +type Options struct { + Store *Store + GitHub GitHub + Now func() time.Time + // DefaultTimeout is the hive-wide front timeout; TimeoutFor returns a + // per-repo override (<= 0 means use the default). + DefaultTimeout time.Duration + TimeoutFor func(repo string) time.Duration + StaleAfter time.Duration + // Strategy returns the repo's live merge strategy. + Strategy func(repo string) string + AutoMergeAllowed func(repo string) bool + Paused func(repo string) bool + // Blocked returns a reason when hold, do-not-merge, exempt or pause + // labels are present; nil uses the shared hold/exempt label helpers. + Blocked func(repo string, labels []string) string + Audit func(Event) + Alert func(Alert) + Logger *slog.Logger +} + +// Outcome is what a gate call did. +type Outcome string + +const ( + OutcomeFront Outcome = "front" + OutcomeDeferred Outcome = "deferred" + OutcomeWaiting Outcome = "waiting" + OutcomeUpdated Outcome = "updated" + OutcomeLeft Outcome = "left" + OutcomeMerged Outcome = "merged" +) + +// Decision is a gate call's result. Reason is set for every no-merge. +type Decision struct { + Outcome Outcome + Reason string + Position int + MergeSHA string +} + +// Lane is the gate. One Lane per process; it resets every in-progress front on +// construction so a validation interrupted by a restart is repeated in full. +type Lane struct { + opts Options + mu sync.Mutex + locks map[string]*sync.Mutex +} + +// New builds the lane and fences every front left by a previous process. +func New(opts Options) (*Lane, error) { + if opts.Store == nil || opts.GitHub == nil { + return nil, errors.New("mergelane: a store and a GitHub client are required") + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.DefaultTimeout <= 0 { + opts.DefaultTimeout = DefaultFrontTimeout + } + if opts.StaleAfter <= 0 { + opts.StaleAfter = DefaultStaleAfter + } + if opts.Logger == nil { + opts.Logger = slog.Default() + } + l := &Lane{opts: opts, locks: map[string]*sync.Mutex{}} + var ev []Event + err := opts.Store.update(func(lanes map[string]*Record) error { + for _, rec := range lanes { + if rec.Front == nil { + continue + } + rec.Epoch++ + rec.Front.Epoch = rec.Epoch + rec.Front.Stage = StageValidating + rec.Front.EvaluatedHead = "" + rec.Front.LastReason = "" + ev = append(ev, l.event(rec, rec.Front.PR, "", "", ActionRestart, + "restart: the in-progress validation is repeated in full before any merge")) + } + return nil + }) + if err != nil { + return nil, err + } + l.emit(ev) + return l, nil +} + +// Snapshot returns the lane's durable record. +func (l *Lane) Snapshot(repo, branch string) (Record, bool, error) { + return l.opts.Store.Snapshot(repo, branch) +} + +// Acquire records that pr is eligible on its merge path and asks for the +// front. The oldest eligible pull request reaches the front first; every +// other caller gets ReasonNotAtFront with its position behind the front. +func (l *Lane) Acquire(repo, branch string, pr int, path string) (Decision, error) { + if err := validArgs(repo, branch, pr); err != nil { + return Decision{}, err + } + now := l.opts.Now() + var dec Decision + var ev []Event + err := l.opts.Store.update(func(lanes map[string]*Record) error { + rec := lane(lanes, repo, branch) + l.expireLocked(rec, now, &ev) + l.pruneLocked(rec, now, pr) + if rec.Front == nil || rec.Front.PR != pr { + if i := rec.waiterIndex(pr); i >= 0 { + rec.Waiting[i].LastSeen = now + } else { + rec.Waiting = append(rec.Waiting, Waiter{PR: pr, Path: path, EligibleAt: now, LastSeen: now}) + if rec.Front != nil { + ev = append(ev, l.event(rec, pr, "", "", ActionDeferred, ReasonNotAtFront)) + } + } + } + l.promoteLocked(rec, now, &ev) + dec = position(rec, pr) + return nil + }) + if err != nil { + return Decision{}, err + } + l.emit(ev) + return dec, nil +} + +// Release takes pr out of the lane (its path no longer finds it eligible). A +// released front PR goes to the back when it becomes eligible again. +func (l *Lane) Release(repo, branch string, pr int, reason string) error { + if err := validArgs(repo, branch, pr); err != nil { + return err + } + if strings.TrimSpace(reason) == "" { + reason = "released by its merge path" + } + now := l.opts.Now() + var ev []Event + err := l.opts.Store.update(func(lanes map[string]*Record) error { + rec := lane(lanes, repo, branch) + if rec.Front != nil && rec.Front.PR == pr { + l.exitLocked(rec, now, rec.Front.EvaluatedHead, "", reason, &ev) + l.promoteLocked(rec, now, &ev) + return nil + } + rec.removeWaiter(pr) + return nil + }) + if err != nil { + return err + } + l.emit(ev) + return nil +} + +// Advance runs one round of the front PR's validation: validate → update → +// wait → final live re-check → pinned merge → post-merge verify. A caller +// that is not at the front gets ReasonNotAtFront and nothing is read or +// written on GitHub for it. Any change, error or ambiguity means no merge. +func (l *Lane) Advance(ctx context.Context, repo, branch string, pr int, authorize Authorizer) (Decision, error) { + if err := validArgs(repo, branch, pr); err != nil { + return Decision{}, err + } + unlock := l.lockLane(repo, branch) + defer unlock() + now := l.opts.Now() + var front *Front + var dec Decision + var ev []Event + err := l.opts.Store.update(func(lanes map[string]*Record) error { + rec := lane(lanes, repo, branch) + l.expireLocked(rec, now, &ev) + l.promoteLocked(rec, now, &ev) + if rec.Front == nil || rec.Front.PR != pr { + dec = position(rec, pr) + return nil + } + f := *rec.Front + front = &f + return nil + }) + if err != nil { + return Decision{}, err + } + l.emit(ev) + if front == nil { + return dec, nil + } + r := &round{l: l, ctx: ctx, repo: repo, branch: branch, front: front, authorize: authorize} + return r.run() +} + +func validArgs(repo, branch string, pr int) error { + if strings.TrimSpace(repo) == "" || strings.TrimSpace(branch) == "" || pr <= 0 { + return fmt.Errorf("mergelane: repo, branch and a positive PR number are required (got %q, %q, %d)", repo, branch, pr) + } + return nil +} + +func position(rec *Record, pr int) Decision { + if rec.Front != nil && rec.Front.PR == pr { + return Decision{Outcome: OutcomeFront} + } + return Decision{Outcome: OutcomeDeferred, Reason: ReasonNotAtFront, Position: rec.waiterIndex(pr) + 1} +} + +func (l *Lane) lockLane(repo, branch string) func() { + key := laneKey(repo, branch) + l.mu.Lock() + m, ok := l.locks[key] + if !ok { + m = &sync.Mutex{} + l.locks[key] = m + } + l.mu.Unlock() + m.Lock() + return m.Unlock +} + +func (l *Lane) timeoutFor(repo string) time.Duration { + if l.opts.TimeoutFor != nil { + if d := l.opts.TimeoutFor(repo); d > 0 { + return d + } + } + return l.opts.DefaultTimeout +} + +func (l *Lane) expireLocked(rec *Record, now time.Time, ev *[]Event) { + if rec.Front != nil && now.After(rec.Front.DeadlineAt) { + l.exitLocked(rec, now, rec.Front.EvaluatedHead, "", + fmt.Sprintf("front timeout: required checks did not finish within %s", l.timeoutFor(rec.Repo)), ev) + } +} + +func (l *Lane) exitLocked(rec *Record, now time.Time, head, tip, reason string, ev *[]Event) { + pr := rec.Front.PR + rec.Front = nil + rec.removeWaiter(pr) + rec.LastExit = &Exit{PR: pr, Reason: reason, At: now} + *ev = append(*ev, l.event(rec, pr, head, tip, ActionFrontExit, reason)) +} + +func (l *Lane) pruneLocked(rec *Record, now time.Time, keep int) { + cutoff := now.Add(-l.opts.StaleAfter) + kept := rec.Waiting[:0] + for _, w := range rec.Waiting { + if w.PR == keep || !w.LastSeen.Before(cutoff) { + kept = append(kept, w) + } + } + rec.Waiting = kept +} + +func (l *Lane) promoteLocked(rec *Record, now time.Time, ev *[]Event) { + if rec.Front != nil || len(rec.Waiting) == 0 { + return + } + w := rec.Waiting[0] + rec.Waiting = append([]Waiter(nil), rec.Waiting[1:]...) + rec.Epoch++ + rec.Front = &Front{ + PR: w.PR, Path: w.Path, Epoch: rec.Epoch, Stage: StageValidating, + EnteredAt: now, DeadlineAt: now.Add(l.timeoutFor(rec.Repo)), + } + *ev = append(*ev, l.event(rec, w.PR, "", "", ActionFrontEnter, "oldest eligible pull request reached the front")) +} + +func (l *Lane) event(rec *Record, pr int, head, tip, action, reason string) Event { + return Event{At: l.opts.Now(), Repo: rec.Repo, Branch: rec.Branch, PR: pr, Head: head, Tip: tip, Action: action, Reason: reason} +} + +func (l *Lane) emit(events []Event) { + for _, e := range events { + l.opts.Logger.Info("merge lane decision", + "repo", e.Repo, "branch", e.Branch, "pr", e.PR, "head", e.Head, "tip", e.Tip, + "action", e.Action, "reason", e.Reason) + if l.opts.Audit != nil { + l.opts.Audit(e) + } + } +} + +// strategyProblem reports a repo no longer on hive-serialized (R4): its +// validation ends without a merge. Deciding this needs no GitHub call. +func (l *Lane) strategyProblem(repo string) string { + strategy := "" + if l.opts.Strategy != nil { + strategy = l.opts.Strategy(repo) + } + if strategy != config.MergeStrategyHiveSerialized { + return fmt.Sprintf("merge strategy is %q, not %q: validation ended without a merge", strategy, config.MergeStrategyHiveSerialized) + } + return "" +} + +// policyProblem re-reads the live repo policy and labels. +func (l *Lane) policyProblem(repo string, labels []string) string { + if reason := l.strategyProblem(repo); reason != "" { + return reason + } + if l.opts.Paused != nil && l.opts.Paused(repo) { + return "repository is paused" + } + if l.opts.AutoMergeAllowed == nil || !l.opts.AutoMergeAllowed(repo) { + return "auto-merge is not allowed for the repository" + } + if l.opts.Blocked != nil { + return l.opts.Blocked(repo, labels) + } + return defaultBlocked(labels) +} + +func defaultBlocked(labels []string) string { + for _, label := range labels { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(label)), "hive-pause/") { + return fmt.Sprintf("pause label %q is present", label) + } + } + if ghub.HasHoldLabel(labels) { + return "a hold label is present" + } + if ghub.HasExemptLabel(labels, nil) { + return "a do-not-merge or exempt label is present" + } + return "" +} + +// prShape reports a PR that can no longer merge into branch; such a PR is no +// longer eligible and leaves the front. +func prShape(p PullRequest, branch string) string { + switch { + case p.Merged: + return "pull request is already merged" + case !p.Open: + return "pull request is closed" + case p.Draft: + return "pull request is a draft" + case p.Base != branch: + return fmt.Sprintf("pull request now targets %q, not %q", p.Base, branch) + } + return "" +} + +// mergeability maps GitHub's answer: conflicts leave the front, unknown and +// any other no wait for the next round. +func mergeability(p PullRequest) (string, bool) { + switch ghub.MergeableFromState(p.MergeableState, p.Mergeable) { + case ghub.MergeableYes: + return "", false + case ghub.MergeableUnknown: + return "mergeability is not yet known", false + } + if p.MergeableState == "dirty" { + return "pull request has merge conflicts", true + } + return fmt.Sprintf("pull request is not mergeable (state %q)", p.MergeableState), false +} + +type checkVerdict struct { + outcome ghub.CheckOutcome + reason string +} + +// checks re-reads the required set and its results on head. An unknown or +// empty set never passes (Decision #5); missing, queued or running waits. +func (l *Lane) checks(ctx context.Context, repo, branch, head string) checkVerdict { + rules := l.opts.GitHub.RequiredChecks(ctx, repo, branch) + if !rules.Known || len(rules.Required) == 0 { + reason := rules.Reason + if reason == "" { + reason = "required check set is empty" + } + return checkVerdict{ghub.CheckWait, "required checks unknown, no merge: " + reason} + } + runs, err := l.opts.GitHub.CheckRuns(ctx, repo, head) + if err != nil { + return checkVerdict{ghub.CheckWait, "reading check results failed: " + err.Error()} + } + var waiting []string + for _, name := range rules.SortedRequired() { + run, ok := runs[name] + if !ok { + waiting = append(waiting, name+" (missing)") + continue + } + switch ghub.ClassifyRequiredCheck(run.Status, run.Conclusion) { + case ghub.CheckFailed: + return checkVerdict{ghub.CheckFailed, fmt.Sprintf("required check %q finished as %s on head %s", name, run.Conclusion, short(head))} + case ghub.CheckWait: + waiting = append(waiting, fmt.Sprintf("%s (%s)", name, run.Status)) + } + } + if len(waiting) > 0 { + return checkVerdict{ghub.CheckWait, fmt.Sprintf("waiting for required checks on head %s: %s", short(head), strings.Join(waiting, ", "))} + } + return checkVerdict{outcome: ghub.CheckPassed} +} + +func short(sha string) string { + if len(sha) > 12 { + return sha[:12] + } + return sha +} + +// round is one Advance of the front PR. front is a copy of the durable front; +// every write back is fenced on its epoch. +type round struct { + l *Lane + ctx context.Context + repo string + branch string + front *Front + authorize Authorizer +} + +func (r *round) run() (Decision, error) { + gh := r.l.opts.GitHub + if reason := r.l.strategyProblem(r.repo); reason != "" { + return r.leave("", "", reason) + } + p, err := gh.PullRequest(r.ctx, r.repo, r.front.PR) + if err != nil { + return r.hold("", "", "reading the pull request failed: "+err.Error()) + } + if reason := prShape(p, r.branch); reason != "" { + return r.leave(p.Head, "", reason) + } + if reason := r.l.policyProblem(r.repo, p.Labels); reason != "" { + return r.leave(p.Head, "", reason) + } + if r.front.PinnedHead != "" { + if p.Head == r.front.PinnedHead { + return r.hold(p.Head, "", "waiting for the branch update to produce a new head") + } + r.front.PinnedHead = "" + r.front.EvaluatedHead = "" + } + if r.front.EvaluatedHead != "" && r.front.EvaluatedHead != p.Head { + r.l.emit([]Event{r.event(p.Head, "", ActionRefusal, + fmt.Sprintf("head changed during validation from %s: the new head is evaluated from the start", short(r.front.EvaluatedHead)))}) + } + r.front.EvaluatedHead = p.Head + r.front.Stage = StageValidating + + tip, err := gh.BranchTip(r.ctx, r.repo, r.branch) + if err != nil { + return r.hold(p.Head, "", "reading the target branch tip failed: "+err.Error()) + } + contains, err := gh.HeadContains(r.ctx, r.repo, tip, p.Head) + if err != nil { + return r.hold(p.Head, tip, "comparing the head with the target branch failed: "+err.Error()) + } + if !contains { + return r.update(p.Head, tip) + } + if v := r.l.checks(r.ctx, r.repo, r.branch, p.Head); v.outcome != ghub.CheckPassed { + if v.outcome == ghub.CheckFailed { + return r.leave(p.Head, tip, v.reason) + } + r.front.Stage = StageWaitingChecks + return r.hold(p.Head, tip, v.reason) + } + if reason, leave := mergeability(p); reason != "" { + if leave { + return r.leave(p.Head, tip, reason) + } + return r.hold(p.Head, tip, reason) + } + if reason, leave := r.finalCheck(p.Head, tip); reason != "" { + reason = "final re-check: " + reason + if leave { + return r.leave(p.Head, tip, reason) + } + return r.hold(p.Head, tip, reason) + } + return r.merge(p.Head, tip) +} + +// finalCheck re-reads everything live immediately before the merge (R10). +// The head and tip are compared with the validated ones; containment is a +// property of those two SHAs, so it holds when both are unchanged. +func (r *round) finalCheck(head, tip string) (string, bool) { + gh := r.l.opts.GitHub + q, err := gh.PullRequest(r.ctx, r.repo, r.front.PR) + if err != nil { + return "reading the pull request failed: " + err.Error(), false + } + if q.Head != head { + return fmt.Sprintf("head changed from %s to %s", short(head), short(q.Head)), false + } + if reason := prShape(q, r.branch); reason != "" { + return reason, true + } + if reason, leave := mergeability(q); reason != "" { + return reason, leave + } + if reason := r.l.policyProblem(r.repo, q.Labels); reason != "" { + return reason, true + } + now, err := gh.BranchTip(r.ctx, r.repo, r.branch) + if err != nil { + return "reading the target branch tip failed: " + err.Error(), false + } + if now != tip { + return fmt.Sprintf("target branch moved from %s to %s; the pull request is brought up to date again", short(tip), short(now)), false + } + if v := r.l.checks(r.ctx, r.repo, r.branch, head); v.outcome != ghub.CheckPassed { + return v.reason, v.outcome == ghub.CheckFailed + } + if r.authorize == nil { + return "no merge-path authorization was supplied", false + } + if err := r.authorize(r.ctx, head); err != nil { + return "the merge path's authorization no longer holds: " + err.Error(), false + } + return "", false +} + +func (r *round) update(head, tip string) (Decision, error) { + err := r.l.opts.GitHub.UpdateBranch(r.ctx, r.repo, r.front.PR, head) + if errors.Is(err, ErrHeadMoved) { + r.front.EvaluatedHead = "" + return r.hold(head, tip, "head changed before the branch update; the new head is evaluated from the start") + } + if err != nil { + return r.hold(head, tip, "branch update failed: "+err.Error()) + } + r.front.PinnedHead = head + r.front.Stage = StageUpdating + r.front.LastReason = "" + r.front.DeadlineAt = r.l.opts.Now().Add(r.l.timeoutFor(r.repo)) + reason := fmt.Sprintf("head does not contain the target branch tip %s: merged the tip in, pinned to head %s", short(tip), short(head)) + if err := r.save(); err != nil { + return Decision{Outcome: OutcomeWaiting, Reason: err.Error()}, err + } + r.l.emit([]Event{r.event(head, tip, ActionUpdate, reason)}) + return Decision{Outcome: OutcomeUpdated, Reason: reason}, nil +} + +func (r *round) merge(head, tip string) (Decision, error) { + r.front.Stage = StageMerging + if err := r.save(); err != nil { + return Decision{Outcome: OutcomeWaiting, Reason: err.Error()}, err + } + sha, err := r.l.opts.GitHub.Merge(r.ctx, r.repo, r.front.PR, head) + if errors.Is(err, ErrHeadMoved) { + r.front.EvaluatedHead = "" + r.front.Stage = StageValidating + return r.hold(head, tip, "merge refused: head moved past the pinned SHA; validation restarts") + } + if err != nil { + r.front.Stage = StageValidating + return r.hold(head, tip, "merge call failed; no merge assumed this round: "+err.Error()) + } + reason := fmt.Sprintf("merged as %s after the final re-check", short(sha)) + r.l.emit([]Event{r.event(head, tip, ActionMerge, reason)}) + dec, lerr := r.leave(head, tip, reason) + dec.Outcome, dec.MergeSHA = OutcomeMerged, sha + r.verify(sha, tip) + return dec, lerr +} + +// verify checks the merge landed directly on the validated tip (R13). +func (r *round) verify(sha, tip string) { + gh := r.l.opts.GitHub + a := Alert{Repo: r.repo, Branch: r.branch, PR: r.front.PR, MergeSHA: sha, ValidatedTip: tip} + parents, err := gh.CommitParents(r.ctx, r.repo, sha) + switch { + case err != nil: + a.Reason = fmt.Sprintf("could not verify that PR #%d's merge %s landed on the validated tip %s: %v", r.front.PR, short(sha), short(tip), err) + case len(parents) == 0: + a.Reason = fmt.Sprintf("PR #%d's merge %s has no parent commit", r.front.PR, short(sha)) + case parents[0] != tip: + a.UnexpectedCommit = parents[0] + a.UnexpectedPRs, _ = gh.PullRequestsForCommit(r.ctx, r.repo, parents[0]) + a.Reason = fmt.Sprintf("PR #%d merged as %s on unexpected commit %s, not on the validated tip %s", r.front.PR, short(sha), short(parents[0]), short(tip)) + if len(a.UnexpectedPRs) > 0 { + a.Reason += fmt.Sprintf(" (commit from PR #%d)", a.UnexpectedPRs[0]) + } + default: + return + } + r.l.opts.Logger.Error("merge lane: merge did not land on the validated tip", + "repo", r.repo, "branch", r.branch, "pr", r.front.PR, "merge", sha, "reason", a.Reason) + r.l.emit([]Event{r.event(sha, tip, ActionAlert, a.Reason)}) + if r.l.opts.Alert != nil { + r.l.opts.Alert(a) + } +} + +func (r *round) event(head, tip, action, reason string) Event { + return Event{At: r.l.opts.Now(), Repo: r.repo, Branch: r.branch, PR: r.front.PR, Head: head, Tip: tip, Action: action, Reason: reason} +} + +// hold keeps the PR at the front without a merge this round; a reason is +// audited once per change. +func (r *round) hold(head, tip, reason string) (Decision, error) { + audit := reason != r.front.LastReason + r.front.LastReason = reason + if err := r.save(); err != nil { + return Decision{Outcome: OutcomeWaiting, Reason: err.Error()}, err + } + if audit { + r.l.emit([]Event{r.event(head, tip, ActionRefusal, reason)}) + } + return Decision{Outcome: OutcomeWaiting, Reason: reason}, nil +} + +// leave ends the front with reason and starts the next pull request. +func (r *round) leave(head, tip, reason string) (Decision, error) { + now := r.l.opts.Now() + var ev []Event + err := r.l.opts.Store.update(func(lanes map[string]*Record) error { + rec := lane(lanes, r.repo, r.branch) + if !r.fenceOK(rec) { + return ErrFenced + } + r.l.exitLocked(rec, now, head, tip, reason, &ev) + r.l.promoteLocked(rec, now, &ev) + return nil + }) + r.l.emit(ev) + return Decision{Outcome: OutcomeLeft, Reason: reason}, err +} + +// save writes the round's front back only if it is still the same front. +func (r *round) save() error { + return r.l.opts.Store.update(func(lanes map[string]*Record) error { + rec := lane(lanes, r.repo, r.branch) + if !r.fenceOK(rec) { + return ErrFenced + } + *rec.Front = *r.front + return nil + }) +} + +func (r *round) fenceOK(rec *Record) bool { + return rec.Front != nil && rec.Front.PR == r.front.PR && rec.Front.Epoch == r.front.Epoch +} diff --git a/src/pkg/mergelane/lane_test.go b/src/pkg/mergelane/lane_test.go new file mode 100644 index 0000000000..f009869511 --- /dev/null +++ b/src/pkg/mergelane/lane_test.go @@ -0,0 +1,628 @@ +package mergelane + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/hivecommons/hive/pkg/config" + ghub "github.com/hivecommons/hive/pkg/github" +) + +const ( + testRepo = "acme/widgets" + testBranch = "main" +) + +var errBoom = errors.New("boom") + +// fakeGH models one repo: a branch tip, PR heads, which tip each head +// contains, check runs per head and merge commits with their parents. Hooks +// fire inside a call (before it answers) on the call's n-th invocation, which +// is how tests change GitHub state in the middle of a round. +type fakeGH struct { + tip string + prs map[int]*PullRequest + contained map[string]string + required ghub.BranchRulesResult + runs map[string]map[string]CheckRun + parents map[string][]string + commitPRs map[string][]int + errs map[string]error + autoLand bool + pending map[int]string + seq int + calls []string + counts map[string]int + hooks map[string]map[int]func() + updates []string + merges []string +} + +func newFake() *fakeGH { + return &fakeGH{ + tip: "t0", + prs: map[int]*PullRequest{}, + contained: map[string]string{}, + required: ghub.BranchRulesResult{Required: map[string]bool{"build": true}, Known: true}, + runs: map[string]map[string]CheckRun{}, + parents: map[string][]string{}, + commitPRs: map[string][]int{}, + errs: map[string]error{}, + autoLand: true, + pending: map[int]string{}, + counts: map[string]int{}, + hooks: map[string]map[int]func(){}, + } +} + +// addPR opens a green, up-to-date, mergeable PR into main. +func (f *fakeGH) addPR(n int, head string) *PullRequest { + p := &PullRequest{Number: n, Head: head, Base: testBranch, Open: true, MergeableState: "clean"} + f.prs[n] = p + f.contained[head] = f.tip + f.pass(head) + return p +} + +func (f *fakeGH) pass(head string) { + f.setRun(head, "build", "completed", "success") +} + +func (f *fakeGH) setRun(head, name, status, conclusion string) { + if f.runs[head] == nil { + f.runs[head] = map[string]CheckRun{} + } + f.runs[head][name] = CheckRun{Status: status, Conclusion: conclusion} +} + +func (f *fakeGH) on(name string, n int, fn func()) { + if f.hooks[name] == nil { + f.hooks[name] = map[int]func(){} + } + f.hooks[name][n] = fn +} + +func (f *fakeGH) call(name string) error { + f.calls = append(f.calls, name) + f.counts[name]++ + if fn := f.hooks[name][f.counts[name]]; fn != nil { + fn() + } + return f.errs[name] +} + +func (f *fakeGH) land(n int) { + f.prs[n].Head = f.pending[n] + delete(f.pending, n) +} + +func (f *fakeGH) PullRequest(_ context.Context, _ string, n int) (PullRequest, error) { + if err := f.call("PullRequest"); err != nil { + return PullRequest{}, err + } + p, ok := f.prs[n] + if !ok { + return PullRequest{}, fmt.Errorf("PR #%d not found", n) + } + out := *p + out.Labels = append([]string(nil), p.Labels...) + return out, nil +} + +func (f *fakeGH) BranchTip(_ context.Context, _, _ string) (string, error) { + if err := f.call("BranchTip"); err != nil { + return "", err + } + return f.tip, nil +} + +func (f *fakeGH) HeadContains(_ context.Context, _, tip, head string) (bool, error) { + if err := f.call("HeadContains"); err != nil { + return false, err + } + return f.contained[head] == tip, nil +} + +func (f *fakeGH) RequiredChecks(_ context.Context, _, _ string) ghub.BranchRulesResult { + if err := f.call("RequiredChecks"); err != nil { + return ghub.BranchRulesResult{Reason: err.Error()} + } + out := f.required + out.Required = map[string]bool{} + for name := range f.required.Required { + out.Required[name] = true + } + return out +} + +func (f *fakeGH) CheckRuns(_ context.Context, _, head string) (map[string]CheckRun, error) { + if err := f.call("CheckRuns"); err != nil { + return nil, err + } + out := map[string]CheckRun{} + for name, run := range f.runs[head] { + out[name] = run + } + return out, nil +} + +func (f *fakeGH) UpdateBranch(_ context.Context, _ string, n int, expectedHead string) error { + if err := f.call("UpdateBranch"); err != nil { + return err + } + f.updates = append(f.updates, fmt.Sprintf("%d:%s", n, expectedHead)) + p := f.prs[n] + if p.Head != expectedHead { + return ErrHeadMoved + } + f.seq++ + head := fmt.Sprintf("%s-u%d", expectedHead, f.seq) + f.contained[head] = f.tip + if f.autoLand { + p.Head = head + } else { + f.pending[n] = head + } + return nil +} + +func (f *fakeGH) Merge(_ context.Context, _ string, n int, head string) (string, error) { + if err := f.call("Merge"); err != nil { + return "", err + } + f.merges = append(f.merges, fmt.Sprintf("%d:%s", n, head)) + p := f.prs[n] + if p.Head != head { + return "", ErrHeadMoved + } + f.seq++ + sha := fmt.Sprintf("m%d", f.seq) + f.parents[sha] = []string{f.tip, head} + f.tip = sha + p.Merged, p.Open = true, false + return sha, nil +} + +func (f *fakeGH) CommitParents(_ context.Context, _, sha string) ([]string, error) { + if err := f.call("CommitParents"); err != nil { + return nil, err + } + return f.parents[sha], nil +} + +func (f *fakeGH) PullRequestsForCommit(_ context.Context, _, sha string) ([]int, error) { + if err := f.call("PullRequestsForCommit"); err != nil { + return nil, err + } + return f.commitPRs[sha], nil +} + +func (f *fakeGH) count(name string) int { + n := 0 + for _, c := range f.calls { + if c == name { + n++ + } + } + return n +} + +// harness wires a Lane over a temp store, the fake and an injected clock. +type harness struct { + t *testing.T + path string + f *fakeGH + now time.Time + lane *Lane + strategy string + autoMerge bool + paused bool + override time.Duration + blocked func(repo string, labels []string) string + authErr error + events []Event + alerts []Alert +} + +func newHarness(t *testing.T) *harness { + t.Helper() + h := &harness{ + t: t, + path: filepath.Join(t.TempDir(), "mergelane", "fronts.json"), + f: newFake(), + now: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC), + strategy: config.MergeStrategyHiveSerialized, + autoMerge: true, + } + h.restart() + return h +} + +func (h *harness) options(store *Store) Options { + return Options{ + Store: store, + GitHub: h.f, + Now: func() time.Time { return h.now }, + TimeoutFor: func(string) time.Duration { return h.override }, + Strategy: func(string) string { return h.strategy }, + AutoMergeAllowed: func(string) bool { return h.autoMerge }, + Paused: func(string) bool { return h.paused }, + Blocked: func(repo string, labels []string) string { + if h.blocked != nil { + return h.blocked(repo, labels) + } + return defaultBlocked(labels) + }, + Audit: func(e Event) { h.events = append(h.events, e) }, + Alert: func(a Alert) { h.alerts = append(h.alerts, a) }, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} + +// restart simulates a Hive restart: a fresh store handle and a fresh Lane +// over the same file. +func (h *harness) restart() { + h.t.Helper() + store, err := OpenStore(h.path) + if err != nil { + h.t.Fatalf("OpenStore: %v", err) + } + l, err := New(h.options(store)) + if err != nil { + h.t.Fatalf("New: %v", err) + } + h.lane = l +} + +func (h *harness) authorize(context.Context, string) error { return h.authErr } + +func (h *harness) acquire(pr int) Decision { + h.t.Helper() + d, err := h.lane.Acquire(testRepo, testBranch, pr, "sweep") + if err != nil { + h.t.Fatalf("Acquire(#%d): %v", pr, err) + } + return d +} + +func (h *harness) advance(pr int) Decision { + h.t.Helper() + d, err := h.lane.Advance(context.Background(), testRepo, testBranch, pr, h.authorize) + if err != nil { + h.t.Fatalf("Advance(#%d): %v", pr, err) + } + return d +} + +func (h *harness) record() Record { + h.t.Helper() + rec, _, err := h.lane.Snapshot(testRepo, testBranch) + if err != nil { + h.t.Fatalf("Snapshot: %v", err) + } + return rec +} + +func (h *harness) frontPR() int { + if rec := h.record(); rec.Front != nil { + return rec.Front.PR + } + return 0 +} + +func (h *harness) eventsFor(action string) []Event { + var out []Event + for _, e := range h.events { + if e.Action == action { + out = append(out, e) + } + } + return out +} + +func expectOutcome(t *testing.T, d Decision, want Outcome, reasonContains string) { + t.Helper() + if d.Outcome != want { + t.Fatalf("outcome = %q (reason %q), want %q", d.Outcome, d.Reason, want) + } + if reasonContains != "" && !strings.Contains(d.Reason, reasonContains) { + t.Fatalf("reason = %q, want it to contain %q", d.Reason, reasonContains) + } +} + +func TestNewRequiresStoreAndGitHub(t *testing.T) { + if _, err := New(Options{}); err == nil { + t.Fatal("New without a store and client must fail") + } + store, err := OpenStore(filepath.Join(t.TempDir(), "fronts.json")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + l, err := New(Options{Store: store, GitHub: newFake()}) + if err != nil { + t.Fatalf("New with defaults: %v", err) + } + if l.opts.DefaultTimeout != DefaultFrontTimeout || l.opts.StaleAfter != DefaultStaleAfter || l.opts.Now == nil || l.opts.Logger == nil { + t.Fatalf("defaults not applied: %+v", l.opts) + } + if got := l.timeoutFor(testRepo); got != 60*time.Minute { + t.Fatalf("hive-wide default front timeout = %s, want 60m", got) + } +} + +func TestOpenStoreRefusesBadFiles(t *testing.T) { + if _, err := OpenStore(" "); err == nil { + t.Fatal("an empty store path must be refused") + } + dir := t.TempDir() + + corrupt := filepath.Join(dir, "corrupt.json") + if err := os.WriteFile(corrupt, []byte("{nope"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := OpenStore(corrupt); err == nil || !strings.Contains(err.Error(), "unparseable") { + t.Fatalf("corrupt store: err = %v, want unparseable refusal", err) + } + if got, _ := os.ReadFile(corrupt); string(got) != "{nope" { + t.Fatalf("corrupt store must be left untouched, got %q", got) + } + + dup := filepath.Join(dir, "dup.json") + body := `{"version":1,"lanes":[{"repo":"acme/widgets","branch":"main"},{"repo":"ACME/widgets","branch":"main"}]}` + if err := os.WriteFile(dup, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if _, err := OpenStore(dup); err == nil || !strings.Contains(err.Error(), "conflicting") { + t.Fatalf("duplicate lanes: err = %v, want conflicting refusal", err) + } + + if _, err := OpenStore(dir); err == nil || !strings.Contains(err.Error(), "reading lane record") { + t.Fatalf("directory as store: err = %v, want read error", err) + } +} + +func TestStoreErrorsFailClosed(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + if err := os.WriteFile(h.path, []byte("{nope"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := h.lane.Acquire(testRepo, testBranch, 1, "sweep"); err == nil { + t.Fatal("Acquire over a corrupt record must fail") + } + if _, err := h.lane.Advance(context.Background(), testRepo, testBranch, 1, h.authorize); err == nil { + t.Fatal("Advance over a corrupt record must fail") + } + if err := h.lane.Release(testRepo, testBranch, 1, ""); err == nil { + t.Fatal("Release over a corrupt record must fail") + } + if _, _, err := h.lane.Snapshot(testRepo, testBranch); err == nil { + t.Fatal("Snapshot over a corrupt record must fail") + } + if len(h.f.calls) != 0 || len(h.f.merges) != 0 { + t.Fatalf("no GitHub call may happen without a readable front record, got %v", h.f.calls) + } + + dir := filepath.Dir(h.path) + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dir, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := h.lane.Acquire(testRepo, testBranch, 1, "sweep"); err == nil { + t.Fatal("Acquire must fail when the record directory cannot be created") + } +} + +func TestGateArgumentsValidated(t *testing.T) { + h := newHarness(t) + if _, err := h.lane.Acquire("", testBranch, 1, "sweep"); err == nil { + t.Fatal("Acquire without a repo must fail") + } + if _, err := h.lane.Advance(context.Background(), testRepo, " ", 1, h.authorize); err == nil { + t.Fatal("Advance without a branch must fail") + } + if err := h.lane.Release(testRepo, testBranch, 0, "x"); err == nil { + t.Fatal("Release without a PR must fail") + } +} + +func TestSnapshotMissingLaneAndPersistence(t *testing.T) { + h := newHarness(t) + if _, ok, err := h.lane.Snapshot(testRepo, "release"); ok || err != nil { + t.Fatalf("missing lane: ok=%v err=%v", ok, err) + } + h.f.addPR(1, "h1") + h.f.addPR(2, "h2") + h.acquire(1) + h.now = h.now.Add(time.Minute) + h.acquire(2) + reopened, err := OpenStore(h.path) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + rec, ok, err := reopened.Snapshot(testRepo, testBranch) + if err != nil || !ok { + t.Fatalf("Snapshot after reopen: ok=%v err=%v", ok, err) + } + if rec.Front == nil || rec.Front.PR != 1 || len(rec.Waiting) != 1 || rec.Waiting[0].PR != 2 { + t.Fatalf("durable lane = %+v, want front #1 and #2 waiting", rec) + } +} + +func TestNonFrontCallerIsDeferredWithoutGitHubCalls(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + p2 := h.f.addPR(2, "h2") + h.f.contained[p2.Head] = "old" + + expectOutcome(t, h.acquire(1), OutcomeFront, "") + d := h.acquire(2) + expectOutcome(t, d, OutcomeDeferred, ReasonNotAtFront) + if d.Position != 1 { + t.Fatalf("position = %d, want 1", d.Position) + } + d = h.advance(2) + expectOutcome(t, d, OutcomeDeferred, ReasonNotAtFront) + if len(h.f.calls) != 0 { + t.Fatalf("a non-front PR must cause no GitHub call (R16), got %v", h.f.calls) + } + expectOutcome(t, h.advance(3), OutcomeDeferred, ReasonNotAtFront) + + h.acquire(2) + if got := len(h.eventsFor(ActionDeferred)); got != 1 { + t.Fatalf("deferral audited %d times, want once on joining", got) + } +} + +func TestStaleWaiterIsPruned(t *testing.T) { + h := newHarness(t) + for n := 1; n <= 3; n++ { + h.f.addPR(n, fmt.Sprintf("h%d", n)) + } + h.acquire(1) + h.acquire(2) + h.now = h.now.Add(DefaultStaleAfter + time.Minute) + h.acquire(1) + h.acquire(3) + rec := h.record() + if len(rec.Waiting) != 1 || rec.Waiting[0].PR != 3 { + t.Fatalf("waiting = %+v, want only #3 after #2 went stale", rec.Waiting) + } +} + +func TestReleaseWaiterAndFront(t *testing.T) { + h := newHarness(t) + for n := 1; n <= 3; n++ { + h.f.addPR(n, fmt.Sprintf("h%d", n)) + h.acquire(n) + } + if err := h.lane.Release(testRepo, testBranch, 3, "no longer eligible"); err != nil { + t.Fatalf("Release waiter: %v", err) + } + if err := h.lane.Release(testRepo, testBranch, 1, ""); err != nil { + t.Fatalf("Release front: %v", err) + } + rec := h.record() + if rec.Front == nil || rec.Front.PR != 2 || len(rec.Waiting) != 0 { + t.Fatalf("lane = %+v, want #2 at the front and nobody waiting", rec) + } + if rec.LastExit == nil || rec.LastExit.PR != 1 || rec.LastExit.Reason != "released by its merge path" { + t.Fatalf("last exit = %+v", rec.LastExit) + } +} + +func TestRefusalAuditedOncePerReason(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.f.setRun("h1", "build", "in_progress", "") + h.acquire(1) + h.advance(1) + h.advance(1) + if got := len(h.eventsFor(ActionRefusal)); got != 1 { + t.Fatalf("same wait reason audited %d times, want 1", got) + } + if st := h.record().Front.Stage; st != StageWaitingChecks { + t.Fatalf("stage = %q, want %q", st, StageWaitingChecks) + } +} + +func TestNilPolicyCallbacksFailClosed(t *testing.T) { + store, err := OpenStore(filepath.Join(t.TempDir(), "fronts.json")) + if err != nil { + t.Fatal(err) + } + f := newFake() + f.addPR(1, "h1") + l, err := New(Options{Store: store, GitHub: f, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) + if err != nil { + t.Fatal(err) + } + if _, err := l.Acquire(testRepo, testBranch, 1, "sweep"); err != nil { + t.Fatal(err) + } + d, err := l.Advance(context.Background(), testRepo, testBranch, 1, nil) + if err != nil { + t.Fatal(err) + } + expectOutcome(t, d, OutcomeLeft, "merge strategy") + if len(f.calls) != 0 || len(f.merges) != 0 { + t.Fatalf("deciding the strategy needs no GitHub call, got %v", f.calls) + } + + l.opts.Strategy = func(string) string { return config.MergeStrategyHiveSerialized } + if _, err := l.Acquire(testRepo, testBranch, 1, "sweep"); err != nil { + t.Fatal(err) + } + d, _ = l.Advance(context.Background(), testRepo, testBranch, 1, nil) + expectOutcome(t, d, OutcomeLeft, "auto-merge is not allowed") + + l.opts.AutoMergeAllowed = func(string) bool { return true } + if _, err := l.Acquire(testRepo, testBranch, 1, "sweep"); err != nil { + t.Fatal(err) + } + d, _ = l.Advance(context.Background(), testRepo, testBranch, 1, nil) + expectOutcome(t, d, OutcomeWaiting, "no merge-path authorization") + if len(f.merges) != 0 { + t.Fatalf("no merge without an authorization callback, got %v", f.merges) + } +} + +func TestDefaultBlocked(t *testing.T) { + for _, tc := range []struct { + labels []string + want string + }{ + {nil, ""}, + {[]string{"enhancement", "hive/abc"}, ""}, + {[]string{"hold"}, "hold label"}, + {[]string{"on-hold"}, "hold label"}, + {[]string{"do-not-merge"}, "do-not-merge"}, + {[]string{"Hive-Pause/hive-1"}, "pause label"}, + } { + got := defaultBlocked(tc.labels) + if (tc.want == "") != (got == "") || !strings.Contains(got, tc.want) { + t.Errorf("defaultBlocked(%v) = %q, want %q", tc.labels, got, tc.want) + } + } +} + +func TestMergeability(t *testing.T) { + yes := true + for _, tc := range []struct { + state string + mergeable *bool + reason string + leave bool + }{ + {"clean", nil, "", false}, + {"unstable", nil, "", false}, + {"unknown", nil, "not yet known", false}, + {"unknown", &yes, "", false}, + {"dirty", nil, "merge conflicts", true}, + {"blocked", nil, "not mergeable", false}, + } { + reason, leave := mergeability(PullRequest{MergeableState: tc.state, Mergeable: tc.mergeable}) + if leave != tc.leave || (tc.reason == "") != (reason == "") || !strings.Contains(reason, tc.reason) { + t.Errorf("mergeability(%q) = %q, %v; want %q, %v", tc.state, reason, leave, tc.reason, tc.leave) + } + } +} + +func TestShort(t *testing.T) { + if got := short("0123456789abcdef"); got != "0123456789ab" { + t.Fatalf("short = %q", got) + } + if got := short("abc"); got != "abc" { + t.Fatalf("short = %q", got) + } +} diff --git a/src/pkg/mergelane/scenario_test.go b/src/pkg/mergelane/scenario_test.go new file mode 100644 index 0000000000..59b1da055a --- /dev/null +++ b/src/pkg/mergelane/scenario_test.go @@ -0,0 +1,605 @@ +package mergelane + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" +) + +// AC6, AC15 (R15), AC35: two green PRs into one branch. Only one is ever at the +// front; the second merges only after the first merged and it was brought up +// to date and passed its required checks on the new tip. Every decision is +// audited with PR, head, tip and reason. +func TestTwoGreenPRsMergeOneAtATimeAgainstTheNewTip(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.f.addPR(2, "h2") + expectOutcome(t, h.acquire(1), OutcomeFront, "") + h.now = h.now.Add(time.Minute) + expectOutcome(t, h.acquire(2), OutcomeDeferred, ReasonNotAtFront) + expectOutcome(t, h.advance(2), OutcomeDeferred, ReasonNotAtFront) + + d := h.advance(1) + expectOutcome(t, d, OutcomeMerged, "merged") + if d.MergeSHA == "" || h.f.tip != d.MergeSHA { + t.Fatalf("merge sha = %q, tip = %q", d.MergeSHA, h.f.tip) + } + if h.frontPR() != 2 { + t.Fatalf("front = #%d after #1 merged, want #2", h.frontPR()) + } + if len(h.f.updates) != 0 { + t.Fatalf("#2 must not be updated while #1 is at the front (R16), got %v", h.f.updates) + } + + // #2 was green on t0; one validation never covers two merges. + expectOutcome(t, h.advance(2), OutcomeUpdated, "merged the tip in") + if want := []string{"2:h2"}; fmt.Sprint(h.f.updates) != fmt.Sprint(want) { + t.Fatalf("updates = %v, want %v (pinned to the evaluated head, AC25)", h.f.updates, want) + } + newHead := h.f.prs[2].Head + expectOutcome(t, h.advance(2), OutcomeWaiting, "build (missing)") + h.f.pass(newHead) + expectOutcome(t, h.advance(2), OutcomeMerged, "") + if want := []string{"1:h1", "2:" + newHead}; fmt.Sprint(h.f.merges) != fmt.Sprint(want) { + t.Fatalf("merges = %v, want %v", h.f.merges, want) + } + if h.record().Front != nil { + t.Fatal("the front must be empty after the last merge") + } + if len(h.alerts) != 0 { + t.Fatalf("no alert expected, got %+v", h.alerts) + } + + for _, e := range h.events { + if e.Repo != testRepo || e.Branch != testBranch || e.PR <= 0 || e.Reason == "" || e.At.IsZero() { + t.Fatalf("incomplete audit entry %+v", e) + } + } + for _, action := range []string{ActionFrontEnter, ActionFrontExit, ActionDeferred, ActionUpdate, ActionMerge, ActionRefusal} { + if len(h.eventsFor(action)) == 0 { + t.Fatalf("no %s audit entry in %+v", action, h.events) + } + } + for _, action := range []string{ActionUpdate, ActionMerge} { + for _, e := range h.eventsFor(action) { + if e.Head == "" || e.Tip == "" { + t.Fatalf("%s audit entry lacks head or tip: %+v", action, e) + } + } + } +} + +// AC8, R17: PRs reach the front in the order they became eligible; one that +// leaves the front goes to the back. +func TestOldestEligibleFirstAndLeaversGoToTheBack(t *testing.T) { + h := newHarness(t) + for _, n := range []int{3, 1, 2} { + h.f.addPR(n, fmt.Sprintf("h%d", n)) + h.acquire(n) + h.now = h.now.Add(time.Minute) + } + h.f.setRun("h3", "build", "completed", "failure") + expectOutcome(t, h.advance(3), OutcomeLeft, "failure") + h.f.pass("h3") + expectOutcome(t, h.acquire(3), OutcomeDeferred, ReasonNotAtFront) + + h.acquire(1) + expectOutcome(t, h.advance(1), OutcomeMerged, "") + h.acquire(2) + expectOutcome(t, h.advance(2), OutcomeUpdated, "") + h.f.pass(h.f.prs[2].Head) + expectOutcome(t, h.advance(2), OutcomeMerged, "") + var order []int + for _, e := range h.eventsFor(ActionFrontEnter) { + order = append(order, e.PR) + } + if want := []int{3, 1, 2, 3}; fmt.Sprint(order) != fmt.Sprint(want) { + t.Fatalf("front order = %v, want %v", order, want) + } +} + +// AC9, AC10, AC25: a PR GitHub reports "clean" that does not contain the tip +// is detected through the compare API and brought up to date with a merge +// update pinned to its head; the lane waits for the new head before checking. +func TestStalePRIsUpdatedWhateverGitHubReports(t *testing.T) { + h := newHarness(t) + h.f.autoLand = false + p := h.f.addPR(1, "h1") + h.f.contained["h1"] = "old-tip" + h.acquire(1) + + expectOutcome(t, h.advance(1), OutcomeUpdated, "") + if p.MergeableState != "clean" || len(h.f.merges) != 0 { + t.Fatalf("a stale PR must not merge, merges = %v", h.f.merges) + } + if st := h.record().Front.Stage; st != StageUpdating { + t.Fatalf("stage = %q, want %q", st, StageUpdating) + } + expectOutcome(t, h.advance(1), OutcomeWaiting, "waiting for the branch update") + if len(h.f.updates) != 1 { + t.Fatalf("the update must not be repeated while it is landing, got %v", h.f.updates) + } + h.f.land(1) + expectOutcome(t, h.advance(1), OutcomeWaiting, "build (missing)") + h.f.pass(p.Head) + expectOutcome(t, h.advance(1), OutcomeMerged, "") + if want := []string{"1:h1"}; fmt.Sprint(h.f.updates) != fmt.Sprint(want) { + t.Fatalf("updates = %v, want %v", h.f.updates, want) + } +} + +// AC9: if the head changed since it was evaluated, the update is not made; +// the new head is evaluated and the PR keeps the front. +func TestUpdateRefusedWhenHeadChanged(t *testing.T) { + h := newHarness(t) + p := h.f.addPR(1, "h1") + h.f.contained["h1"] = "old-tip" + h.acquire(1) + h.f.on("UpdateBranch", 1, func() { p.Head = "h1b" }) + + expectOutcome(t, h.advance(1), OutcomeWaiting, "head changed before the branch update") + if p.Head != "h1b" || h.frontPR() != 1 { + t.Fatalf("head = %q, front = #%d; the PR keeps the front and its new head", p.Head, h.frontPR()) + } + + h.f.errs["UpdateBranch"] = errBoom + expectOutcome(t, h.advance(1), OutcomeWaiting, "branch update failed") + if len(h.f.merges) != 0 { + t.Fatalf("merges = %v", h.f.merges) + } +} + +// AC11, R12: the target branch moving before the final re-check stops the +// merge; the PR is brought up to date again and the reason is audited. +func TestMovedBranchStopsTheMerge(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.acquire(1) + h.f.on("BranchTip", 2, func() { h.f.tip = "t1" }) + + expectOutcome(t, h.advance(1), OutcomeWaiting, "target branch moved from t0 to t1") + if len(h.f.merges) != 0 { + t.Fatalf("no merge call may be made, got %v", h.f.merges) + } + refusals := h.eventsFor(ActionRefusal) + if len(refusals) != 1 || !strings.Contains(refusals[0].Reason, "target branch moved") || refusals[0].Head != "h1" { + t.Fatalf("refusal audit = %+v", refusals) + } + expectOutcome(t, h.advance(1), OutcomeUpdated, "") + if want := []string{"1:h1"}; fmt.Sprint(h.f.updates) != fmt.Sprint(want) { + t.Fatalf("updates = %v, want %v", h.f.updates, want) + } +} + +// AC12, R11: a head that changes during validation (or at the merge, 409) +// is never merged; the new head is validated from the start. +func TestChangedHeadStopsTheMerge(t *testing.T) { + h := newHarness(t) + p := h.f.addPR(1, "h1") + h.acquire(1) + h.f.on("PullRequest", 2, func() { + p.Head = "h1b" + h.f.contained["h1b"] = h.f.tip + h.f.pass("h1b") + }) + expectOutcome(t, h.advance(1), OutcomeWaiting, "head changed from h1 to h1b") + if len(h.f.merges) != 0 { + t.Fatalf("no merge call may be made, got %v", h.f.merges) + } + + h.f.on("Merge", 1, func() { p.Head = "h1c" }) + expectOutcome(t, h.advance(1), OutcomeWaiting, "merge refused: head moved") + if p.Merged || h.record().Front.Stage != StageValidating { + t.Fatalf("merged=%v stage=%q after a 409", p.Merged, h.record().Front.Stage) + } + h.f.contained["h1c"] = h.f.tip + h.f.pass("h1c") + expectOutcome(t, h.advance(1), OutcomeMerged, "") + if want := []string{"1:h1b", "1:h1c"}; fmt.Sprint(h.f.merges) != fmt.Sprint(want) { + t.Fatalf("merge calls = %v, want %v", h.f.merges, want) + } + found := false + for _, e := range h.eventsFor(ActionRefusal) { + found = found || strings.Contains(e.Reason, "head changed during validation") + } + if !found { + t.Fatalf("head change during validation not audited: %+v", h.events) + } +} + +// AC13: a hold, do-not-merge, exempt or pause label (or a repo pause) added +// while checks run stops the merge, even though the PR was eligible when +// validation started. +func TestHoldAddedDuringValidationStopsTheMerge(t *testing.T) { + for _, tc := range []struct { + name string + apply func(h *harness, p *PullRequest) + want string + }{ + {"hold", func(_ *harness, p *PullRequest) { p.Labels = []string{"hold"} }, "hold label"}, + {"on-hold", func(_ *harness, p *PullRequest) { p.Labels = []string{"on-hold"} }, "hold label"}, + {"do-not-merge", func(_ *harness, p *PullRequest) { p.Labels = []string{"do-not-merge"} }, "do-not-merge"}, + {"pause label", func(_ *harness, p *PullRequest) { p.Labels = []string{"hive-pause/hive-1"} }, "pause label"}, + {"exempt", func(h *harness, p *PullRequest) { + p.Labels = []string{"keep-open"} + h.blocked = func(_ string, labels []string) string { + if len(labels) > 0 && labels[0] == "keep-open" { + return "an exempt label is present" + } + return "" + } + }, "exempt label"}, + {"repo paused", func(h *harness, _ *PullRequest) { h.paused = true }, "repository is paused"}, + } { + t.Run(tc.name+"/between rounds", func(t *testing.T) { + h := newHarness(t) + p := h.f.addPR(1, "h1") + h.f.setRun("h1", "build", "in_progress", "") + h.acquire(1) + expectOutcome(t, h.advance(1), OutcomeWaiting, "build (in_progress)") + tc.apply(h, p) + h.f.pass("h1") + expectOutcome(t, h.advance(1), OutcomeLeft, tc.want) + if len(h.f.merges) != 0 { + t.Fatalf("no merge call may be made, got %v", h.f.merges) + } + }) + t.Run(tc.name+"/at the final re-check", func(t *testing.T) { + h := newHarness(t) + p := h.f.addPR(1, "h1") + h.acquire(1) + h.f.on("PullRequest", 2, func() { tc.apply(h, p) }) + expectOutcome(t, h.advance(1), OutcomeLeft, "final re-check: ") + if len(h.f.merges) != 0 { + t.Fatalf("no merge call may be made, got %v", h.f.merges) + } + }) + } +} + +// AC14, AC16: a required check that never starts never passes; an unknown or +// empty required set never merges. The PR leaves only by timing out. +func TestMissingOrUnknownRequiredChecksNeverPass(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + delete(h.f.runs, "h1") + h.acquire(1) + for i := 0; i < 5; i++ { + expectOutcome(t, h.advance(1), OutcomeWaiting, "build (missing)") + h.now = h.now.Add(10 * time.Minute) + } + h.now = h.now.Add(11 * time.Minute) + expectOutcome(t, h.advance(1), OutcomeDeferred, ReasonNotAtFront) + if rec := h.record(); rec.Front != nil || rec.LastExit == nil || !strings.Contains(rec.LastExit.Reason, "front timeout") { + t.Fatalf("lane = %+v, want a timeout exit", rec) + } + if len(h.f.merges) != 0 { + t.Fatalf("merges = %v", h.f.merges) + } + + for _, tc := range []struct { + name string + rules func(f *fakeGH) + want string + }{ + {"unknown", func(f *fakeGH) { f.errs["RequiredChecks"] = errBoom }, "required checks unknown, no merge: boom"}, + {"empty", func(f *fakeGH) { f.required.Required = map[string]bool{} }, "required check set is empty"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + tc.rules(h.f) + h.acquire(1) + expectOutcome(t, h.advance(1), OutcomeWaiting, tc.want) + if len(h.f.merges) != 0 { + t.Fatalf("merges = %v", h.f.merges) + } + }) + } +} + +// AC17, R4: other changes while checks run stop the merge. +func TestOtherChangesDuringValidationStopTheMerge(t *testing.T) { + for _, tc := range []struct { + name string + apply func(h *harness, p *PullRequest) + outcome Outcome + want string + }{ + {"closed", func(_ *harness, p *PullRequest) { p.Open = false }, OutcomeLeft, "pull request is closed"}, + {"draft", func(_ *harness, p *PullRequest) { p.Draft = true }, OutcomeLeft, "pull request is a draft"}, + {"retargeted", func(_ *harness, p *PullRequest) { p.Base = "release" }, OutcomeLeft, `now targets "release"`}, + {"merged elsewhere", func(_ *harness, p *PullRequest) { p.Merged = true }, OutcomeLeft, "already merged"}, + {"switched to direct", func(h *harness, _ *PullRequest) { h.strategy = "direct" }, OutcomeLeft, "validation ended without a merge"}, + {"auto-merge off", func(h *harness, _ *PullRequest) { h.autoMerge = false }, OutcomeLeft, "auto-merge is not allowed"}, + {"authorization lost", func(h *harness, _ *PullRequest) { h.authErr = errors.New("approval does not match head") }, OutcomeWaiting, "authorization no longer holds"}, + {"new required check", func(h *harness, _ *PullRequest) { h.f.required.Required["e2e"] = true }, OutcomeWaiting, "e2e (missing)"}, + {"required check failed", func(h *harness, _ *PullRequest) { h.f.setRun("h1", "build", "completed", "cancelled") }, OutcomeLeft, "cancelled"}, + {"became unmergeable", func(_ *harness, p *PullRequest) { p.MergeableState = "dirty" }, OutcomeLeft, "merge conflicts"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + p := h.f.addPR(1, "h1") + h.acquire(1) + h.f.on("PullRequest", 2, func() { tc.apply(h, p) }) + expectOutcome(t, h.advance(1), tc.outcome, tc.want) + if len(h.f.merges) != 0 { + t.Fatalf("no merge call may be made, got %v", h.f.merges) + } + }) + } + + t.Run("direct before validation needs no GitHub call", func(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.acquire(1) + h.strategy = "direct" + expectOutcome(t, h.advance(1), OutcomeLeft, "validation ended without a merge") + if len(h.f.calls) != 0 { + t.Fatalf("GitHub calls = %v, want none", h.f.calls) + } + }) +} + +// AC18, AC20: skipped and neutral pass like success; failure, cancelled, +// timed_out and action_required fail, the PR leaves the front, the next one +// starts and PRs further back are not updated. +func TestRequiredCheckClassification(t *testing.T) { + for _, tc := range []struct { + conclusion string + outcome Outcome + }{ + {"success", OutcomeMerged}, + {"skipped", OutcomeMerged}, + {"neutral", OutcomeMerged}, + {"failure", OutcomeLeft}, + {"cancelled", OutcomeLeft}, + {"timed_out", OutcomeLeft}, + {"action_required", OutcomeLeft}, + } { + t.Run(tc.conclusion, func(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.f.setRun("h1", "build", "completed", tc.conclusion) + for _, n := range []int{2, 3} { + h.f.addPR(n, fmt.Sprintf("h%d", n)) + h.f.contained[fmt.Sprintf("h%d", n)] = "old-tip" + } + for _, n := range []int{1, 2, 3} { + h.acquire(n) + } + expectOutcome(t, h.advance(1), tc.outcome, "") + if h.frontPR() != 2 { + t.Fatalf("front = #%d, want #2 to start next", h.frontPR()) + } + if len(h.f.updates) != 0 { + t.Fatalf("no PR behind the front may be updated, got %v", h.f.updates) + } + expectOutcome(t, h.advance(2), OutcomeUpdated, "") + expectOutcome(t, h.advance(3), OutcomeDeferred, ReasonNotAtFront) + for _, u := range h.f.updates { + if strings.HasPrefix(u, "3:") { + t.Fatalf("#3 was updated while waiting: %v", h.f.updates) + } + } + }) + } +} + +// AC19: uncertainty (mergeability unknown, a failed API call) blocks the +// merge for that round with a reason. +func TestUncertaintyBlocksTheRound(t *testing.T) { + for _, tc := range []struct { + name string + apply func(h *harness, p *PullRequest) + want string + }{ + {"mergeability unknown", func(_ *harness, p *PullRequest) { p.MergeableState = "unknown" }, "mergeability is not yet known"}, + {"mergeability blocked", func(_ *harness, p *PullRequest) { p.MergeableState = "blocked" }, "not mergeable"}, + {"pr read fails", func(h *harness, _ *PullRequest) { h.f.errs["PullRequest"] = errBoom }, "reading the pull request failed"}, + {"tip read fails", func(h *harness, _ *PullRequest) { h.f.errs["BranchTip"] = errBoom }, "reading the target branch tip failed"}, + {"compare fails", func(h *harness, _ *PullRequest) { h.f.errs["HeadContains"] = errBoom }, "comparing the head"}, + {"check runs fail", func(h *harness, _ *PullRequest) { h.f.errs["CheckRuns"] = errBoom }, "reading check results failed"}, + {"final pr read fails", func(h *harness, _ *PullRequest) { + h.f.on("PullRequest", 2, func() { h.f.errs["PullRequest"] = errBoom }) + }, "final re-check: reading the pull request failed"}, + {"final mergeability unknown", func(h *harness, p *PullRequest) { + h.f.on("PullRequest", 2, func() { p.MergeableState = "unknown" }) + }, "final re-check: mergeability is not yet known"}, + {"final tip read fails", func(h *harness, _ *PullRequest) { + h.f.on("BranchTip", 2, func() { h.f.errs["BranchTip"] = errBoom }) + }, "final re-check: reading the target branch tip failed"}, + {"final required checks unknown", func(h *harness, _ *PullRequest) { + h.f.on("RequiredChecks", 2, func() { h.f.errs["RequiredChecks"] = errBoom }) + }, "final re-check: required checks unknown"}, + {"merge call fails", func(h *harness, _ *PullRequest) { h.f.errs["Merge"] = errBoom }, "merge call failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + p := h.f.addPR(1, "h1") + h.acquire(1) + tc.apply(h, p) + expectOutcome(t, h.advance(1), OutcomeWaiting, tc.want) + if p.Merged || h.frontPR() != 1 { + t.Fatalf("merged=%v front=#%d; uncertainty must keep the front without merging", p.Merged, h.frontPR()) + } + if refusals := h.eventsFor(ActionRefusal); len(refusals) == 0 || !strings.Contains(refusals[len(refusals)-1].Reason, tc.want) { + t.Fatalf("refusal not audited: %+v", refusals) + } + }) + } +} + +// AC21, R18: a stuck front times out with a reason and the next PR starts; a +// per-repo override applies; the timeout restarts on every branch update. +func TestFrontTimeout(t *testing.T) { + t.Run("hive-wide default", func(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.f.addPR(2, "h2") + h.f.setRun("h1", "build", "queued", "") + h.acquire(1) + h.acquire(2) + h.now = h.now.Add(59 * time.Minute) + expectOutcome(t, h.advance(1), OutcomeWaiting, "build (queued)") + h.now = h.now.Add(2 * time.Minute) + expectOutcome(t, h.advance(1), OutcomeDeferred, ReasonNotAtFront) + rec := h.record() + if rec.Front == nil || rec.Front.PR != 2 { + t.Fatalf("front = %+v, want #2 after the timeout", rec.Front) + } + if rec.LastExit == nil || rec.LastExit.PR != 1 || !strings.Contains(rec.LastExit.Reason, "within 1h0m0s") { + t.Fatalf("last exit = %+v", rec.LastExit) + } + expectOutcome(t, h.acquire(1), OutcomeDeferred, ReasonNotAtFront) + }) + + t.Run("per-repo override restarted by an update", func(t *testing.T) { + h := newHarness(t) + h.override = 10 * time.Minute + h.f.addPR(1, "h1") + h.f.contained["h1"] = "old-tip" + h.acquire(1) + h.now = h.now.Add(8 * time.Minute) + expectOutcome(t, h.advance(1), OutcomeUpdated, "") + h.now = h.now.Add(7 * time.Minute) + expectOutcome(t, h.advance(1), OutcomeWaiting, "missing") + h.now = h.now.Add(4 * time.Minute) + expectOutcome(t, h.advance(1), OutcomeDeferred, ReasonNotAtFront) + exits := h.eventsFor(ActionFrontExit) + if len(exits) != 1 || !strings.Contains(exits[0].Reason, "within 10m0s") { + t.Fatalf("exit audit = %+v", exits) + } + }) +} + +// AC22, R19: after a restart there is still one front per branch, the +// in-progress validation is reset and repeated in full before any merge, and +// a validation from before the restart is fenced. +func TestRestartRepeatsValidationAndFencesTheOldOne(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.f.addPR(2, "h2") + h.f.setRun("h1", "build", "in_progress", "") + h.acquire(1) + h.acquire(2) + expectOutcome(t, h.advance(1), OutcomeWaiting, "") + before := h.record().Front + + h.restart() + rec := h.record() + if rec.Front == nil || rec.Front.PR != 1 || len(rec.Waiting) != 1 || rec.Waiting[0].PR != 2 { + t.Fatalf("lane after restart = %+v, want #1 front and #2 waiting", rec) + } + if rec.Front.Epoch <= before.Epoch || rec.Front.EvaluatedHead != "" || rec.Front.Stage != StageValidating { + t.Fatalf("front after restart = %+v (before %+v), want a fenced, reset validation", rec.Front, before) + } + if len(h.eventsFor(ActionRestart)) != 1 { + t.Fatalf("restart not audited: %+v", h.events) + } + + h.f.pass("h1") + h.f.calls = nil + expectOutcome(t, h.advance(1), OutcomeMerged, "") + for _, call := range []string{"PullRequest", "BranchTip", "HeadContains", "RequiredChecks", "CheckRuns"} { + if h.f.count(call) == 0 { + t.Fatalf("no %s before the merge after a restart; calls = %v", call, h.f.calls) + } + } + + t.Run("stale holder is fenced", func(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.acquire(1) + old := h.lane + h.f.on("PullRequest", 2, func() { h.restart() }) + _, err := old.Advance(context.Background(), testRepo, testBranch, 1, h.authorize) + if !errors.Is(err, ErrFenced) { + t.Fatalf("err = %v, want ErrFenced", err) + } + if len(h.f.merges) != 0 { + t.Fatalf("a fenced validation must not merge, got %v", h.f.merges) + } + }) + + t.Run("release during a round is fenced", func(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.f.setRun("h1", "build", "in_progress", "") + h.acquire(1) + h.f.on("PullRequest", 1, func() { + if err := h.lane.Release(testRepo, testBranch, 1, "no longer eligible"); err != nil { + t.Fatalf("Release: %v", err) + } + }) + _, err := h.lane.Advance(context.Background(), testRepo, testBranch, 1, h.authorize) + if !errors.Is(err, ErrFenced) { + t.Fatalf("err = %v, want ErrFenced", err) + } + }) + + t.Run("leave after release is fenced", func(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.f.setRun("h1", "build", "completed", "failure") + h.acquire(1) + h.f.on("PullRequest", 1, func() { _ = h.lane.Release(testRepo, testBranch, 1, "gone") }) + d, err := h.lane.Advance(context.Background(), testRepo, testBranch, 1, h.authorize) + if !errors.Is(err, ErrFenced) || d.Outcome != OutcomeLeft { + t.Fatalf("d=%+v err=%v, want a fenced leave", d, err) + } + }) +} + +// AC23, R13: a commit landing between the final re-check and the merge is +// detected after the merge and alerted on, naming the merged PR and the +// unexpected commit and its PR. +func TestMergeOnUnexpectedTipRaisesAlert(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.acquire(1) + h.f.on("Merge", 1, func() { + h.f.tip = "x1" + h.f.commitPRs["x1"] = []int{77} + }) + d := h.advance(1) + expectOutcome(t, d, OutcomeMerged, "") + if len(h.alerts) != 1 { + t.Fatalf("alerts = %+v, want one", h.alerts) + } + a := h.alerts[0] + if a.PR != 1 || a.UnexpectedCommit != "x1" || fmt.Sprint(a.UnexpectedPRs) != "[77]" || a.ValidatedTip != "t0" || a.MergeSHA != d.MergeSHA { + t.Fatalf("alert = %+v", a) + } + if !strings.Contains(a.Reason, "PR #1") || !strings.Contains(a.Reason, "x1") || !strings.Contains(a.Reason, "PR #77") { + t.Fatalf("alert reason = %q", a.Reason) + } + if len(h.eventsFor(ActionAlert)) != 1 { + t.Fatalf("alert not audited: %+v", h.events) + } + + for _, tc := range []struct { + name string + apply func(f *fakeGH) + want string + }{ + {"parents unreadable", func(f *fakeGH) { f.errs["CommitParents"] = errBoom }, "could not verify"}, + {"no parent", func(f *fakeGH) { + f.on("CommitParents", 1, func() { f.parents = map[string][]string{} }) + }, "has no parent commit"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + h.f.addPR(1, "h1") + h.acquire(1) + tc.apply(h.f) + expectOutcome(t, h.advance(1), OutcomeMerged, "") + if len(h.alerts) != 1 || !strings.Contains(h.alerts[0].Reason, tc.want) { + t.Fatalf("alerts = %+v, want %q", h.alerts, tc.want) + } + }) + } +} diff --git a/src/pkg/mergelane/store.go b/src/pkg/mergelane/store.go new file mode 100644 index 0000000000..2352d41f05 --- /dev/null +++ b/src/pkg/mergelane/store.go @@ -0,0 +1,264 @@ +package mergelane + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" + "time" +) + +// DefaultStorePath is where the lane's front records live: the persistent data +// area that already holds the convergence ledger, so a restart reads them back. +const DefaultStorePath = "/data/mergelane/fronts.json" + +const ( + storeFileMode = 0o660 + storeFormatVersion = 1 + storeLockSuffix = ".lock" +) + +// Front stages, as shown in the lane record. +const ( + StageValidating = "validating" + StageUpdating = "updating" + StageWaitingChecks = "waiting-checks" + StageMerging = "merging" +) + +// ErrFenced means the front record changed under a caller (another process, +// a restart, a timeout or a release): the caller's validation is void. +var ErrFenced = errors.New("mergelane: front record changed; validation is fenced") + +// Front is the durable record of the one pull request at the front of a lane. +type Front struct { + PR int `json:"pr"` + Path string `json:"path,omitempty"` + Epoch uint64 `json:"epoch"` + Stage string `json:"stage"` + // EnteredAt is when the PR reached the front; DeadlineAt is the front + // timeout, restarted on every branch update. + EnteredAt time.Time `json:"entered_at"` + DeadlineAt time.Time `json:"deadline_at"` + // EvaluatedHead is the head the current validation is evaluating. It is + // cleared on restart so a validation is always repeated in full. + EvaluatedHead string `json:"evaluated_head,omitempty"` + // PinnedHead is the head a branch update was pinned to; until the PR's + // head moves off it the update has not landed. + PinnedHead string `json:"pinned_head,omitempty"` + // LastReason is the last no-merge reason audited for this front, so a + // round that waits for the same reason is not audited again. + LastReason string `json:"last_reason,omitempty"` +} + +// Waiter is an eligible pull request behind the front. +type Waiter struct { + PR int `json:"pr"` + Path string `json:"path,omitempty"` + EligibleAt time.Time `json:"eligible_at"` + LastSeen time.Time `json:"last_seen"` +} + +// Exit is the last departure from the front. +type Exit struct { + PR int `json:"pr"` + Reason string `json:"reason"` + At time.Time `json:"at"` +} + +// Record is one (repo, target branch) lane. +type Record struct { + Repo string `json:"repo"` + Branch string `json:"branch"` + Front *Front `json:"front,omitempty"` + // Waiting is ordered oldest-eligible first and never holds the front PR. + Waiting []Waiter `json:"waiting,omitempty"` + LastExit *Exit `json:"last_exit,omitempty"` + // Epoch increases on every front entry and restart and is never reused; + // it fences a holder whose front was taken away. + Epoch uint64 `json:"epoch"` +} + +type persistedStore struct { + Version int `json:"version"` + Lanes []Record `json:"lanes"` +} + +// Store is the durable lane record: one JSON file, reloaded under an +// exclusive file lock on every transaction and rewritten atomically, the same +// idiom as the convergence claim ledger. Process memory is never authority. +type Store struct { + path string + mu sync.Mutex + lanes map[string]*Record +} + +// OpenStore loads the lane record at path, creating an empty one when the file +// does not exist. A file that exists but cannot be parsed is refused and left +// untouched for inspection; the lane then cannot run, which fails closed. +func OpenStore(path string) (*Store, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("mergelane: store path is required") + } + s := &Store{path: path} + if err := s.reloadLocked(); err != nil { + return nil, err + } + return s, nil +} + +func laneKey(repo, branch string) string { + return strings.ToLower(strings.TrimSpace(repo)) + "@" + strings.TrimSpace(branch) +} + +func (s *Store) reloadLocked() error { + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + s.lanes = map[string]*Record{} + return nil + } + if err != nil { + return fmt.Errorf("reading lane record %s: %w", s.path, err) + } + var p persistedStore + if err := json.Unmarshal(data, &p); err != nil { + return fmt.Errorf("lane record %s is unparseable and is left untouched for inspection: %w", s.path, err) + } + lanes := make(map[string]*Record, len(p.Lanes)) + for i := range p.Lanes { + rec := p.Lanes[i] + key := laneKey(rec.Repo, rec.Branch) + if _, dup := lanes[key]; dup { + return fmt.Errorf("lane record %s holds conflicting entries for %s", s.path, key) + } + lanes[key] = &rec + } + s.lanes = lanes + return nil +} + +func (s *Store) persistLocked() error { + all := make([]Record, 0, len(s.lanes)) + for _, rec := range s.lanes { + all = append(all, *rec) + } + sort.Slice(all, func(i, j int) bool { + return laneKey(all[i].Repo, all[i].Branch) < laneKey(all[j].Repo, all[j].Branch) + }) + data, err := json.MarshalIndent(persistedStore{Version: storeFormatVersion, Lanes: all}, "", " ") + if err != nil { + return fmt.Errorf("marshaling lane record: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(s.path), filepath.Base(s.path)+".*.tmp") + if err != nil { + return fmt.Errorf("writing tmp lane record: %w", err) + } + tmpPath := tmp.Name() + _, werr := tmp.Write(data) + if werr == nil { + werr = tmp.Chmod(storeFileMode) + } + if werr == nil { + werr = tmp.Sync() + } + if cerr := tmp.Close(); werr == nil { + werr = cerr + } + if werr == nil { + werr = os.Rename(tmpPath, s.path) + } + if werr != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("writing lane record %s: %w", s.path, werr) + } + if dir, err := os.Open(filepath.Dir(s.path)); err == nil { + _ = dir.Sync() + _ = dir.Close() + } + return nil +} + +// update runs fn over a freshly reloaded record under the in-process mutex and +// the cross-process file lock, persisting only when fn succeeds. +func (s *Store) update(fn func(lanes map[string]*Record) error) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := os.MkdirAll(filepath.Dir(s.path), 0o770); err != nil { + return fmt.Errorf("creating lane record directory: %w", err) + } + f, err := os.OpenFile(s.path+storeLockSuffix, os.O_CREATE|os.O_RDWR, storeFileMode) + if err != nil { + return fmt.Errorf("opening lane record lock: %w", err) + } + defer func() { _ = f.Close() }() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + return fmt.Errorf("locking lane record: %w", err) + } + defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() + if err := s.reloadLocked(); err != nil { + return err + } + if err := fn(s.lanes); err != nil { + return err + } + return s.persistLocked() +} + +// lane returns the record for (repo, branch), creating it on first use. +func lane(lanes map[string]*Record, repo, branch string) *Record { + key := laneKey(repo, branch) + rec, ok := lanes[key] + if !ok { + rec = &Record{Repo: strings.TrimSpace(repo), Branch: strings.TrimSpace(branch)} + lanes[key] = rec + } + return rec +} + +// Snapshot returns a copy of the (repo, branch) lane as last persisted. +func (s *Store) Snapshot(repo, branch string) (Record, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.reloadLocked(); err != nil { + return Record{}, false, err + } + rec, ok := s.lanes[laneKey(repo, branch)] + if !ok { + return Record{}, false, nil + } + out := *rec + if rec.Front != nil { + f := *rec.Front + out.Front = &f + } + out.Waiting = append([]Waiter(nil), rec.Waiting...) + if rec.LastExit != nil { + e := *rec.LastExit + out.LastExit = &e + } + return out, true, nil +} + +func (r *Record) removeWaiter(pr int) { + kept := r.Waiting[:0] + for _, w := range r.Waiting { + if w.PR != pr { + kept = append(kept, w) + } + } + r.Waiting = kept +} + +func (r *Record) waiterIndex(pr int) int { + for i, w := range r.Waiting { + if w.PR == pr { + return i + } + } + return -1 +} From e01ca19b58db02f0c6915767941aa7e22a03ceea Mon Sep 17 00:00:00 2001 From: scanner Date: Tue, 6 Oct 2026 14:18:57 -0400 Subject: [PATCH 2/2] fix(mergelane): validate merge_strategy without agents and refresh api-reference citations TestInvalidMergeStrategyFailsValidationAndStopsAutoMerge built an agentless config, so Validate() stopped at 'at least one agent must be configured' before reaching the merge_strategy check. Use ValidateWithOptions with RequireAgents unset, matching the other agentless config tests. Also rewrite the 102 api-reference.md source citations drifted by the pkg/dashboard/api.go route additions (check-api-reference-citations --fix). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: scanner --- src/docs/api-reference.md | 204 ++++++++++----------- src/pkg/config/repo_merge_strategy_test.go | 6 +- 2 files changed, 106 insertions(+), 104 deletions(-) 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..051621d40b 100644 --- a/src/pkg/config/repo_merge_strategy_test.go +++ b/src/pkg/config/repo_merge_strategy_test.go @@ -76,9 +76,11 @@ func TestInvalidMergeStrategyFailsValidationAndStopsAutoMerge(t *testing.T) { cfg.ACMMLevel = &level cfg.Project.RepoPolicies = []RepoPolicy{{Repo: "console", MergeStrategy: "hive-serialised"}} - err := cfg.Validate() + // No agents on purpose: RequireAgents=false so the merge_strategy check is + // the first thing that can fail. + err := cfg.ValidateWithOptions(ValidateOptions{}) if err == nil || !strings.Contains(err.Error(), "console") || !strings.Contains(err.Error(), "hive-serialised") { - t.Fatalf("Validate() = %v, want error naming repo and value", err) + t.Fatalf("ValidateWithOptions() = %v, want error naming repo and value", err) } if cfg.RepoAutoMergeEnabled("console") { t.Error("auto-merge must be off for a repo with an invalid merge_strategy")