Compare commits

..

1 Commits

Author SHA1 Message Date
core-devops 0b771d5770 feat(workspace-server): approval-gate infrastructure for destructive ops (Phase 4)
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Successful in 3s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 7s
Check migration collisions / Migration version collision check (pull_request) Successful in 18s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
qa-review / approved (pull_request_target) Failing after 4s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request_target) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E Chat / E2E Chat (pull_request) Successful in 2s
security-review / approved (pull_request_target) Failing after 9s
CI / Canvas Deploy Status (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 55s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 58s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m26s
CI / Platform (Go) (pull_request) Successful in 4m9s
CI / all-required (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 6s
sop-tier-check / tier-check (pull_request_target) Failing after 7s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Has been cancelled
qa-review / approved (pull_request_review) Has been skipped
security-review / approved (pull_request_review) Has been skipped
sop-tier-check / tier-check (pull_request_review) Failing after 11s
audit-force-merge / audit (pull_request_target) Successful in 21s
Server-side gate so destructive org operations the user-driven platform agent can
trigger require a human approval (RFC docs/design/rfc-platform-agent.md). The
platform MCP is a CLIENT of these handlers, so enforcement lives here (the trust
boundary), not in the MCP.

- migration 20260606020000_approvals_consumed: approval_requests.consumed_at
  (single-use) + request_hash (dedup) + a partial index for the gate lookup.
- internal/approvals/policy.go: the one auditable map of gated actions
  (delete_workspace / deprovision / secret_write / org_token_mint) + IsGated.
- requireApproval(): consumes a matching approved+unconsumed request (race-safe
  via conditional UPDATE ... RETURNING / FOR UPDATE SKIP LOCKED) and proceeds,
  else creates/reuses a pending request (dedup by request_hash), broadcasts it to
  the canvas and escalates to the parent if any. gateDestructive() wraps it and
  writes HTTP 202 pending for gin handlers.

Matching is (workspace_id, action, request_hash) where request_hash is a stable
digest of the op + context, so an approval for 'delete ws A' can't be replayed to
'delete ws B', and retries reuse one pending row instead of flooding.

Tests: policy + hash-stability/context-sensitivity unit tests; gateDestructive
non-gated passthrough; and a real-Postgres integration test proving the full
cycle — pending -> dedup -> approve -> consume -> single-use (no replay) ->
context isolation (sqlmock cannot prove consume-once row state).

Infrastructure only — NOT yet wired into live handlers. Wiring requires a
platform-agent caller marker so the gate fires only for concierge-initiated calls
(not operator/CP flows); that lands with Phase 3's runtime/MCP marker so existing
delete/secret flows are unchanged until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:43:25 -07:00
16 changed files with 625 additions and 1067 deletions
+90 -325
View File
@@ -4,11 +4,7 @@
Gitea 1.22.6+ has auto-merge (`pull_auto_merge`) but no GitHub-style merge
queue. This script provides the missing serialized policy in user space:
1. Scan open same-repo PRs that are NOT opted out (auto-discovery, see below),
oldest-first, skipping drafts, until an ACTIONABLE one is found. A non-ready
candidate (REQUEST_CHANGES, mergeable!=True, insufficient genuine approvals,
or red required CI) is SKIPPED so it cannot head-of-line block newer ready
PRs; the scan continues to the next candidate.
1. Pick the oldest open PR carrying QUEUE_LABEL (skipping HOLD_LABEL).
2. Refuse to act unless main's BP-required contexts are green.
3. Refuse fork PRs; the queue may only mutate same-repo branches.
4. If the PR branch does not contain current main, call Gitea's
@@ -33,42 +29,13 @@ Authoritative gates (fail-closed):
approvals present). It is NEVER used to bypass a failing REQUIRED context
or missing approvals.
Auto-discovery (opt-OUT, label-optional):
The queue is SELF-SUSTAINING — a ready PR does NOT need a human (or an agent)
to add the `merge-queue` label first. When AUTO_DISCOVER is on (default), the
queue enumerates ALL open same-repo PRs and considers any that meets the full
merge bar (genuine approvals on current head + BP-required green + mergeable +
no open REQUEST_CHANGES). The merge bar above is UNCHANGED; auto-discovery only
changes WHICH PRs are considered, not whether they are mergeable.
This deliberately removes the historical dependency on an agent adding the
`merge-queue` label — agent Gitea tokens lack `write:issue` (labels are
issue-scoped), so they could never self-label and the queue stalled. The label
is now OPTIONAL metadata, not a gate.
SAFETY is preserved as opt-OUT: any PR carrying an opt-out label
(OPT_OUT_LABELS — `merge-queue-hold`, `do-not-auto-merge`, `wip`, `draft` by
default) is skipped (never auto-considered, never merged). Draft PRs
(draft=true STATE) are also skipped; the literal `draft` LABEL is an
additional explicit opt-out a human can apply without converting to a draft.
A human who wants to keep a PR out of autonomous merging just adds one of
those labels. Setting AUTO_DISCOVER=0 restores the legacy opt-IN behaviour
(only PRs already carrying QUEUE_LABEL are considered).
Head-of-line (HOL) safety has two complementary layers:
(a) The queue SCANS THROUGH the FIFO candidate list and skips any non-ready
PR (REQUEST_CHANGES, mergeable!=True, insufficient genuine approvals, or
red required CI) instead of locking on the oldest and waiting, so a PR
that can never become ready without human action does not block newer
ready PRs.
(b) For the candidate the scan acts on, two permanent failure modes HOLD the
PR (apply HOLD_LABEL) and let the scan CONTINUE to the next candidate
rather than re-selecting the same wedged PR every tick:
- a permanent permission/4xx merge error (403/404/405), and
- a persistent branch-update conflict (the /update endpoint returns
HTTP 409 because the PR branch cannot be merged with main without a
manual rebase). A conflict will not self-resolve, so retrying it
every tick would HOL-block every ready PR behind it (issue #2352).
Head-of-line (HOL) safety: a permanent permission/4xx merge error
(403/404/405) HOLDS the PR (applies HOLD_LABEL) so the queue advances to the
next PR instead of re-selecting the same wedged PR every tick. Likewise, a
persistent branch-update conflict (the /update endpoint returns HTTP 409
because the PR branch cannot be merged with main without manual rebase) HOLDS
the PR — a conflict will not self-resolve, so retrying it every tick would
HOL-block every ready PR behind it (issue #2352).
Status-fetch is fail-closed: if the combined status for a sha cannot be
fetched, the PR is skipped this tick (never treated as green).
@@ -101,33 +68,6 @@ WATCH_BRANCH = _env("WATCH_BRANCH", default="main")
QUEUE_LABEL = _env("QUEUE_LABEL", default="merge-queue")
HOLD_LABEL = _env("HOLD_LABEL", default="merge-queue-hold")
UPDATE_STYLE = _env("UPDATE_STYLE", default="merge")
# Auto-discovery (opt-OUT). When truthy (default), the queue considers ALL open
# same-repo PRs that meet the merge bar, not only PRs already carrying
# QUEUE_LABEL — so the queue is self-sustaining without any human/agent labeling
# (agent tokens lack write:issue and cannot self-label). Set AUTO_DISCOVER=0 to
# restore the legacy opt-IN behaviour (QUEUE_LABEL required to be considered).
AUTO_DISCOVER = _env("AUTO_DISCOVER", default="1").strip().lower() not in {
"0",
"false",
"no",
"off",
"",
}
# Opt-OUT labels. A PR carrying ANY of these is skipped (never auto-considered,
# never merged) — the human escape hatch from autonomous merging. HOLD_LABEL is
# always included so the existing hold semantics keep working. `do-not-auto-merge`
# and `wip` let a human keep a PR out of the auto-merge path without removing it.
# `draft` is included as a literal label too: Gitea draft STATE (draft=true) is
# already skipped via _issue_is_draft, but a "draft" LABEL is an additional,
# explicit opt-out signal a human can apply without converting the PR to a draft.
OPT_OUT_LABELS = {
name.strip()
for name in _env(
"OPT_OUT_LABELS",
default="do-not-auto-merge,wip,draft",
).split(",")
if name.strip()
} | ({HOLD_LABEL} if HOLD_LABEL else set())
REQUIRED_CONTEXTS_RAW = _env(
"REQUIRED_CONTEXTS",
default=(
@@ -470,85 +410,6 @@ def choose_next_queued_issue(
return candidates[0] if candidates else None
def _issue_is_draft(issue: dict) -> bool:
"""True if the issue/PR is a draft.
The /issues listing exposes draft state under the `pull_request` sub-object
(`{"draft": true}`); some Gitea versions also surface a top-level `draft`.
Either is honoured. Drafts are never auto-considered for merging.
"""
pr = issue.get("pull_request")
if isinstance(pr, dict) and pr.get("draft") is True:
return True
return issue.get("draft") is True
def choose_candidate_issues(
issues: list[dict],
*,
queue_label: str,
opt_out_labels: set[str],
auto_discover: bool,
) -> list[dict]:
"""All open PRs eligible for a merge attempt this tick, oldest-first.
This is the auto-discovery selector. It does NOT change the merge bar — it
only changes WHICH PRs are considered:
- auto_discover=True (default): every open same-repo PR is a candidate,
EXCEPT those carrying an opt-out label or marked draft. The QUEUE_LABEL
is optional metadata, not a gate, so a ready PR reaches the queue with no
human/agent labeling (the write:issue gap is removed).
- auto_discover=False: legacy opt-IN — only PRs carrying queue_label are
candidates (still skipping opt-out labels and drafts).
Opt-out is the safety escape hatch: any opt_out_labels member present skips
the PR entirely (never considered, never merged). Ordering is oldest-first
(created_at, then number) to preserve the serialized FIFO ordering.
Returns the FULL ordered list (not just the head) so process_once can SCAN
THROUGH non-ready candidates instead of locking on the oldest. A non-ready
auto-discovered PR (e.g. one with REQUEST_CHANGES or mergeable=false, which
can never become ready without human action) must NOT head-of-line block the
newer ready PRs behind it — the readiness check happens per-candidate in
process_once, and a `wait` candidate is skipped to the next one.
"""
candidates = []
for issue in issues:
if "pull_request" not in issue:
continue
labels = label_names(issue)
if opt_out_labels & labels:
continue # opt-out: human kept this PR out of autonomous merging
if _issue_is_draft(issue):
continue # drafts are never auto-merged
if not auto_discover and queue_label not in labels:
continue # legacy opt-IN: require the queue label
candidates.append(issue)
candidates.sort(key=lambda issue: (issue.get("created_at") or "", int(issue["number"])))
return candidates
def choose_next_candidate_issue(
issues: list[dict],
*,
queue_label: str,
opt_out_labels: set[str],
auto_discover: bool,
) -> dict | None:
"""The oldest eligible candidate, or None. Thin head-of-list wrapper around
choose_candidate_issues; retained for callers/tests that only want the head.
process_once uses the full list (choose_candidate_issues) so it can scan past
non-ready PRs rather than HOL-block on the oldest."""
candidates = choose_candidate_issues(
issues,
queue_label=queue_label,
opt_out_labels=opt_out_labels,
auto_discover=auto_discover,
)
return candidates[0] if candidates else None
def pr_contains_base_sha(commits: list[dict], base_sha: str) -> bool:
for commit in commits:
sha = commit.get("sha") or commit.get("id")
@@ -716,31 +577,6 @@ def list_queued_issues() -> list[dict]:
return body
def list_candidate_issues(*, auto_discover: bool) -> list[dict]:
"""Open PR issues eligible for consideration this tick.
With auto_discover=True (default) this enumerates ALL open PRs (no label
filter) so the queue is self-sustaining — a ready PR is considered without
any human/agent first adding QUEUE_LABEL. With auto_discover=False it falls
back to the legacy label-filtered listing (opt-IN). Opt-out filtering and
draft-skipping happen in choose_next_candidate_issue, not here.
"""
if not auto_discover:
return list_queued_issues()
_, body = api(
"GET",
f"/repos/{OWNER}/{NAME}/issues",
query={
"state": "open",
"type": "pulls",
"limit": "50",
},
)
if not isinstance(body, list):
raise ApiError("candidate issues response not list")
return body
def get_pull(pr_number: int) -> dict:
_, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}")
if not isinstance(body, dict):
@@ -895,181 +731,45 @@ def process_once(*, dry_run: bool = False) -> int:
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green: {', '.join(main_bad)}")
return 0
candidates = choose_candidate_issues(
list_candidate_issues(auto_discover=AUTO_DISCOVER),
issue = choose_next_queued_issue(
list_queued_issues(),
queue_label=QUEUE_LABEL,
opt_out_labels=OPT_OUT_LABELS,
auto_discover=AUTO_DISCOVER,
hold_label=HOLD_LABEL,
)
if not candidates:
print(
"::notice::no merge candidates "
f"(auto_discover={'on' if AUTO_DISCOVER else 'off'})"
)
if not issue:
print("::notice::merge queue empty")
return 0
# HOL fix: SCAN THROUGH the FIFO candidate list until a PR we can ACT on is
# found, instead of locking on the oldest and waiting. A non-ready candidate
# (decision.action == "wait": REQUEST_CHANGES, mergeable!=True, insufficient
# genuine approvals, or red required CI) is SKIPPED — it must NOT head-of-line
# block the newer ready PRs behind it. The merge bar is unchanged: a skipped
# PR is never merged, and the first ACTIONABLE candidate (an "update" that
# advances a stale branch, or a fully-ready "merge") terminates the scan.
#
# `update` is treated as actionable, not skippable: a PR whose head merely
# lacks current main is in a legitimate in-progress state (updating it +
# rerunning CI moves it toward ready), unlike a PR that can never become
# ready without a human (RC / conflict), which is a `wait` and gets skipped.
for issue in candidates:
decision, ctx = _evaluate_candidate(
issue,
main_sha=main_sha,
main_status=main_status,
required_contexts=contexts,
required_approvals=required_approvals,
dry_run=dry_run,
)
if decision is None:
continue # not merge-eligible (not-open / opted-out / fork / wrong base)
pr_number = ctx["pr_number"]
print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}")
if decision.action == "wait":
# Non-ready: skip to the next candidate (no HOL block, no merge).
continue
if decision.action == "update":
try:
update_pull(pr_number, dry_run=dry_run)
except BranchUpdateConflictError as exc:
# The branch cannot be updated with main because of a real
# conflict (HTTP 409 from /update). This is the #2352 HOL guard:
# a conflict will not self-resolve without a human/agent rebase,
# so re-attempting the update every tick would head-of-line block
# every ready PR behind it. HOLD this PR (apply HOLD_LABEL, which
# is an opt-out label so later ticks skip it) and CONTINUE the
# scan so a newer ready PR can still merge this tick. Fail-closed:
# a held PR is skipped, never merged.
sys.stderr.write(
f"::error::branch-update conflict for PR #{pr_number}: {exc}\n"
)
hold_note = (
"merge-queue: could not update this branch with "
f"`{WATCH_BRANCH}` — the update returned a merge conflict "
f"(HTTP 409) that the queue cannot auto-resolve ({exc}). "
f"Applied `{HOLD_LABEL}` to unblock the queue (HOL guard). "
f"Fix: rebase/merge `{WATCH_BRANCH}` into this branch and "
f"resolve the conflicts, then remove `{HOLD_LABEL}` to requeue."
)
hold_pr(pr_number, hold_note, dry_run=dry_run)
continue # held — keep scanning for a mergeable candidate
post_comment(
pr_number,
(
f"merge-queue: updated this branch with `{WATCH_BRANCH}` at "
f"`{main_sha[:12]}`. Waiting for CI on the refreshed head."
),
dry_run=dry_run,
)
return 0
if decision.ready:
latest_main_sha = get_branch_head(WATCH_BRANCH)
if latest_main_sha != main_sha:
print(
f"::notice::main moved {main_sha[:8]} -> {latest_main_sha[:8]}; "
"deferring to next tick"
)
return 0
try:
merge_pull(pr_number, dry_run=dry_run, force=decision.force)
except MergePermissionError as exc:
# Permanent merge failure (HTTP 403/404/405). HOLD this PR by
# applying HOLD_LABEL (it becomes an opt-out label, so subsequent
# ticks skip it) and CONTINUE scanning so the queue still advances
# to the next ready PR this tick rather than stalling.
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
hold_note = (
"merge-queue: merge failed with a permanent permission error "
f"({exc}). No available token has Can-merge permission for this "
f"PR. Applied `{HOLD_LABEL}` to unblock the queue (HOL guard). "
f"Fix: grant Can-merge to the queue token, then remove "
f"`{HOLD_LABEL}` to requeue."
)
try:
add_label_by_name(pr_number, HOLD_LABEL, dry_run=dry_run)
except ApiError as label_exc:
# If we cannot even apply the hold label, fall back to a comment
# so the wedge is at least visible; do NOT loop on this PR.
sys.stderr.write(
f"::error::could not apply HOLD_LABEL to PR #{pr_number}: {label_exc}\n"
)
hold_note += (
f"\n\n(NOTE: could not apply the hold label automatically: "
f"{label_exc}. Please add `{HOLD_LABEL}` manually.)"
)
post_comment(pr_number, hold_note, dry_run=dry_run)
continue # held — keep scanning for a mergeable candidate
return 0
return 0
def _evaluate_candidate(
issue: dict,
*,
main_sha: str,
main_status: dict,
required_contexts: list[str],
required_approvals: int,
dry_run: bool,
) -> tuple[MergeDecision | None, dict]:
"""Evaluate a single auto-discovered candidate against the full merge bar.
Returns (decision, ctx) where ctx carries {"pr_number"}. A None decision
means the PR is not merge-eligible at all (not open / opted-out / draft /
fork / wrong base) and the caller should skip to the next candidate; for
fork / wrong-base the explanatory comment is posted here before returning.
The merge bar is UNCHANGED from the single-PR path — this only factors the
per-PR evaluation out so process_once can scan multiple candidates. A failed
status fetch still raises (fail-closed): it propagates to the caller so the
PR is never treated as green.
"""
pr_number = int(issue["number"])
ctx = {"pr_number": pr_number}
pr = get_pull(pr_number)
if pr.get("state") != "open":
print(f"::notice::PR #{pr_number} is not open; skipping")
return None, ctx
# Defensive opt-out/draft re-check on the authoritative pull payload: the
# /issues listing's label/draft view can lag, but the merge bar must respect
# the live pull state. (choose_candidate_issues already filtered on the
# listing; this guards against a stale listing racing a just-added opt-out.)
if OPT_OUT_LABELS & label_names(pr):
print(f"::notice::PR #{pr_number} carries an opt-out label; skipping")
return None, ctx
if pr.get("draft") is True:
print(f"::notice::PR #{pr_number} is a draft; skipping")
return None, ctx
return 0
if pr.get("base", {}).get("ref") != WATCH_BRANCH:
post_comment(pr_number, f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.", dry_run=dry_run)
return None, ctx
return 0
if pr.get("head", {}).get("repo_id") != pr.get("base", {}).get("repo_id"):
post_comment(pr_number, "merge-queue: skipped; fork PRs are not supported by the serialized queue.", dry_run=dry_run)
return None, ctx
return 0
head_sha = pr.get("head", {}).get("sha")
if not isinstance(head_sha, str) or len(head_sha) < 7:
raise ApiError(f"PR #{pr_number} missing head sha")
commits = get_pull_commits(pr_number)
current_base = pr_has_current_base(pr, commits, main_sha)
# Fail-closed: a failed status fetch raises here and propagates (the PR is
# never treated as green).
# Fail-closed: a failed status fetch raises here and the tick is skipped
# (the PR is never treated as green).
pr_status = get_combined_status(head_sha)
pr_labels = label_names(pr)
# FAIL-CLOSED: Gitea returns mergeable=None (or omits the field) while it is
# still COMPUTING conflict state. Only the literal True is decisive proof the
# PR is conflict-free; None and False both mean "not (yet) mergeable". We must
# NOT autonomously merge on an unknown — treat anything but True as not-yet-
# mergeable so evaluate_merge_readiness returns a "wait" decision.
mergeable = pr.get("mergeable") is True
# mergeable so evaluate_merge_readiness returns a transient "wait" decision.
# This is transient: process_once returns 0 (no hold label, no dequeue) and
# the PR is re-checked next tick once Gitea has finished computing mergeability.
mergeable_field = pr.get("mergeable")
mergeable = mergeable_field is True
reviews = get_pull_reviews(pr_number)
approvers, request_changes = genuine_approvals(
@@ -1079,7 +779,7 @@ def _evaluate_candidate(
decision = evaluate_merge_readiness(
main_status=main_status,
pr_status=pr_status,
required_contexts=required_contexts,
required_contexts=contexts,
required_approvals=required_approvals,
approvers=approvers,
request_changes=request_changes,
@@ -1087,7 +787,72 @@ def _evaluate_candidate(
mergeable=mergeable,
pr_labels=pr_labels,
)
return decision, ctx
print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}")
if decision.action == "update":
try:
update_pull(pr_number, dry_run=dry_run)
except BranchUpdateConflictError as exc:
# The branch cannot be updated with main because of a real conflict
# (HTTP 409). This is the HOL fix for issue #2352: previously the
# 409 propagated to main() and the tick exited 0 with the PR still
# queued, so the NEXT tick re-selected the SAME conflicted PR and
# retried the failing update forever — head-of-line-blocking every
# ready PR behind it. A conflict will not self-resolve; it needs a
# human/agent rebase. So HOLD this PR (HOL guard) and advance to the
# next candidate. Fail-closed: a held PR is skipped, never merged.
sys.stderr.write(
f"::error::branch-update conflict for PR #{pr_number}: {exc}\n"
)
hold_note = (
"merge-queue: could not update this branch with "
f"`{WATCH_BRANCH}` — the update returned a merge conflict "
f"(HTTP 409) that the queue cannot auto-resolve ({exc}). "
f"Applied `{HOLD_LABEL}` to unblock the queue (HOL guard). "
f"Fix: rebase/merge `{WATCH_BRANCH}` into this branch and "
f"resolve the conflicts, then remove `{HOLD_LABEL}` to requeue."
)
hold_pr(pr_number, hold_note, dry_run=dry_run)
return 0
post_comment(
pr_number,
(
f"merge-queue: updated this branch with `{WATCH_BRANCH}` at "
f"`{main_sha[:12]}`. Waiting for CI on the refreshed head."
),
dry_run=dry_run,
)
return 0
if decision.ready:
latest_main_sha = get_branch_head(WATCH_BRANCH)
if latest_main_sha != main_sha:
print(
f"::notice::main moved {main_sha[:8]} -> {latest_main_sha[:8]}; "
"deferring to next tick"
)
return 0
try:
merge_pull(pr_number, dry_run=dry_run, force=decision.force)
except MergePermissionError as exc:
# Permanent merge failure (HTTP 403/404/405). This is the
# head-of-line (HOL) bug fix: previously we returned 0 with the PR
# still queued, so the next tick re-selected the SAME wedged PR
# forever and the queue never advanced. Instead, HOLD this PR by
# applying HOLD_LABEL (choose_next_queued_issue skips held PRs), so
# the queue moves on to the next candidate. A maintainer removes
# the hold once the permission issue is fixed.
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
hold_note = (
"merge-queue: merge failed with a permanent permission error "
f"({exc}). No available token has Can-merge permission for this "
f"PR. Applied `{HOLD_LABEL}` to unblock the queue (HOL guard). "
f"Fix: grant Can-merge to the queue token, then remove "
f"`{HOLD_LABEL}` to requeue."
)
hold_pr(pr_number, hold_note, dry_run=dry_run)
return 0
return 0
return 0
def main() -> int:
+53 -14
View File
@@ -197,15 +197,19 @@ if [ "$HTTP_CODE" != "200" ]; then
exit 1
fi
# Filter: state=APPROVED, official=true, not-dismissed, non-author,
# commit_id matches current PR head. All conditions are mandatory.
# Filter: state=APPROVED, not-dismissed, non-author. Optionally strict-mode
# adds commit_id==head.sha (off by default; see header).
JQ_FILTER='.[]
| select(.state == "APPROVED")
| select(.official == true)
| select(.dismissed != true)
| select(.user.login != $author)
| select(.commit_id == $head)
| .user.login'
| select(.official != false)
| select(.user.login != $author)'
if [ "${REVIEW_CHECK_STRICT:-}" = "1" ]; then
JQ_FILTER="${JQ_FILTER}
| select(.commit_id == \$head)"
fi
JQ_FILTER="${JQ_FILTER}
| .user.login"
REVIEW_CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILTER" "$REVIEWS_JSON" | sort -u)
debug "candidate non-author approvers: $(echo "$REVIEW_CANDIDATES" | tr '\n' ' ')"
@@ -237,14 +241,49 @@ if [ -z "$REVIEW_CANDIDATES" ]; then
fi
# --- COMMENT APPROVAL REMOVED (security hardening) ---
# Previous versions accepted issue comments containing generic approval
# keywords (APPROVED/LGTM/ACCEPTED) or agent prefixes ([core-qa-agent],
# [core-security-agent]) as satisfying the gate. Both paths are bypasses:
# a comment lacks the audit trail, dismissal, stale-review invalidation,
# and commit_id binding that an official Gitea review provides.
# Only APPROVED reviews from the Gitea reviews API count.
CANDIDATES="$REVIEW_CANDIDATES"
# --- Fallback/extension (internal#348): check issue comments for agent-approval ---
# core-qa-agent and core-security-agent can approve via issue comments. Always
# include comment candidates, even if the reviews API returned approvals for a
# different team; team membership below is the authoritative filter.
COMMENT_CANDIDATES=""
AGENT_PATTERN=""
case "$TEAM" in
qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;;
security) AGENT_PATTERN="\\[core-security-agent\\]" ;;
esac
HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments")
debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}"
if [ "$HTTP_CODE" = "200" ]; then
# JQ expression: select non-author comments that match either the
# agent-prefix pattern (case-insensitive) OR a generic approval keyword.
JQ_APPROVALS='
.[] |
select(.user.login != $author) |
. as $cmt |
if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then
$cmt.user.login
elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then
$cmt.user.login
else
empty
end
'
COMMENT_CANDIDATES=$(jq -r \
--arg author "$PR_AUTHOR" \
--arg agent_pattern "$AGENT_PATTERN" \
"$JQ_APPROVALS" \
"$COMMENTS_JSON" 2>/dev/null | sort -u)
debug "comment-based approval candidates: $(echo "$COMMENT_CANDIDATES" | tr '\n' ' ')"
if [ -n "$COMMENT_CANDIDATES" ]; then
echo "::notice::${TEAM}-review: found $(echo "$COMMENT_CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..."
fi
else
debug "could not fetch issue comments (HTTP ${HTTP_CODE})"
fi
CANDIDATES=$(printf '%s\n%s\n' "$REVIEW_CANDIDATES" "$COMMENT_CANDIDATES" | sed '/^$/d' | sort -u)
if [ -z "${CANDIDATES:-}" ]; then
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates from reviews API or issue comments)"
+26 -2
View File
@@ -48,6 +48,7 @@ set -euo pipefail
# workflow-level jq install can fail on runners with network restrictions
# (GitHub releases not reachable from some runner networks — infra#241
# follow-up). This fallback is idempotent — no-op when jq is already on PATH.
# SOP_FAIL_OPEN=1 makes this always exit 0 so CI never blocks on jq absence.
if ! command -v jq >/dev/null 2>&1; then
echo "::notice::jq not found on PATH — attempting install..."
_jq_installed="no"
@@ -66,6 +67,12 @@ if ! command -v jq >/dev/null 2>&1; then
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq installation failed — apt-get and GitHub binary both failed."
echo "::error::sop-tier-check requires jq for all JSON API parsing."
# SOP_FAIL_OPEN=1 is set in the workflow step's env — makes script always
# exit 0 so CI never blocks. The SOP-6 tier review gate remains enforced.
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
fi
@@ -94,10 +101,15 @@ echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUT
# cause the script to exit prematurely when the token is empty/invalid — the
# if check below handles that case gracefully. Without || true, a 401 from an
# empty/invalid token causes jq to exit 1, triggering set -e and exiting the
# entire script before the error can be logged.
# entire script before SOP_FAIL_OPEN can be evaluated (the check is in the jq-
# install block; if jq is already on PATH, that block is skipped entirely).
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""') || true
if [ -z "$WHOAMI" ]; then
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
echo "::notice::token resolves to user: $WHOAMI"
@@ -107,6 +119,10 @@ echo "::notice::token resolves to user: $WHOAMI"
HEAD_SHA=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}" | jq -r '.head.sha // ""') || true
if [ -z "$HEAD_SHA" ]; then
echo "::error::Failed to fetch PR head SHA — token may be invalid."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
debug "pr-head-sha=$HEAD_SHA"
@@ -199,6 +215,10 @@ if [ "${SOP_DEBUG:-}" = "1" ]; then
fi
if [ "$_HTTP_EXIT" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /orgs/${OWNER}/teams failed (curl exit=$_HTTP_EXIT HTTP=$HTTP_CODE) — token may lack read:org scope or be invalid."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
@@ -245,13 +265,17 @@ done
# 5. Read approving reviewers. set +e disables set -e temporarily so that curl
# failures (e.g. empty/invalid token → HTTP 401) do not abort the script before
# set -e is restored immediately after.
# SOP_FAIL_OPEN is evaluated. set -e is restored immediately after.
set +e
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
_REVIEWS_EXIT=$?
set -e
if [ $_REVIEWS_EXIT -ne 0 ] || [ -z "$REVIEWS" ]; then
echo "::error::Failed to fetch reviews (curl exit=$_REVIEWS_EXIT) — token may be invalid or unreachable."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
APPROVERS=$(echo "$REVIEWS" | jq -r --arg head_sha "$HEAD_SHA" '[.[] | select(.state=="APPROVED" and .commit_id == $head_sha) | .user.login] | unique | .[]') || true
+6 -17
View File
@@ -109,34 +109,23 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self._json(200, [{
"state": "APPROVED",
"dismissed": True,
"official": True,
"user": {"login": "core-devops"},
"commit_id": "deadbeef0000111122223333444455556666",
"commit_id": "abc1234",
}])
if sc == "T3_reviews_approved_non_author":
return self._json(200, [
{"state": "CHANGES_REQUESTED", "dismissed": False, "official": True, "user": {"login": "bob"}, "commit_id": "deadbeef0000111122223333444455556666"},
{"state": "APPROVED", "dismissed": False, "official": True, "user": {"login": "core-devops"}, "commit_id": "deadbeef0000111122223333444455556666"},
{"state": "CHANGES_REQUESTED", "dismissed": False, "user": {"login": "bob"}, "commit_id": "abc1234"},
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
])
if sc == "T19_ai_sop_ack_approved":
# ai-sop-ack member submitted APPROVED review — must NOT count
# toward qa-review (team_id=20) or security-review (team_id=21).
return self._json(200, [
{"state": "APPROVED", "dismissed": False, "official": True, "user": {"login": "ai-reviewer"}, "commit_id": "deadbeef0000111122223333444455556666"},
{"state": "APPROVED", "dismissed": False, "user": {"login": "ai-reviewer"}, "commit_id": "abc1234"},
])
if sc == "T21_stale_head_approved":
# APPROVED review but on an old commit (stale head) → must be rejected
return self._json(200, [
{"state": "APPROVED", "dismissed": False, "official": True, "user": {"login": "core-devops"}, "commit_id": "oldsha0000000000000000000000000000"},
])
if sc == "T22_missing_official":
# APPROVED review with no official field → must be rejected
return self._json(200, [
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "deadbeef0000111122223333444455556666"},
])
# Default: one non-author APPROVED (current head, official)
# Default: one non-author APPROVED
return self._json(200, [
{"state": "APPROVED", "dismissed": False, "official": True, "user": {"login": "core-devops"}, "commit_id": "deadbeef0000111122223333444455556666"},
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
])
# GET /repos/{owner}/{name}/issues/{pr_number}/comments
+11 -598
View File
@@ -308,8 +308,6 @@ def test_process_once_holds_pr_on_permanent_merge_error(monkeypatch):
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", {"merge-queue-hold", "do-not-auto-merge", "wip"})
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
@@ -326,7 +324,7 @@ def test_process_once_holds_pr_on_permanent_merge_error(monkeypatch):
return {"state": "success", "statuses": [{"context": ctx, "status": "success"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
monkeypatch.setattr(mq, "list_queued_issues", lambda: [
{"number": 100, "pull_request": {}, "labels": [{"name": "merge-queue"}],
"created_at": "2026-06-01T00:00:00Z"},
])
@@ -376,8 +374,6 @@ def _fully_ready_process_once_monkeypatch(monkeypatch, mergeable, calls):
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", {"merge-queue-hold", "do-not-auto-merge", "wip"})
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
required_contexts=["CI / all-required (pull_request)"],
@@ -393,7 +389,7 @@ def _fully_ready_process_once_monkeypatch(monkeypatch, mergeable, calls):
return {"state": "success", "statuses": [{"context": ctx, "status": "success"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
monkeypatch.setattr(mq, "list_queued_issues", lambda: [
{"number": 102, "pull_request": {}, "labels": [{"name": "merge-queue"}],
"created_at": "2026-06-01T00:00:00Z"},
])
@@ -488,8 +484,6 @@ def test_status_fetch_failure_is_fail_closed(monkeypatch):
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", {"merge-queue-hold", "do-not-auto-merge", "wip"})
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
required_contexts=["CI / all-required (pull_request)"],
@@ -507,7 +501,7 @@ def test_status_fetch_failure_is_fail_closed(monkeypatch):
raise mq.ApiError("GET /commits/HEAD/status -> HTTP 502: bad gateway")
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
monkeypatch.setattr(mq, "list_queued_issues", lambda: [
{"number": 101, "pull_request": {}, "labels": [{"name": "merge-queue"}],
"created_at": "2026-06-01T00:00:00Z"},
])
@@ -601,8 +595,6 @@ def _stale_pr_update_409_monkeypatch(monkeypatch, queued_issues, calls):
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", {"merge-queue-hold", "do-not-auto-merge", "wip"})
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
required_contexts=["CI / all-required (pull_request)"],
@@ -618,8 +610,7 @@ def _stale_pr_update_409_monkeypatch(monkeypatch, queued_issues, calls):
return {"state": "success", "statuses": [{"context": ctx, "status": "success"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
# Scan-loop process_once enumerates candidates via list_candidate_issues.
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: queued_issues)
monkeypatch.setattr(mq, "list_queued_issues", lambda: queued_issues)
monkeypatch.setattr(mq, "get_pull", lambda n: {
"state": "open", "number": n, "mergeable": True,
"base": {"ref": "main", "repo_id": 1},
@@ -649,7 +640,6 @@ def _stale_pr_update_409_monkeypatch(monkeypatch, queued_issues, calls):
def fake_add_label(pr_number, label_name, *, dry_run):
calls["hold_label"] = (pr_number, label_name)
calls.setdefault("holds", []).append((pr_number, label_name))
monkeypatch.setattr(mq, "add_label_by_name", fake_add_label)
monkeypatch.setattr(mq, "post_comment", lambda *a, **k: None)
@@ -679,12 +669,9 @@ def test_process_once_holds_pr_on_409_conflict_on_update(monkeypatch):
def test_queue_advances_past_held_conflicted_pr(monkeypatch):
"""End-to-end HOL proof for #2352 under the scan-loop architecture: PR #1409
(oldest) hits a 409-on-update and is HELD (HOLD_LABEL applied); once held it
carries an opt-out label so it is excluded from candidate selection and can
never re-block the queue. The 409-conflict hold (#2354) and the
scan-through-skip (#2356) coexist: a held conflicted PR is both held AND no
longer a candidate, so newer ready PRs behind it are unblocked."""
"""End-to-end HOL proof for #2352: PR #1409 (oldest) hits a 409-on-update
and is held; on the NEXT tick choose_next_queued_issue must SKIP the held
PR and select the next ready PR (#1500) instead of stalling on #1409."""
calls = {"update_attempts": 0, "merge_attempts": 0, "hold_label": None}
conflicted = {"number": 1409, "pull_request": {},
"labels": [{"name": "merge-queue"}],
@@ -698,30 +685,16 @@ def test_queue_advances_past_held_conflicted_pr(monkeypatch):
calls=calls,
)
# Tick 1: oldest (#1409) is selected, 409-on-update → held, then the scan
# CONTINUES to #1500 (which also 409s in this fixture and is likewise held).
# The key #2352 property: the conflicted oldest PR is held and does NOT stop
# the scan from advancing past it.
# Tick 1: oldest (#1409) is selected, 409-on-update → held.
rc = mq.process_once(dry_run=False)
assert rc == 0
assert (1409, "merge-queue-hold") in calls["holds"]
assert calls["merge_attempts"] == 0 # held, not merged — fail-closed
assert calls["hold_label"] == (1409, "merge-queue-hold")
# Simulate the label now present on #1409 (as the real hold would persist).
conflicted["labels"] = [{"name": "merge-queue"}, {"name": "merge-queue-hold"}]
# Next selection: the scan-loop candidate selector must SKIP the now-held
# #1409 (HOLD_LABEL is in OPT_OUT_LABELS) and surface the next ready
# candidate #1500 — the held PR no longer head-of-line blocks. The legacy
# opt-IN selector (choose_next_queued_issue) honours the same hold.
opt_out = {"merge-queue-hold", "do-not-auto-merge", "wip"}
remaining = mq.choose_candidate_issues(
[conflicted, next_ready],
queue_label="merge-queue",
opt_out_labels=opt_out,
auto_discover=True,
)
assert [i["number"] for i in remaining] == [1500]
# Tick 2: the queue must ADVANCE — choose_next_queued_issue skips the held
# #1409 and selects the next ready candidate #1500, NOT re-select #1409.
selected = mq.choose_next_queued_issue(
[conflicted, next_ready],
queue_label="merge-queue",
@@ -729,563 +702,3 @@ def test_queue_advances_past_held_conflicted_pr(monkeypatch):
)
assert selected is not None
assert selected["number"] == 1500
# --------------------------------------------------------------------------
# §SOP-22: AUTO-DISCOVERY (opt-OUT, label-optional). The queue must be
# self-sustaining — a ready PR is considered/merged with NO `merge-queue`
# label, while opt-out labels (merge-queue-hold / do-not-auto-merge / wip) and
# drafts are skipped. The merge bar (approvals/required-green/mergeable) is
# unchanged; only candidate selection changes.
# --------------------------------------------------------------------------
OPT_OUT = {"merge-queue-hold", "do-not-auto-merge", "wip"}
def _issue(number, labels, *, created="2026-06-01T00:00:00Z", draft=False, is_pr=True):
pr = {"draft": draft} if is_pr else None
out = {
"number": number,
"labels": [{"name": n} for n in labels],
"created_at": created,
}
if pr is not None:
out["pull_request"] = pr
return out
def test_auto_discover_selects_unlabeled_ready_pr():
"""A ready PR with NO merge-queue label is auto-considered (the autonomy fix:
agents cannot self-label because their token lacks write:issue)."""
issues = [_issue(50, labels=[])] # no merge-queue label at all
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is not None
assert selected["number"] == 50
def test_auto_discover_skips_opt_out_labels():
"""Each opt-out label keeps a PR OUT of autonomous merging (the human escape
hatch). A PR carrying any of them is never selected even though it is open."""
for optout in OPT_OUT:
issues = [_issue(60, labels=[optout])]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is None, f"{optout!r} should opt the PR out"
def test_auto_discover_skips_opt_out_even_when_queue_labeled():
"""An opt-out label beats the merge-queue label: a held/wip PR that also
carries merge-queue is still skipped."""
issues = [_issue(61, labels=["merge-queue", "wip"])]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is None
def test_auto_discover_skips_drafts():
issues = [_issue(62, labels=[], draft=True)]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is None
def test_auto_discover_skips_non_pull_issues():
"""A plain issue (no pull_request key) is never a merge candidate."""
issues = [_issue(63, labels=[], is_pr=False)]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is None
def test_auto_discover_oldest_first_skipping_opt_out():
"""Selection is FIFO (oldest created_at first), and the opt-out PR is passed
over for the next-oldest eligible PR."""
issues = [
_issue(70, labels=["do-not-auto-merge"], created="2026-06-01T01:00:00Z"),
_issue(71, labels=[], created="2026-06-01T02:00:00Z"),
_issue(72, labels=["merge-queue"], created="2026-06-01T03:00:00Z"),
]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected["number"] == 71 # 70 opted out, 71 is next-oldest eligible
def test_opt_in_mode_requires_queue_label():
"""AUTO_DISCOVER off restores legacy opt-IN: only merge-queue-labeled PRs are
candidates; an unlabeled ready PR is NOT selected."""
issues = [
_issue(80, labels=[], created="2026-06-01T01:00:00Z"),
_issue(81, labels=["merge-queue"], created="2026-06-01T02:00:00Z"),
]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=False
)
assert selected["number"] == 81
def test_opt_in_mode_still_honours_opt_out():
"""Even in opt-IN mode, an opt-out label on a queue-labeled PR skips it."""
issues = [_issue(82, labels=["merge-queue", "merge-queue-hold"])]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=False
)
assert selected is None
def test_list_candidate_issues_omits_label_filter_when_auto_discover(monkeypatch):
"""The auto-discovery listing must NOT pass a `labels` filter (so unlabeled
PRs are enumerated); the opt-IN listing must keep filtering by QUEUE_LABEL."""
captured = {}
def fake_api(method, path, *, query=None, **kw):
captured["query"] = dict(query or {})
return 200, []
monkeypatch.setattr(mq, "api", fake_api)
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
mq.list_candidate_issues(auto_discover=True)
assert "labels" not in captured["query"]
assert captured["query"].get("type") == "pulls"
mq.list_candidate_issues(auto_discover=False)
assert captured["query"].get("labels") == "merge-queue"
def _wire_ready_process_once(monkeypatch, *, issues, pr_payload, calls):
"""Wire process_once fully green EXCEPT candidate selection / pull payload,
which the caller supplies to exercise auto-discovery end-to-end."""
monkeypatch.setattr(mq, "OWNER", "molecule-ai")
monkeypatch.setattr(mq, "NAME", "molecule-core")
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", OPT_OUT)
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
required_contexts=["CI / all-required (pull_request)"],
required_approvals=2, block_on_rejected_reviews=True,
))
main_sha = "b" * 40
head_sha = "a" * 40
monkeypatch.setattr(mq, "get_branch_head", lambda branch: main_sha)
def fake_combined(sha):
ctx = "CI / all-required (push)" if sha == main_sha else "CI / all-required (pull_request)"
return {"state": "success", "statuses": [{"context": ctx, "status": "success"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: issues)
monkeypatch.setattr(mq, "get_pull", lambda n: dict(pr_payload, number=n))
monkeypatch.setattr(mq, "get_pull_commits", lambda n: [{"sha": main_sha}, {"sha": head_sha}])
monkeypatch.setattr(mq, "get_pull_reviews", lambda n: [
{"state": "APPROVED", "user": {"login": "agent-researcher"},
"official": True, "stale": False, "dismissed": False, "commit_id": head_sha},
{"state": "APPROVED", "user": {"login": "agent-reviewer-cr2"},
"official": True, "stale": False, "dismissed": False, "commit_id": head_sha},
])
def fake_merge(pr_number, *, dry_run, force=False):
calls["merged"] = pr_number
monkeypatch.setattr(mq, "merge_pull", fake_merge)
monkeypatch.setattr(mq, "update_pull", lambda *a, **k: calls.__setitem__("updated", True))
monkeypatch.setattr(mq, "post_comment", lambda *a, **k: None)
monkeypatch.setattr(mq, "add_label_by_name", lambda *a, **k: None)
return main_sha, head_sha
def test_process_once_auto_merges_unlabeled_ready_pr(monkeypatch):
"""End-to-end: a fully-ready PR with NO merge-queue label is auto-merged.
This is the core autonomy fix — no human/agent labeling required."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(90, labels=[])], # NO merge-queue label
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [],
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] == 90 # merged despite no merge-queue label
def test_process_once_skips_opt_out_labeled_pr(monkeypatch):
"""A fully-ready PR carrying an opt-out label is NOT merged (skipped)."""
for optout in OPT_OUT:
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(91, labels=[optout])],
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [{"name": optout}],
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None, f"{optout!r} PR must not be merged"
def test_process_once_does_not_merge_unapproved_pr(monkeypatch):
"""A not-ready PR (only one genuine approval) is auto-considered but NOT
merged — auto-discovery does not lower the merge bar."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
main_sha, _ = _wire_ready_process_once(
monkeypatch,
issues=[_issue(92, labels=[])],
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [],
},
calls=calls,
)
# Only ONE genuine approval → below the required 2.
monkeypatch.setattr(mq, "get_pull_reviews", lambda n: [
{"state": "APPROVED", "user": {"login": "agent-researcher"},
"official": True, "stale": False, "dismissed": False, "commit_id": head_sha},
])
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None
def test_process_once_does_not_merge_red_required_pr(monkeypatch):
"""A not-ready PR (required context red) is auto-considered but NOT merged."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
main_sha = "b" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(93, labels=[])],
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [],
},
calls=calls,
)
# Required PR context is FAILURE; main stays green.
def fake_combined(sha):
if sha == main_sha:
return {"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}]}
return {"state": "failure",
"statuses": [{"context": "CI / all-required (pull_request)", "status": "failure"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None
def test_process_once_does_not_merge_unmergeable_pr(monkeypatch):
"""A not-ready PR (mergeable False = conflicts) is auto-considered but NOT
merged."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(94, labels=[])],
pr_payload={
"state": "open", "mergeable": False, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [],
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None
# --------------------------------------------------------------------------
# §SOP-22 (cont.): HEAD-OF-LINE (HOL) — a non-ready auto-discovered candidate
# must NOT block the newer ready PRs behind it. The queue SCANS THROUGH the
# FIFO candidate list, skipping `wait` candidates (REQUEST_CHANGES, mergeable
# != True, insufficient genuine approvals, or red required CI), and merges the
# first ready PR in the SAME tick. (Regression for the #1519-style false
# candidate the reviewer caught: open + unlabeled + mergeable=false + current-
# head official REQUEST_CHANGES + <2 genuine approvals.)
# --------------------------------------------------------------------------
MAIN_SHA = "b" * 40
def _wire_multi_candidate_process_once(monkeypatch, *, issues, pulls, reviews, calls):
"""Wire process_once for MULTIPLE candidates, dispatching get_pull /
get_pull_reviews / head-status BY PR NUMBER so each candidate can have a
different readiness. `pulls` maps number -> pull payload; `reviews` maps
number -> reviews list. Main is green; each PR head status is green."""
monkeypatch.setattr(mq, "OWNER", "molecule-ai")
monkeypatch.setattr(mq, "NAME", "molecule-core")
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", OPT_OUT)
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
required_contexts=["CI / all-required (pull_request)"],
required_approvals=2, block_on_rejected_reviews=True,
))
monkeypatch.setattr(mq, "get_branch_head", lambda branch: MAIN_SHA)
def fake_combined(sha):
ctx = "CI / all-required (push)" if sha == MAIN_SHA else "CI / all-required (pull_request)"
return {"state": "success", "statuses": [{"context": ctx, "status": "success"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: issues)
monkeypatch.setattr(mq, "get_pull", lambda n: dict(pulls[n], number=n))
# Each PR head contains current main (so no candidate needs an update; the
# only differentiator is readiness). head sha is the pull's own head.
monkeypatch.setattr(
mq, "get_pull_commits",
lambda n: [{"sha": MAIN_SHA}, {"sha": pulls[n]["head"]["sha"]}],
)
monkeypatch.setattr(mq, "get_pull_reviews", lambda n: reviews[n])
def fake_merge(pr_number, *, dry_run, force=False):
calls.setdefault("merged", [])
calls["merged"].append(pr_number)
monkeypatch.setattr(mq, "merge_pull", fake_merge)
monkeypatch.setattr(mq, "update_pull", lambda *a, **k: calls.__setitem__("updated", True))
monkeypatch.setattr(mq, "post_comment", lambda *a, **k: None)
monkeypatch.setattr(mq, "add_label_by_name", lambda *a, **k: None)
def _two_approvals(head_sha):
return [
{"state": "APPROVED", "user": {"login": "agent-researcher"},
"official": True, "stale": False, "dismissed": False, "commit_id": head_sha},
{"state": "APPROVED", "user": {"login": "agent-reviewer-cr2"},
"official": True, "stale": False, "dismissed": False, "commit_id": head_sha},
]
def test_hol_unready_oldest_does_not_block_newer_ready_pr(monkeypatch):
"""The OLDEST auto-discovered candidate is NOT ready (mergeable=false). The
queue must SKIP it and merge the NEWER ready PR in the SAME tick — no HOL."""
calls = {"updated": False}
old_head, new_head = "a" * 40, "c" * 40
_wire_multi_candidate_process_once(
monkeypatch,
issues=[
_issue(500, labels=[], created="2026-06-01T01:00:00Z"), # oldest, NOT ready
_issue(501, labels=[], created="2026-06-01T02:00:00Z"), # newer, READY
],
pulls={
500: {"state": "open", "mergeable": False, "draft": False, # conflict
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": old_head, "repo_id": 1}, "labels": []},
501: {"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": new_head, "repo_id": 1}, "labels": []},
},
reviews={500: _two_approvals(old_head), 501: _two_approvals(new_head)},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
# The newer ready PR merged; the non-ready oldest did not block it.
assert calls.get("merged") == [501]
def test_hol_1519_style_false_candidate_never_merged_and_never_blocks(monkeypatch):
"""Live #1519 repro: oldest, open, UNLABELED, but mergeable=false + a
current-head official REQUEST_CHANGES + only ONE genuine approval. It must
NEVER be merged and must NEVER block the newer ready PR behind it."""
calls = {"updated": False}
false_head, ready_head = "a" * 40, "c" * 40
_wire_multi_candidate_process_once(
monkeypatch,
issues=[
_issue(1519, labels=[], created="2026-05-20T00:00:00Z"), # oldest false candidate
_issue(2000, labels=[], created="2026-06-01T00:00:00Z"), # newer, READY
],
pulls={
1519: {"state": "open", "mergeable": False, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": false_head, "repo_id": 1}, "labels": []},
2000: {"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": ready_head, "repo_id": 1}, "labels": []},
},
reviews={
1519: [
# one genuine approval (below 2) ...
{"state": "APPROVED", "user": {"login": "agent-researcher"},
"official": True, "stale": False, "dismissed": False, "commit_id": false_head},
# ... plus a current-head official REQUEST_CHANGES (human action needed)
{"state": "REQUEST_CHANGES", "user": {"login": "agent-reviewer"},
"official": True, "stale": False, "dismissed": False, "commit_id": false_head},
],
2000: _two_approvals(ready_head),
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
# #1519 is never merged; the ready PR behind it merges this same tick.
assert calls.get("merged") == [2000]
assert 1519 not in calls.get("merged", [])
def test_hol_unready_red_required_ci_is_skipped_for_ready_pr(monkeypatch):
"""A candidate whose required CI is RED is skipped (not waited-on) so the
newer ready PR merges in the same tick."""
calls = {"updated": False}
red_head, ready_head = "a" * 40, "c" * 40
_wire_multi_candidate_process_once(
monkeypatch,
issues=[
_issue(600, labels=[], created="2026-06-01T01:00:00Z"), # required CI red
_issue(601, labels=[], created="2026-06-01T02:00:00Z"), # ready
],
pulls={
600: {"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": red_head, "repo_id": 1}, "labels": []},
601: {"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": ready_head, "repo_id": 1}, "labels": []},
},
reviews={600: _two_approvals(red_head), 601: _two_approvals(ready_head)},
calls=calls,
)
# PR 600's required PR context is FAILURE; 601 (and main) stay green.
def fake_combined(sha):
if sha == MAIN_SHA:
return {"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}]}
state = "failure" if sha == red_head else "success"
return {"state": state,
"statuses": [{"context": "CI / all-required (pull_request)", "status": state}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls.get("merged") == [601]
def test_hol_all_candidates_unready_merges_nothing(monkeypatch):
"""If EVERY candidate is non-ready, the queue merges nothing (fail-closed)
and does not loop — it simply finds no actionable PR this tick."""
calls = {"updated": False}
h1, h2 = "a" * 40, "c" * 40
_wire_multi_candidate_process_once(
monkeypatch,
issues=[
_issue(700, labels=[], created="2026-06-01T01:00:00Z"), # RC
_issue(701, labels=[], created="2026-06-01T02:00:00Z"), # unmergeable
],
pulls={
700: {"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": h1, "repo_id": 1}, "labels": []},
701: {"state": "open", "mergeable": False, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": h2, "repo_id": 1}, "labels": []},
},
reviews={
700: _two_approvals(h1) + [
{"state": "REQUEST_CHANGES", "user": {"login": "agent-reviewer"},
"official": True, "stale": False, "dismissed": False, "commit_id": h1},
],
701: _two_approvals(h2),
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls.get("merged") is None # nothing merged; no HOL loop
def test_opt_out_draft_label_excludes_candidate():
"""The literal `draft` label is now an opt-out label (added to the default
OPT_OUT_LABELS), independent of Gitea draft STATE — a human can opt a PR out
by labeling it `draft` without converting it to a draft PR."""
# `draft` must be in the shipped default opt-out set.
assert "draft" in mq.OPT_OUT_LABELS
opt_out = OPT_OUT | {"draft"}
issues = [_issue(800, labels=["draft"], draft=False)] # label only, not draft STATE
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=opt_out, auto_discover=True
)
assert selected is None
def test_choose_candidate_issues_returns_full_fifo_list_skipping_opt_out():
"""choose_candidate_issues returns ALL eligible candidates oldest-first (so
process_once can scan past non-ready ones), skipping opt-out/draft/non-PR."""
issues = [
_issue(72, labels=["merge-queue"], created="2026-06-01T03:00:00Z"),
_issue(70, labels=["do-not-auto-merge"], created="2026-06-01T01:00:00Z"), # opt-out
_issue(71, labels=[], created="2026-06-01T02:00:00Z"),
_issue(73, labels=[], draft=True, created="2026-06-01T00:30:00Z"), # draft
_issue(74, labels=[], is_pr=False, created="2026-06-01T00:00:00Z"), # not a PR
]
ordered = mq.choose_candidate_issues(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert [i["number"] for i in ordered] == [71, 72] # FIFO, opt-out/draft/non-PR dropped
def test_process_once_defensive_skip_when_pull_payload_opted_out(monkeypatch):
"""If the listing missed an opt-out label but the authoritative pull payload
carries it (stale listing race), process_once must still skip the merge."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(95, labels=[])], # listing shows no opt-out
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [{"name": "do-not-auto-merge"}], # live pull is opted out
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None
+19 -53
View File
@@ -14,17 +14,10 @@
# T9 — team membership probe → 403 (token not in team) → script exits 1 (fail closed)
# T10 — CURL_AUTH_FILE created with mode 600 and correct header content
# T11 — bash syntax check (bash -n passes)
# T12 — jq filter: non-author APPROVED official current-head → in candidate list; dismissed → excluded
# T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded
# T13 — missing required env GITEA_TOKEN → exits 1 with error
# T14 — non-default-base PR exits 0 without requiring review
# T15comment agent-prefix approval → exit 1
# T16 — comment generic keyword approval → exit 1
# T17 — comments with no approval keywords → exit 1
# T18 — wrong-team review + right-team comment → exit 1
# T19 — ai-sop-ack APPROVED review excluded from qa-review gate
# T20 — ai-sop-ack APPROVED review excluded from security-review gate
# T21 — stale-head APPROVED review → exit 1 (commit_id mismatch)
# T22 — missing/non-official APPROVED review → exit 1 (official != true)
# T18wrong-team review candidate does not block right-team comment approval
#
# Hostile-self-review (per feedback_assert_exact_not_substring):
# this test MUST FAIL if the script is absent. Verified by running
@@ -326,50 +319,41 @@ assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_
assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token '
rm -f "$T10_AUTHFILE"
# T12 — jq filter: non-author APPROVED official current-head included; dismissed/stale/missing-official excluded
# T12 — jq filter: non-author APPROVED included, dismissed excluded
echo
echo "== T12 jq filter =="
# These are tested indirectly via T3 and T6 above, but let's also test
# the jq expression directly.
JQ_FILTER='.[]
| select(.state == "APPROVED")
| select(.official == true)
| select(.dismissed != true)
| select(.user.login != "alice")
| select(.commit_id == $head)
| .user.login'
T12_INPUT='[{"state":"APPROVED","official":true,"dismissed":false,"commit_id":"deadbeef0000111122223333444455556666","user":{"login":"core-devops"}},{"state":"CHANGES_REQUESTED","official":true,"dismissed":false,"commit_id":"deadbeef0000111122223333444455556666","user":{"login":"bob"}},{"state":"APPROVED","official":true,"dismissed":false,"commit_id":"deadbeef0000111122223333444455556666","user":{"login":"alice"}},{"state":"APPROVED","official":true,"dismissed":true,"commit_id":"deadbeef0000111122223333444455556666","user":{"login":"carol"}},{"state":"APPROVED","official":false,"dismissed":false,"commit_id":"deadbeef0000111122223333444455556666","user":{"login":"dave"}},{"state":"APPROVED","official":true,"dismissed":false,"commit_id":"oldsha0000000000000000000000000000","user":{"login":"eve"}}]'
T12_INPUT='[{"state":"APPROVED","dismissed":false,"user":{"login":"core-devops"}},{"state":"CHANGES_REQUESTED","dismissed":false,"user":{"login":"bob"}},{"state":"APPROVED","dismissed":false,"user":{"login":"alice"}},{"state":"APPROVED","dismissed":true,"user":{"login":"carol"}}]'
JQ_CMD=$(command -v jq 2>/dev/null || echo /tmp/jq)
T12_CANDIDATES=$(echo "$T12_INPUT" | "$JQ_CMD" -r --arg head "deadbeef0000111122223333444455556666" "$JQ_FILTER" 2>/dev/null | sort -u)
assert_contains "T12 jq: core-devops (non-author APPROVED official current-head) in candidates" "core-devops" "$T12_CANDIDATES"
T12_CANDIDATES=$(echo "$T12_INPUT" | "$JQ_CMD" -r "$JQ_FILTER" 2>/dev/null | sort -u)
assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-devops" "$T12_CANDIDATES"
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
assert_eq "T12 jq: dave (official=false) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^dave$' || true)"
assert_eq "T12 jq: eve (stale head) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^eve$' || true)"
# T15 — comment-based approval via agent prefix pattern → exit 1
# SECURITY: agent-prefix comments are also removed. A text prefix in an
# issue comment is spoofable (any team member can type "[core-qa-agent]")
# and lacks the audit trail of an official Gitea review.
# T15 — comment-based approval via agent prefix pattern → exit 0
echo
echo "== T15 comment agent-prefix approval =="
T15_OUT=$(run_review_check "T15_comments_agent_approval")
T15_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T15 exit code 1 (agent-prefix comment rejected — not an official review)" "1" "$T15_RC"
assert_contains "T15 no candidates error" "no candidates from reviews API or issue comments" "$T15_OUT"
assert_eq "T15 exit code 0 (agent-comment approval + team member)" "0" "$T15_RC"
assert_contains "T15 comment fallback notice" "comment-based approval" "$T15_OUT"
assert_contains "T15 core-qa-agent APPROVED" "APPROVED by core-qa-agent" "$T15_OUT"
# T16 — comment-based approval via generic APPROVED keyword → exit 1
# SECURITY: generic keywords (APPROVED/LGTM/ACCEPTED) must NOT satisfy the
# gate — only official Gitea reviews or agent-prefix comments count. A plain
# comment from a team member is a bypass if it skips the review UI.
# T16 — comment-based approval via generic APPROVED keyword → exit 0
echo
echo "== T16 comment generic keyword approval =="
T16_OUT=$(run_review_check "T16_comments_generic_approval")
T16_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T16 exit code 1 (generic-approval comment rejected — not an official review)" "1" "$T16_RC"
assert_contains "T16 no candidates error" "no candidates from reviews API or issue comments" "$T16_OUT"
assert_eq "T16 exit code 0 (generic-approval comment + team member)" "0" "$T16_RC"
assert_contains "T16 comment fallback notice" "comment-based approval" "$T16_OUT"
# T17 — no approval keywords in comments → exit 1
echo
@@ -379,16 +363,16 @@ T17_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T17 exit code 1 (no candidates from comments)" "1" "$T17_RC"
assert_contains "T17 no candidates error" "no candidates from reviews API or issue comments" "$T17_OUT"
# T18 — wrong-team review + right-team comment → exit 1
# SECURITY: with comment approval fully removed, a wrong-team review plus
# a right-team comment yields NO valid candidates. Only official reviews
# from the target team count.
# T18 — a wrong-team PR review candidate must not suppress a right-team
# comment approval. This matches PR #1790, where QA had an APPROVED review
# and security approved via the agent comment convention.
echo
echo "== T18 review candidate wrong team, comment candidate right team =="
T18_OUT=$(run_review_check "T18_review_wrong_team_comment_right_team")
T18_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T18 exit code 1 (comment approval removed — no valid candidates)" "1" "$T18_RC"
assert_contains "T18 none are in team" "none are in team" "$T18_OUT"
assert_eq "T18 exit code 0 (comment approval still considered)" "0" "$T18_RC"
assert_contains "T18 comment candidate notice" "comment-based approval" "$T18_OUT"
assert_contains "T18 comment approver accepted" "APPROVED by core-qa-agent" "$T18_OUT"
# T19 — ai-sop-ack member APPROVED review must NOT count toward qa-review
# or security-review (R1 hardening refinement, msg 1388c76f).
@@ -409,24 +393,6 @@ assert_eq "T20 exit code 1 (ai-sop-ack not in security team)" "1" "$T20_RC"
assert_contains "T20 ai-reviewer excluded from security" "candidates: ai-reviewer" "$T20_OUT"
assert_contains "T20 none are in security team" "none are in team" "$T20_OUT"
# T21 — stale-head APPROVED review must be rejected (commit_id mismatch).
# SECURITY: an approval on an old commit does not cover the current head.
echo
echo "== T21 stale-head APPROVED review rejected =="
T21_OUT=$(run_review_check "T21_stale_head_approved")
T21_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T21 exit code 1 (stale-head approval rejected)" "1" "$T21_RC"
assert_contains "T21 no candidates error" "no candidates from reviews API or issue comments" "$T21_OUT"
# T22 — missing/non-official APPROVED review must be rejected.
# SECURITY: only official Gitea reviews count; comments and non-official reviews lack audit trail.
echo
echo "== T22 missing official flag APPROVED review rejected =="
T22_OUT=$(run_review_check "T22_missing_official")
T22_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T22 exit code 1 (missing official rejected)" "1" "$T22_RC"
assert_contains "T22 no candidates error" "no candidates from reviews API or issue comments" "$T22_OUT"
echo
echo "------"
echo "PASS=$PASS FAIL=$FAIL"
+3 -19
View File
@@ -7,13 +7,10 @@ name: gitea-merge-queue
# the user-space queue bot, one PR per tick, using the non-bypass merge actor.
#
# Queue contract:
# - auto-discovery (default): any open same-repo PR is considered — no
# `merge-queue` label required (the label is optional metadata now)
# - add label `merge-queue` to an open same-repo PR
# - bot updates stale PR heads with current main, then waits for CI
# - bot merges only when current main is green, genuine approvals are present
# on the current head, required PR contexts pass, and the PR is mergeable
# - add `merge-queue-hold`, `do-not-auto-merge`, or `wip` to keep a PR OUT of
# autonomous merging; draft PRs are also skipped
# - bot merges only when current main is green and required PR contexts pass
# - add `merge-queue-hold` to pause a queued PR without removing it
on:
# Schedule moved to operator-config:
@@ -51,19 +48,6 @@ jobs:
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
QUEUE_LABEL: merge-queue
HOLD_LABEL: merge-queue-hold
# Auto-discovery (opt-OUT). When on (default), the queue considers ALL
# open same-repo PRs that meet the merge bar — it does NOT wait for a
# human/agent to add `merge-queue`. Agent Gitea tokens lack
# write:issue (labels are issue-scoped) and could never self-label,
# which stalled the queue; the label is now OPTIONAL metadata. The
# merge bar is UNCHANGED — only candidate selection widens. Set
# AUTO_DISCOVER=0 to restore legacy opt-IN (require the merge-queue
# label to be considered).
AUTO_DISCOVER: "1"
# Opt-OUT labels: any of these on a PR keeps it OUT of autonomous
# merging (the human escape hatch). HOLD_LABEL is always also honoured.
# A human who wants a PR held just adds one of these labels.
OPT_OUT_LABELS: do-not-auto-merge,wip
UPDATE_STYLE: merge
# Recognised official-reviewer set. A merge needs >= required_approvals
# DISTINCT genuine official approvals from these accounts on the
+14 -30
View File
@@ -8,39 +8,26 @@ against the latest `main`.
## Queue Contract
**Auto-discovery (opt-OUT, default).** You do NOT need to label a PR. The bot
auto-discovers every open same-repo PR and merges any that meets the bar. The
`merge-queue` label is now optional metadata, not a gate. This removed the
historical autonomy gap: agent Gitea tokens lack `write:issue` (labels are
issue-scoped), so agents could never self-label and ready PRs stalled.
To keep a PR OUT of autonomous merging, add an opt-OUT label:
`merge-queue-hold`, `do-not-auto-merge`, or `wip`. Draft PRs are also skipped.
Add the `merge-queue` label to an open PR when it is ready to merge.
The bot processes one PR per tick:
1. Confirms `main`'s branch-protection-required push contexts are green.
2. Selects the oldest open same-repo PR that is NOT opt-out-labeled and NOT a
draft (auto-discovery). With `AUTO_DISCOVER=0` it falls back to legacy
opt-IN: only PRs carrying `merge-queue` are considered.
3. Rejects fork PRs because the queue may only update same-repo branches.
4. If the PR head does not contain current `main`, calls Gitea's
1. Confirms `main` is green.
2. Selects the oldest open PR carrying `merge-queue`.
3. Skips PRs with `merge-queue-hold`.
4. Rejects fork PRs because the queue may only update same-repo branches.
5. If the PR head does not contain current `main`, calls Gitea's
`/pulls/{n}/update?style=merge` endpoint and waits for CI on the new head.
5. Merges only when, on the PR's CURRENT head sha:
- `>= required_approvals` distinct genuine official `APPROVED` reviews from
the recognised reviewer set (read from branch protection; default 2),
- no open official `REQUEST_CHANGES`,
- every branch-protection-required status context is green, and
- the PR is `mergeable` (Gitea returns `True`; `None`/`False` = wait).
6. Merges only after the current PR head has required contexts green:
- `CI / all-required (pull_request)`
- `sop-checklist / all-items-acked (pull_request)`
The merge bar is unchanged by auto-discovery — only WHICH PRs are considered
changes. The workflow is serialized with `concurrency`, so two PRs cannot be
The workflow is serialized with `concurrency`, so two queued PRs cannot be
merged against the same observed `main`.
## Operator Commands
Queue a PR (optional — auto-discovery already considers every ready PR; the
label is just visible metadata):
Queue a PR:
```bash
curl -fsS -X POST \
@@ -50,8 +37,7 @@ curl -fsS -X POST \
-d '{"labels":["merge-queue"]}'
```
Keep a PR OUT of autonomous merging (opt-OUT — use `merge-queue-hold`,
`do-not-auto-merge`, or `wip`):
Temporarily hold a queued PR:
```bash
curl -fsS -X POST \
@@ -70,11 +56,9 @@ REPO=molecule-ai/molecule-core \
WATCH_BRANCH=main \
QUEUE_LABEL=merge-queue \
HOLD_LABEL=merge-queue-hold \
AUTO_DISCOVER=1 \
OPT_OUT_LABELS=do-not-auto-merge,wip \
REVIEWER_SET=agent-reviewer,agent-researcher,agent-reviewer-cr2 \
UPDATE_STYLE=merge \
python3 .gitea/scripts/gitea-merge-queue.py --dry-run
REQUIRED_CONTEXTS='CI / all-required (pull_request),sop-checklist / all-items-acked (pull_request)' \
python3 .gitea/scripts/gitea-merge-queue.py
```
Dry run:
@@ -0,0 +1,39 @@
// Package approvals holds the single source of truth for which destructive
// org operations require a human approval before they execute.
//
// (RFC docs/design/rfc-platform-agent.md — Phase 4)
//
// The org-level platform agent is driven by end-user chat and holds an org-admin
// token, so destructive/irreversible operations it can trigger are gated: the
// handler creates a pending approval and returns it instead of executing, and a
// human decides via the existing approvals subsystem. Keeping the gated-action
// list in ONE map makes the blast-radius boundary auditable in a single place —
// a handler not listed here behaves exactly as before.
package approvals
// Action is the canonical identifier of a gated destructive operation. The same
// string is stored in approval_requests.action so the gate can match a pending/
// approved request to the operation being retried.
type Action string
const (
ActionDeleteWorkspace Action = "delete_workspace"
ActionDeprovision Action = "deprovision_workspace"
ActionSecretWrite Action = "secret_write"
ActionOrgTokenMint Action = "org_token_mint"
)
// gated is the set of actions that require a human approval. Add an entry here
// (and gate the corresponding handler with requireApproval) to expand the
// boundary; remove one to drop a gate. This is the only place the policy lives.
var gated = map[Action]bool{
ActionDeleteWorkspace: true,
ActionDeprovision: true,
ActionSecretWrite: true,
ActionOrgTokenMint: true,
}
// IsGated reports whether action requires a human approval before executing.
func IsGated(action Action) bool {
return gated[action]
}
@@ -0,0 +1,153 @@
package handlers
// approval_gate.go — server-side gate for destructive org operations.
// (RFC docs/design/rfc-platform-agent.md — Phase 4)
//
// requireApproval is the choke point a destructive handler calls before
// executing. It is the trust boundary: the platform-management MCP is a CLIENT
// of these handlers, so enforcing here (not in the MCP) means anything holding
// an org-admin token still goes through the gate. The flow:
//
// - if a matching APPROVED + unconsumed approval exists, consume it (single-
// use) and let the operation proceed;
// - otherwise create (or reuse) a PENDING approval, broadcast it to the canvas
// (and escalate to the parent if any), and the handler returns HTTP 202 so a
// human can decide. The agent retries after approval and the gate passes.
//
// Matching is by (workspace_id, action, request_hash) where request_hash is a
// stable digest of the operation + its context, so a retried op reuses its own
// request instead of flooding the table, and an approval for "delete ws A"
// cannot be replayed to "delete ws B".
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/approvals"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
"github.com/gin-gonic/gin"
)
// approvalRequestHash is a stable digest of the gated operation. Go's
// json.Marshal sorts map keys, so the same context always hashes the same.
func approvalRequestHash(workspaceID, action string, contextMap map[string]interface{}) string {
cj, err := json.Marshal(contextMap)
if err != nil || cj == nil {
cj = []byte("{}")
}
sum := sha256.Sum256([]byte(workspaceID + "\x00" + action + "\x00" + string(cj)))
return hex.EncodeToString(sum[:])
}
// requireApproval returns (approved=true, consumedID) when a matching approval
// exists and was just consumed; otherwise it creates/reuses a pending approval
// and returns (false, pendingID). A non-nil error is a server error.
func requireApproval(ctx context.Context, b *events.Broadcaster, workspaceID string, action approvals.Action, reason string, contextMap map[string]interface{}) (bool, string, error) {
hash := approvalRequestHash(workspaceID, string(action), contextMap)
// 1. Atomically consume an approved + unconsumed request, if one exists.
// The conditional UPDATE ... RETURNING makes consumption race-safe: two
// concurrent destructive calls cannot both consume the same approval.
var consumedID string
err := db.DB.QueryRowContext(ctx, `
UPDATE approval_requests SET consumed_at = now()
WHERE id = (
SELECT id FROM approval_requests
WHERE workspace_id = $1 AND action = $2 AND request_hash = $3
AND status = 'approved' AND consumed_at IS NULL
ORDER BY decided_at DESC NULLS LAST
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id
`, workspaceID, string(action), hash).Scan(&consumedID)
if err == nil {
return true, consumedID, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return false, "", fmt.Errorf("consume approval: %w", err)
}
// 2. No usable approval — create a pending one, or reuse an existing pending
// request for the same operation so retries don't flood the table.
cj, mErr := json.Marshal(contextMap)
if mErr != nil || cj == nil {
cj = []byte("{}")
}
var approvalID string
err = db.DB.QueryRowContext(ctx, `
WITH existing AS (
SELECT id FROM approval_requests
WHERE workspace_id = $1 AND action = $2 AND request_hash = $3 AND status = 'pending'
LIMIT 1
), ins AS (
INSERT INTO approval_requests (workspace_id, action, reason, context, request_hash)
SELECT $1, $2, $4, $5::jsonb, $3
WHERE NOT EXISTS (SELECT 1 FROM existing)
RETURNING id
)
SELECT id FROM ins UNION ALL SELECT id FROM existing LIMIT 1
`, workspaceID, string(action), hash, reason, string(cj)).Scan(&approvalID)
if err != nil {
return false, "", fmt.Errorf("create approval: %w", err)
}
// Broadcast to the canvas (the user-facing signal). For a platform agent the
// parent_id is NULL, so the requested-event on its own workspace IS the user
// prompt; ordinary workspaces also escalate to their parent.
if bErr := b.RecordAndBroadcast(ctx, string(events.EventApprovalRequested), workspaceID, map[string]interface{}{
"approval_id": approvalID,
"action": string(action),
"reason": reason,
}); bErr != nil {
log.Printf("approval_gate: broadcast requested failed (ws=%s): %v", workspaceID, bErr)
}
var parentID *string
if pErr := db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID); pErr != nil {
log.Printf("approval_gate: parent lookup failed (ws=%s): %v", workspaceID, pErr)
}
if parentID != nil {
if bErr := b.RecordAndBroadcast(ctx, string(events.EventApprovalEscalated), *parentID, map[string]interface{}{
"approval_id": approvalID,
"from_workspace_id": workspaceID,
"action": string(action),
"reason": reason,
}); bErr != nil {
log.Printf("approval_gate: broadcast escalated failed (ws=%s): %v", workspaceID, bErr)
}
}
return false, approvalID, nil
}
// gateDestructive runs requireApproval for a gated action and, when approval is
// still pending, writes the 202 response and returns false (caller must stop).
// Returns true when the caller may proceed (action consumed an approval).
func gateDestructive(c *gin.Context, b *events.Broadcaster, workspaceID string, action approvals.Action, reason string, contextMap map[string]interface{}) bool {
if !approvals.IsGated(action) {
return true
}
approved, approvalID, err := requireApproval(c.Request.Context(), b, workspaceID, action, reason, contextMap)
if err != nil {
log.Printf("gateDestructive: %v (ws=%s action=%s)", err, workspaceID, action)
c.JSON(http.StatusInternalServerError, gin.H{"error": "approval gate failed"})
return false
}
if !approved {
c.JSON(http.StatusAccepted, gin.H{
"status": "pending_approval",
"approval_id": approvalID,
"action": string(action),
"reason": reason,
})
return false
}
return true
}
@@ -0,0 +1,137 @@
//go:build integration
// +build integration
// approval_gate_integration_test.go — REAL Postgres gate for requireApproval.
//
// Run with:
//
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
// go test -tags=integration ./internal/handlers/ -run Integration_RequireApproval -v
//
// Why this is NOT a sqlmock test
// ------------------------------
// The whole gate is about row state across calls: a pending request is created
// once and reused (dedup), an approval is consumed exactly once (single-use via
// the conditional UPDATE ... RETURNING), and a different operation context hashes
// to a different request. sqlmock returns whatever the stub says; only a real
// Postgres proves the consume-once semantics and the partial-index lookup.
package handlers
import (
"context"
"database/sql"
"testing"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/approvals"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
func TestIntegration_RequireApproval_GateCycle(t *testing.T) {
url := requireIntegrationDBURL(t)
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := conn.Ping(); err != nil {
t.Fatalf("ping: %v", err)
}
t.Cleanup(func() { conn.Close() })
// requireApproval + the broadcaster's structure_events write use the db.DB
// global; point it at the integration DB and restore afterwards.
prev := db.DB
db.DB = conn
t.Cleanup(func() { db.DB = prev })
setupTestRedis(t) // broadcaster publishes to db.RDB; miniredis backs it
ctx := context.Background()
b := newTestBroadcaster()
wsID := uuid.New().String()
t.Cleanup(func() {
_, _ = conn.ExecContext(ctx, `DELETE FROM approval_requests WHERE workspace_id = $1`, wsID)
_, _ = conn.ExecContext(ctx, `DELETE FROM workspaces WHERE id = $1`, wsID)
})
// A root workspace (parent_id NULL) — like the platform agent, it has no
// parent, so the gate's escalation target is the user/canvas. (This branch
// is off main and has no kind column; the gate is kind-agnostic.)
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, status, runtime, parent_id)
VALUES ($1, 'Org Concierge', 0, 'online', 'claude-code', NULL)`, wsID); err != nil {
t.Fatalf("seed root workspace: %v", err)
}
action := approvals.ActionDeleteWorkspace
ctxA := map[string]interface{}{"target": "ws-A"}
// 1. First call → no approval yet → pending created.
ok, id1, err := requireApproval(ctx, b, wsID, action, "delete ws-A", ctxA)
if err != nil {
t.Fatalf("call 1: %v", err)
}
if ok {
t.Fatal("call 1: approved=true, want false (no approval exists yet)")
}
// 2. Same operation again → must REUSE the same pending row (dedup), not flood.
ok, id2, err := requireApproval(ctx, b, wsID, action, "delete ws-A", ctxA)
if err != nil {
t.Fatalf("call 2: %v", err)
}
if ok || id2 != id1 {
t.Fatalf("call 2: ok=%v id2=%s, want false and id2==id1(%s) (dedup)", ok, id2, id1)
}
var nPending int
if err := conn.QueryRowContext(ctx,
`SELECT count(*) FROM approval_requests WHERE workspace_id=$1 AND status='pending'`, wsID).Scan(&nPending); err != nil {
t.Fatalf("count pending: %v", err)
}
if nPending != 1 {
t.Fatalf("pending rows = %d, want 1 (dedup must not flood)", nPending)
}
// 3. A human approves it (simulating the Decide handler).
if _, err := conn.ExecContext(ctx,
`UPDATE approval_requests SET status='approved', decided_by='human', decided_at=now() WHERE id=$1`, id1); err != nil {
t.Fatalf("approve: %v", err)
}
// 4. Now the gate consumes the approval and lets the op proceed.
ok, consumedID, err := requireApproval(ctx, b, wsID, action, "delete ws-A", ctxA)
if err != nil {
t.Fatalf("call 4: %v", err)
}
if !ok || consumedID != id1 {
t.Fatalf("call 4: ok=%v consumedID=%s, want true and id1(%s)", ok, consumedID, id1)
}
// 5. Single-use: the SAME approval cannot be replayed — the next call is
// pending again (a fresh request), not approved.
ok, id5, err := requireApproval(ctx, b, wsID, action, "delete ws-A", ctxA)
if err != nil {
t.Fatalf("call 5: %v", err)
}
if ok {
t.Fatal("call 5: approved=true — a consumed approval was replayed")
}
if id5 == id1 {
t.Fatal("call 5: reused the consumed request id; want a new pending request")
}
// 6. Context isolation: an approval for ws-A must not authorize ws-B.
// Approve the ws-A request, then a ws-B op must still be pending.
if _, err := conn.ExecContext(ctx,
`UPDATE approval_requests SET status='approved', decided_at=now() WHERE id=$1`, id5); err != nil {
t.Fatalf("approve id5: %v", err)
}
ok, _, err = requireApproval(ctx, b, wsID, action, "delete ws-B", map[string]interface{}{"target": "ws-B"})
if err != nil {
t.Fatalf("call 6: %v", err)
}
if ok {
t.Fatal("call 6: ws-B proceeded on a ws-A approval — context isolation broken")
}
}
@@ -0,0 +1,46 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/approvals"
"github.com/gin-gonic/gin"
)
// TestGateDestructive_NonGatedPassesThrough verifies a non-gated action skips
// the gate entirely (no DB access, no 202) so handlers whose action isn't in the
// policy map behave exactly as before.
func TestGateDestructive_NonGatedPassesThrough(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/x", nil)
proceed := gateDestructive(c, newTestBroadcaster(), "ws-1",
approvals.Action("not_a_gated_action"), "noop", nil)
if !proceed {
t.Fatalf("non-gated action must proceed, got proceed=false (status %d)", w.Code)
}
if w.Code != http.StatusOK { // CreateTestContext default; nothing written
t.Errorf("non-gated action wrote a response (status %d), want none", w.Code)
}
}
// TestApprovalRequestHash_StableAndContextSensitive pins the two properties the
// gate relies on: the same operation hashes identically across calls, and a
// different context yields a different hash (so an approval can't be replayed
// onto a different target).
func TestApprovalRequestHash_StableAndContextSensitive(t *testing.T) {
a := approvalRequestHash("ws", "delete_workspace", map[string]interface{}{"target": "A", "n": 1})
aAgain := approvalRequestHash("ws", "delete_workspace", map[string]interface{}{"n": 1, "target": "A"})
b := approvalRequestHash("ws", "delete_workspace", map[string]interface{}{"target": "B", "n": 1})
if a != aAgain {
t.Errorf("hash not stable across equal contexts: %s vs %s", a, aAgain)
}
if a == b {
t.Errorf("hash not context-sensitive: target A and B collided (%s)", a)
}
}
@@ -161,7 +161,7 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
// 1. Strip plugin's rule/fragment markers from CLAUDE.md (mirrors
// AgentskillsAdaptor.uninstall lines 184-188). Best-effort: if
// the user edited CLAUDE.md, our marker stays untouched.
h.stripPluginMarkersFromMemory(ctx, workspaceID, containerName, pluginName)
h.stripPluginMarkersFromMemory(ctx, containerName, pluginName)
// 2. Remove copied skill dirs declared in the plugin's plugin.yaml.
for _, skill := range skillNames {
@@ -171,11 +171,9 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
log.Printf("Plugin uninstall: skipping invalid skill name %q in %s: %v", skill, pluginName, err)
continue
}
if _, rmErr := h.execAsRoot(ctx, containerName, []string{
_, _ = h.execAsRoot(ctx, containerName, []string{
"rm", "-rf", "/configs/skills/" + skill,
}); rmErr != nil {
log.Printf("Plugin uninstall: failed to remove skill %s from %s: %v", skill, workspaceID, rmErr)
}
})
}
// 3. Delete the plugin directory itself (as root to handle file ownership).
@@ -393,7 +393,7 @@ func (h *PluginsHandler) readPluginSkillsFromContainer(ctx context.Context, cont
// `# Plugin: <name> /` — mirrors AgentskillsAdaptor.uninstall's stripping
// logic so install/uninstall are symmetric. Best-effort: silent on read or
// write failure, since the rest of uninstall must still succeed.
func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, workspaceID, containerName, pluginName string) {
func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, containerName, pluginName string) {
// Use sed via bash -c for atomic in-place delete: drop the marker line
// and the blank line that follows it (install adds a leading blank line
// before the marker via append_to_memory). Three sed passes mirror the
@@ -417,9 +417,7 @@ func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, works
`awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' /configs/CLAUDE.md > /tmp/claude.new && mv /tmp/claude.new /configs/CLAUDE.md`,
regexpEscapeForAwk(marker),
)
if _, awkErr := h.execAsRoot(ctx, containerName, []string{"bash", "-c", script}); awkErr != nil {
log.Printf("Plugin uninstall: failed to strip markers from CLAUDE.md for %s in %s: %v", pluginName, workspaceID, awkErr)
}
_, _ = h.execAsRoot(ctx, containerName, []string{"bash", "-c", script})
}
// regexpEscapeForAwk escapes characters that have special meaning inside an
@@ -0,0 +1,5 @@
-- Reverse the approval-gate single-use/dedup columns.
DROP INDEX IF EXISTS approval_requests_gate_idx;
ALTER TABLE approval_requests
DROP COLUMN IF EXISTS request_hash,
DROP COLUMN IF EXISTS consumed_at;
@@ -0,0 +1,18 @@
-- Single-use + dedup support for the destructive-op approval gate.
-- (RFC docs/design/rfc-platform-agent.md — Phase 4)
--
-- consumed_at: an approval is single-use. Once a destructive op consumes an
-- approved request, consumed_at is stamped so the same approval can't be
-- replayed for a second destructive call.
-- request_hash: a stable hash of (workspace_id, action, context) so a repeated
-- destructive attempt matches its own pending/approved request instead of
-- flooding the table with duplicates.
ALTER TABLE approval_requests
ADD COLUMN IF NOT EXISTS consumed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS request_hash TEXT;
-- Hot path: the gate looks up an approved + unconsumed row matching
-- (workspace_id, action, request_hash). Partial index keeps that O(log live).
CREATE INDEX IF NOT EXISTS approval_requests_gate_idx
ON approval_requests (workspace_id, action, request_hash)
WHERE status = 'approved' AND consumed_at IS NULL;