Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81630a36f8 | |||
| 173881e67a | |||
| bde5421766 | |||
| 2e068c7586 | |||
| a380218234 | |||
| 578b145312 | |||
| a77b6850e2 | |||
| 2f9b5b6704 | |||
| 86df02c38f | |||
| db39d519dc | |||
| 0b771d5770 | |||
| 8a63d16f8c | |||
| 63c25d4c3f | |||
| 116697c576 | |||
| d1c6fce937 | |||
| 0e87fde0a3 | |||
| d768d8667b | |||
| 99d4a44250 | |||
| 29d15cbe2c | |||
| b1475b1f71 | |||
| b2d5f88f98 | |||
| 31283a292a | |||
| 8ae3cb6917 | |||
| bc7c45f3d6 | |||
| 7b3fc0f2ef | |||
| e441def8b3 | |||
| 51f83260df | |||
| 2fa68b1f23 | |||
| a60033dc16 |
+379
-132
@@ -4,7 +4,11 @@
|
||||
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. Pick the oldest open PR carrying QUEUE_LABEL (skipping HOLD_LABEL).
|
||||
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.
|
||||
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
|
||||
@@ -29,13 +33,42 @@ Authoritative gates (fail-closed):
|
||||
approvals present). It is NEVER used to bypass a failing REQUIRED context
|
||||
or missing approvals.
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
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).
|
||||
@@ -68,6 +101,33 @@ 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=(
|
||||
@@ -208,6 +268,34 @@ def api(
|
||||
return status, {"_raw": raw.decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
def api_paginated(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
query: dict[str, str] | None = None,
|
||||
page_size: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Fetch all pages of a paginated Gitea list endpoint.
|
||||
|
||||
Gitea paginates with `page` (1-indexed) and `limit`. We loop until a
|
||||
page returns fewer than `page_size` items, indicating the end.
|
||||
"""
|
||||
results: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
page_query = dict(query or {})
|
||||
page_query["page"] = str(page)
|
||||
page_query["limit"] = str(page_size)
|
||||
_, body = api(method, path, query=page_query)
|
||||
if not isinstance(body, list):
|
||||
raise ApiError(f"{path} paginated response not list")
|
||||
results.extend(body)
|
||||
if len(body) < page_size:
|
||||
break
|
||||
page += 1
|
||||
return results
|
||||
|
||||
|
||||
def required_contexts(raw: str) -> list[str]:
|
||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
||||
@@ -240,17 +328,18 @@ def _is_tier_low_pending_ok(
|
||||
) -> bool:
|
||||
"""Return True if tier:low PR can tolerate sop-checklist pending state.
|
||||
|
||||
Per sop-checklist-config.yaml tier_failure_mode, tier:low uses soft-fail:
|
||||
sop-checklist posts state=pending when acks are satisfied (missing
|
||||
manager/ceo acks are informational only). The queue should accept
|
||||
pending instead of waiting for success.
|
||||
GENERIC PENDING-AS-GREEN REMOVED (Researcher + CR2 RC on #2368):
|
||||
The prior soft-fail accepted ANY pending sop-checklist for tier:low,
|
||||
which allowed required checks to pass without genuine verification.
|
||||
Pending required sop-checklist must now always HOLD and appear in
|
||||
missing_or_bad. This function is retained as a policy hook but
|
||||
currently always returns False so pending never counts green.
|
||||
|
||||
If a positively identifiable genuine soft-fail state is defined in
|
||||
future (e.g., a specific check-run conclusion), implement it here
|
||||
with strict positive identification — never default to pass.
|
||||
"""
|
||||
if "tier:low" not in pr_labels:
|
||||
return False
|
||||
if "sop-checklist" not in context:
|
||||
return False
|
||||
status = latest_statuses.get(context) or {}
|
||||
return status_state(status) == "pending"
|
||||
return False
|
||||
|
||||
|
||||
def required_contexts_green(
|
||||
@@ -410,6 +499,85 @@ 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")
|
||||
@@ -520,32 +688,23 @@ def get_combined_status(sha: str) -> dict:
|
||||
"""Combined status + all individual statuses for `sha`.
|
||||
|
||||
The /status endpoint caps the `statuses` array at 30 entries (Gitea
|
||||
default page size), so we fetch the full list via /statuses with a
|
||||
higher limit. The combined `state` still comes from /status.
|
||||
default page size), so we fetch the full list via /statuses. The combined
|
||||
`state` still comes from /status.
|
||||
|
||||
Fail-closed: the PRIMARY /status fetch must succeed. If it raises, the
|
||||
error propagates so the caller skips this PR this tick (we never treat a
|
||||
failed status fetch as green — dev-sop "no fail-open"). Only the SECONDARY
|
||||
/statuses enrichment (which merely extends the per-context list beyond the
|
||||
30-entry cap) is best-effort; if it fails we still have the combined set.
|
||||
Fail-closed: BOTH the PRIMARY /status fetch AND the SECONDARY /statuses
|
||||
enrichment must succeed. If either raises, the error propagates so the
|
||||
caller skips this PR this tick (we never treat a failed status fetch as
|
||||
green — dev-sop "no fail-open"). A paginated /statuses error must NOT
|
||||
silently degrade to an incomplete status set.
|
||||
"""
|
||||
_, combined = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(combined, dict):
|
||||
raise ApiError(f"status for {sha} response not object")
|
||||
combined_statuses: list[dict] = combined.get("statuses") or []
|
||||
try:
|
||||
_, all_statuses_raw = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/commits/{sha}/statuses",
|
||||
query={"limit": "50"},
|
||||
)
|
||||
if isinstance(all_statuses_raw, list):
|
||||
all_statuses: list[dict] = list(all_statuses_raw)
|
||||
else:
|
||||
all_statuses = []
|
||||
except (ApiError, urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
sys.stderr.write(f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n")
|
||||
all_statuses = []
|
||||
all_statuses = api_paginated(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/commits/{sha}/statuses",
|
||||
)
|
||||
# Build latest per context: process combined (ascending→reverse=newest
|
||||
# first), then fill gaps from all_statuses (already newest-first).
|
||||
latest: dict[str, dict] = {}
|
||||
@@ -562,19 +721,36 @@ def get_combined_status(sha: str) -> dict:
|
||||
|
||||
|
||||
def list_queued_issues() -> list[dict]:
|
||||
_, body = api(
|
||||
return api_paginated(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/issues",
|
||||
query={
|
||||
"state": "open",
|
||||
"type": "pulls",
|
||||
"labels": QUEUE_LABEL,
|
||||
"limit": "50",
|
||||
},
|
||||
)
|
||||
if not isinstance(body, list):
|
||||
raise ApiError("queued issues response not list")
|
||||
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()
|
||||
return api_paginated(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/issues",
|
||||
query={
|
||||
"state": "open",
|
||||
"type": "pulls",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_pull(pr_number: int) -> dict:
|
||||
@@ -731,45 +907,181 @@ 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
|
||||
|
||||
issue = choose_next_queued_issue(
|
||||
list_queued_issues(),
|
||||
candidates = choose_candidate_issues(
|
||||
list_candidate_issues(auto_discover=AUTO_DISCOVER),
|
||||
queue_label=QUEUE_LABEL,
|
||||
hold_label=HOLD_LABEL,
|
||||
opt_out_labels=OPT_OUT_LABELS,
|
||||
auto_discover=AUTO_DISCOVER,
|
||||
)
|
||||
if not issue:
|
||||
print("::notice::merge queue empty")
|
||||
if not candidates:
|
||||
print(
|
||||
"::notice::no merge candidates "
|
||||
f"(auto_discover={'on' if AUTO_DISCOVER else 'off'})"
|
||||
)
|
||||
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 0
|
||||
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
|
||||
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 0
|
||||
return None, ctx
|
||||
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 0
|
||||
return None, ctx
|
||||
|
||||
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 the tick is skipped
|
||||
# (the PR is never treated as green).
|
||||
# Fail-closed: a failed status fetch raises here and propagates (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 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
|
||||
# mergeable so evaluate_merge_readiness returns a "wait" decision.
|
||||
mergeable = pr.get("mergeable") is True
|
||||
|
||||
reviews = get_pull_reviews(pr_number)
|
||||
approvers, request_changes = genuine_approvals(
|
||||
@@ -779,7 +1091,7 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
decision = evaluate_merge_readiness(
|
||||
main_status=main_status,
|
||||
pr_status=pr_status,
|
||||
required_contexts=contexts,
|
||||
required_contexts=required_contexts,
|
||||
required_approvals=required_approvals,
|
||||
approvers=approvers,
|
||||
request_changes=request_changes,
|
||||
@@ -787,72 +1099,7 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
mergeable=mergeable,
|
||||
pr_labels=pr_labels,
|
||||
)
|
||||
|
||||
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
|
||||
return decision, ctx
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -863,18 +1110,18 @@ def main() -> int:
|
||||
try:
|
||||
return process_once(dry_run=args.dry_run)
|
||||
except ApiError as exc:
|
||||
# API errors (401/403/404/500) are transient for a queue tick —
|
||||
# log and exit 0 so the workflow is not marked failed and the next
|
||||
# tick can retry. Returning non-zero would permanently fail the
|
||||
# workflow run, blocking future ticks.
|
||||
# FAIL-CLOSED: API errors are not "transient success" — they mean
|
||||
# the queue could not evaluate merge state. Returning 0 hides
|
||||
# persistent infra issues (auth drift, endpoint outages) from
|
||||
# operators. Return 1 so the cron job surfaces red and paging fires.
|
||||
sys.stderr.write(f"::error::queue API error: {exc}\n")
|
||||
return 0
|
||||
return 1
|
||||
except urllib.error.URLError as exc:
|
||||
sys.stderr.write(f"::error::queue network error: {exc}\n")
|
||||
return 0
|
||||
return 1
|
||||
except TimeoutError as exc:
|
||||
sys.stderr.write(f"::error::queue timeout: {exc}\n")
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -546,16 +546,24 @@ def verify_flip(flip: dict, branch: str, n: int) -> dict:
|
||||
|
||||
shas = recent_commits_on_branch(branch, n)
|
||||
if not shas:
|
||||
result["warnings"].append(
|
||||
f"no recent commits on {branch} (cannot verify flip)"
|
||||
)
|
||||
result["masked_runs"].append({
|
||||
"sha": "",
|
||||
"status": "unverified",
|
||||
"target_url": "",
|
||||
"samples": [f"no recent commits on {branch} — cannot verify flip"],
|
||||
})
|
||||
return result
|
||||
|
||||
for sha in shas:
|
||||
try:
|
||||
status_doc = combined_status(sha)
|
||||
except ApiError as e:
|
||||
result["warnings"].append(f"combined-status for {sha}: {e}")
|
||||
result["masked_runs"].append({
|
||||
"sha": sha,
|
||||
"status": "error",
|
||||
"target_url": "",
|
||||
"samples": [f"combined-status API error: {e}"],
|
||||
})
|
||||
continue
|
||||
statuses = status_doc.get("statuses") or []
|
||||
# First entry matching the context name. Newest SHAs come
|
||||
@@ -582,6 +590,17 @@ def verify_flip(flip: dict, branch: str, n: int) -> dict:
|
||||
"target_url": target_url,
|
||||
"samples": ["[log unavailable; status itself is " + state + "]"],
|
||||
})
|
||||
elif state == "success":
|
||||
# Fail-closed: unreadable log on a success status is a
|
||||
# potential Quirk #10 mask (continue-on-error hiding real
|
||||
# failures). We cannot verify it's clean, so treat as
|
||||
# masked rather than allowing the flip.
|
||||
result["masked_runs"].append({
|
||||
"sha": sha,
|
||||
"status": state,
|
||||
"target_url": target_url,
|
||||
"samples": ["[log unavailable; cannot verify status is genuine — treat as masked]"],
|
||||
})
|
||||
break
|
||||
samples = grep_fail_markers(log_text)
|
||||
if state in ("failure", "error"):
|
||||
@@ -605,10 +624,12 @@ def verify_flip(flip: dict, branch: str, n: int) -> dict:
|
||||
break
|
||||
|
||||
if result["checked_commits"] == 0:
|
||||
result["warnings"].append(
|
||||
f"no runs of {target_context!r} found in the last {n} commits on "
|
||||
f"{branch} — cannot verify; allowing flip with warning"
|
||||
)
|
||||
result["masked_runs"].append({
|
||||
"sha": "",
|
||||
"status": "unverified",
|
||||
"target_url": "",
|
||||
"samples": [f"no runs of {target_context!r} found in the last {n} commits on {branch} — cannot verify flip"],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -197,19 +197,15 @@ if [ "$HTTP_CODE" != "200" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Filter: state=APPROVED, not-dismissed, non-author. Optionally strict-mode
|
||||
# adds commit_id==head.sha (off by default; see header).
|
||||
# Filter: state=APPROVED, official=true, not-dismissed, non-author,
|
||||
# commit_id matches current PR head. All conditions are mandatory.
|
||||
JQ_FILTER='.[]
|
||||
| select(.state == "APPROVED")
|
||||
| select(.official == true)
|
||||
| select(.dismissed != true)
|
||||
| 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"
|
||||
| select(.user.login != $author)
|
||||
| select(.commit_id == $head)
|
||||
| .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' ' ')"
|
||||
@@ -241,49 +237,14 @@ if [ -z "$REVIEW_CANDIDATES" ]; then
|
||||
|
||||
fi
|
||||
|
||||
# --- 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)
|
||||
# --- 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"
|
||||
|
||||
if [ -z "${CANDIDATES:-}" ]; then
|
||||
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates from reviews API or issue comments)"
|
||||
|
||||
@@ -48,7 +48,6 @@ 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"
|
||||
@@ -67,12 +66,6 @@ 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
|
||||
@@ -101,15 +94,10 @@ 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 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).
|
||||
# entire script before the error can be logged.
|
||||
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"
|
||||
@@ -119,10 +107,6 @@ 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"
|
||||
@@ -215,10 +199,6 @@ 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
|
||||
|
||||
@@ -265,17 +245,13 @@ 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
|
||||
# SOP_FAIL_OPEN is evaluated. set -e is restored immediately after.
|
||||
# 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
|
||||
|
||||
@@ -689,8 +689,8 @@ def reap_branch(
|
||||
shas = list_recent_commit_shas(branch, limit)
|
||||
except ApiError as e:
|
||||
print(
|
||||
"::warning::status-reaper skipped this tick because the "
|
||||
f"commit list could not be read after retries: {e}"
|
||||
"::error::status-reaper cannot run: commit-list API failed "
|
||||
f"after retries: {e}"
|
||||
)
|
||||
return {
|
||||
"scanned_shas": 0,
|
||||
@@ -704,6 +704,7 @@ def reap_branch(
|
||||
"compensated_cancelled_push": 0,
|
||||
"preserved_pr_without_push_success": 0,
|
||||
"compensated_per_sha": {},
|
||||
"sha_api_errors": 0,
|
||||
"skipped": True,
|
||||
"skip_reason": "commit-list-api-error",
|
||||
}
|
||||
@@ -720,6 +721,7 @@ def reap_branch(
|
||||
"compensated_cancelled_push": 0,
|
||||
"preserved_pr_without_push_success": 0,
|
||||
"compensated_per_sha": {},
|
||||
"sha_api_errors": 0,
|
||||
}
|
||||
|
||||
for sha in shas:
|
||||
@@ -731,8 +733,9 @@ def reap_branch(
|
||||
try:
|
||||
combined = get_combined_status(sha)
|
||||
except ApiError as e:
|
||||
aggregate["sha_api_errors"] += 1
|
||||
print(
|
||||
f"::warning::get_combined_status({sha[:10]}) failed; "
|
||||
f"::error::get_combined_status({sha[:10]}) failed; "
|
||||
f"skipping this SHA: {e}"
|
||||
)
|
||||
continue
|
||||
@@ -819,6 +822,14 @@ def main() -> int:
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
# Observability: infra-failure → red. If the commit list could not be
|
||||
# read or any per-SHA status fetch failed, the tick is incomplete and
|
||||
# must be observable as a failure (non-zero exit) so the cron bot or
|
||||
# runner surface alerts.
|
||||
if counters.get("skipped"):
|
||||
return 1
|
||||
if counters.get("sha_api_errors", 0) > 0:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -109,23 +109,34 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self._json(200, [{
|
||||
"state": "APPROVED",
|
||||
"dismissed": True,
|
||||
"official": True,
|
||||
"user": {"login": "core-devops"},
|
||||
"commit_id": "abc1234",
|
||||
"commit_id": "deadbeef0000111122223333444455556666",
|
||||
}])
|
||||
if sc == "T3_reviews_approved_non_author":
|
||||
return self._json(200, [
|
||||
{"state": "CHANGES_REQUESTED", "dismissed": False, "user": {"login": "bob"}, "commit_id": "abc1234"},
|
||||
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
|
||||
{"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"},
|
||||
])
|
||||
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, "user": {"login": "ai-reviewer"}, "commit_id": "abc1234"},
|
||||
{"state": "APPROVED", "dismissed": False, "official": True, "user": {"login": "ai-reviewer"}, "commit_id": "deadbeef0000111122223333444455556666"},
|
||||
])
|
||||
# Default: one non-author APPROVED
|
||||
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)
|
||||
return self._json(200, [
|
||||
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
|
||||
{"state": "APPROVED", "dismissed": False, "official": True, "user": {"login": "core-devops"}, "commit_id": "deadbeef0000111122223333444455556666"},
|
||||
])
|
||||
|
||||
# GET /repos/{owner}/{name}/issues/{pr_number}/comments
|
||||
|
||||
@@ -2,6 +2,8 @@ import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py"
|
||||
spec = importlib.util.spec_from_file_location("gitea_merge_queue", SCRIPT)
|
||||
mq = importlib.util.module_from_spec(spec)
|
||||
@@ -44,6 +46,35 @@ def test_required_contexts_green_rejects_missing_and_pending():
|
||||
]
|
||||
|
||||
|
||||
def test_required_contexts_green_rejects_volume_skipped_even_for_tier_low():
|
||||
"""volume-skipped pending is a partial view, not a genuine soft-fail.
|
||||
|
||||
Per sop-checklist.py:1179-1187, volume_skipped posts pending with a
|
||||
'[volume-skipped]' prefix. The merge queue must NOT treat this as an
|
||||
acceptable soft-fail for tier:low — the gate did not finish evaluating.
|
||||
"""
|
||||
latest = mq.latest_statuses_by_context([
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
{
|
||||
"context": "sop-checklist / all-items-acked (pull_request)",
|
||||
"status": "pending",
|
||||
"description": "[volume-skipped] comment-cap=1000 hit; please file ...",
|
||||
},
|
||||
])
|
||||
|
||||
ok, missing_or_bad = mq.required_contexts_green(
|
||||
latest,
|
||||
[
|
||||
"CI / all-required (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
],
|
||||
pr_labels={"tier:low"},
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert "sop-checklist / all-items-acked (pull_request)=pending" in missing_or_bad
|
||||
|
||||
|
||||
def test_choose_next_pr_sorts_by_queue_label_timestamp_then_number():
|
||||
issues = [
|
||||
{
|
||||
@@ -308,6 +339,8 @@ 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(
|
||||
@@ -324,7 +357,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_queued_issues", lambda: [
|
||||
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
|
||||
{"number": 100, "pull_request": {}, "labels": [{"name": "merge-queue"}],
|
||||
"created_at": "2026-06-01T00:00:00Z"},
|
||||
])
|
||||
@@ -374,6 +407,8 @@ 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)"],
|
||||
@@ -389,7 +424,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_queued_issues", lambda: [
|
||||
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
|
||||
{"number": 102, "pull_request": {}, "labels": [{"name": "merge-queue"}],
|
||||
"created_at": "2026-06-01T00:00:00Z"},
|
||||
])
|
||||
@@ -484,6 +519,8 @@ 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)"],
|
||||
@@ -501,7 +538,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_queued_issues", lambda: [
|
||||
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
|
||||
{"number": 101, "pull_request": {}, "labels": [{"name": "merge-queue"}],
|
||||
"created_at": "2026-06-01T00:00:00Z"},
|
||||
])
|
||||
@@ -526,6 +563,61 @@ def test_status_fetch_failure_is_fail_closed(monkeypatch):
|
||||
assert merged["called"] is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Pagination: api_paginated loops pages and is fail-closed on page errors
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_api_paginated_loops_pages_until_partial(monkeypatch):
|
||||
"""api_paginated fetches all pages and stops when a page is < page_size."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, query=None, **kw):
|
||||
page = int((query or {}).get("page", "1"))
|
||||
limit = int((query or {}).get("limit", "50"))
|
||||
calls.append((page, limit))
|
||||
if page == 1:
|
||||
return 200, [{"number": 1}, {"number": 2}]
|
||||
if page == 2:
|
||||
return 200, [{"number": 3}]
|
||||
return 200, []
|
||||
|
||||
monkeypatch.setattr(mq, "api", fake_api)
|
||||
results = mq.api_paginated("GET", "/repos/o/r/issues", page_size=2)
|
||||
assert len(results) == 3
|
||||
assert results[0]["number"] == 1
|
||||
assert results[1]["number"] == 2
|
||||
assert results[2]["number"] == 3
|
||||
assert calls == [(1, 2), (2, 2)]
|
||||
|
||||
|
||||
def test_api_paginated_raises_on_non_list(monkeypatch):
|
||||
"""A page that returns a dict instead of list is an error."""
|
||||
def fake_api(method, path, *, query=None, **kw):
|
||||
return 200, {"message": "not found"}
|
||||
|
||||
monkeypatch.setattr(mq, "api", fake_api)
|
||||
with pytest.raises(mq.ApiError):
|
||||
mq.api_paginated("GET", "/repos/o/r/issues")
|
||||
|
||||
|
||||
def test_get_combined_status_propagates_paginated_statuses_error(monkeypatch):
|
||||
"""If the paginated /statuses enrichment raises, the error propagates
|
||||
(fail-closed — we do NOT silently fall back to an incomplete status set)."""
|
||||
monkeypatch.setattr(mq, "OWNER", "o")
|
||||
monkeypatch.setattr(mq, "NAME", "r")
|
||||
|
||||
def fake_api(method, path, *, query=None, **kw):
|
||||
if path.endswith("/status"):
|
||||
return 200, {"state": "success", "statuses": [{"context": "c1", "status": "success", "id": 1}]}
|
||||
if path.endswith("/statuses"):
|
||||
raise mq.ApiError("GET /statuses -> HTTP 502")
|
||||
raise mq.ApiError(f"unexpected {path}")
|
||||
|
||||
monkeypatch.setattr(mq, "api", fake_api)
|
||||
with pytest.raises(mq.ApiError, match="GET /statuses"):
|
||||
mq.get_combined_status("a" * 40)
|
||||
|
||||
|
||||
def test_process_once_holds_tick_when_branch_protection_unavailable(monkeypatch):
|
||||
"""BP enumeration failure → HOLD the whole tick (no merge, rc 0)."""
|
||||
merged = {"called": False}
|
||||
@@ -595,6 +687,8 @@ 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)"],
|
||||
@@ -610,7 +704,8 @@ 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)
|
||||
|
||||
monkeypatch.setattr(mq, "list_queued_issues", lambda: queued_issues)
|
||||
# Scan-loop process_once enumerates candidates via list_candidate_issues.
|
||||
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: queued_issues)
|
||||
monkeypatch.setattr(mq, "get_pull", lambda n: {
|
||||
"state": "open", "number": n, "mergeable": True,
|
||||
"base": {"ref": "main", "repo_id": 1},
|
||||
@@ -640,6 +735,7 @@ 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)
|
||||
|
||||
@@ -669,9 +765,12 @@ 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: 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."""
|
||||
"""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."""
|
||||
calls = {"update_attempts": 0, "merge_attempts": 0, "hold_label": None}
|
||||
conflicted = {"number": 1409, "pull_request": {},
|
||||
"labels": [{"name": "merge-queue"}],
|
||||
@@ -685,16 +784,30 @@ def test_queue_advances_past_held_conflicted_pr(monkeypatch):
|
||||
calls=calls,
|
||||
)
|
||||
|
||||
# Tick 1: oldest (#1409) is selected, 409-on-update → held.
|
||||
# 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.
|
||||
rc = mq.process_once(dry_run=False)
|
||||
assert rc == 0
|
||||
assert calls["hold_label"] == (1409, "merge-queue-hold")
|
||||
assert (1409, "merge-queue-hold") in calls["holds"]
|
||||
assert calls["merge_attempts"] == 0 # held, not merged — fail-closed
|
||||
|
||||
# Simulate the label now present on #1409 (as the real hold would persist).
|
||||
conflicted["labels"] = [{"name": "merge-queue"}, {"name": "merge-queue-hold"}]
|
||||
|
||||
# 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.
|
||||
# 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]
|
||||
selected = mq.choose_next_queued_issue(
|
||||
[conflicted, next_ready],
|
||||
queue_label="merge-queue",
|
||||
@@ -702,3 +815,563 @@ 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
|
||||
|
||||
@@ -320,10 +320,10 @@ class TestVerifyFlip(unittest.TestCase):
|
||||
self.assertEqual(len(verdict["fail_runs"]), 1)
|
||||
self.assertEqual(verdict["fail_runs"][0]["status"], "failure")
|
||||
|
||||
def test_unreadable_log_warns_not_blocks(self):
|
||||
# Acceptance test #5: log fetch 404 (None) → warn, not block.
|
||||
# Status is `success`, log is None — we can't tell, so we warn
|
||||
# and allow.
|
||||
def test_unreadable_log_on_success_blocks(self):
|
||||
# Fail-closed: log fetch 404 (None) on a success status is a
|
||||
# potential Quirk #10 mask — we cannot verify it's genuine, so
|
||||
# we block the flip rather than allowing it.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
@@ -332,7 +332,8 @@ class TestVerifyFlip(unittest.TestCase):
|
||||
with mock.patch.object(lpfc, "fetch_log", return_value=None):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
self.assertEqual(verdict["masked_runs"], [])
|
||||
self.assertEqual(len(verdict["masked_runs"]), 1)
|
||||
self.assertIn("log unavailable", verdict["masked_runs"][0]["samples"][0])
|
||||
self.assertTrue(any("log unavailable" in w for w in verdict["warnings"]))
|
||||
|
||||
def test_unreadable_log_with_failure_status_still_blocks(self):
|
||||
@@ -349,9 +350,9 @@ class TestVerifyFlip(unittest.TestCase):
|
||||
self.assertEqual(len(verdict["fail_runs"]), 1)
|
||||
self.assertIn("log unavailable", verdict["fail_runs"][0]["samples"][0])
|
||||
|
||||
def test_zero_runs_history_warns_allows(self):
|
||||
# No commits with a matching context — newly added workflow.
|
||||
# Allow with warning.
|
||||
def test_zero_runs_history_blocks(self):
|
||||
# No commits with a matching context — cannot verify the flip.
|
||||
# Fail-closed: treat as masked rather than allowing.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1", "sha2"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
@@ -360,17 +361,32 @@ class TestVerifyFlip(unittest.TestCase):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(verdict["checked_commits"], 0)
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
self.assertEqual(verdict["masked_runs"], [])
|
||||
self.assertTrue(any("no runs of" in w for w in verdict["warnings"]))
|
||||
self.assertEqual(len(verdict["masked_runs"]), 1)
|
||||
self.assertIn("cannot verify flip", verdict["masked_runs"][0]["samples"][0])
|
||||
|
||||
def test_zero_commits_warns_allows(self):
|
||||
# Empty branch (newly created repo, e.g.). Allow with warning.
|
||||
def test_zero_commits_blocks(self):
|
||||
# Empty branch (newly created repo, e.g.). Fail-closed: block.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=[]):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(verdict["checked_commits"], 0)
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
self.assertEqual(verdict["masked_runs"], [])
|
||||
self.assertTrue(any("no recent commits" in w for w in verdict["warnings"]))
|
||||
self.assertEqual(len(verdict["masked_runs"]), 1)
|
||||
self.assertIn("cannot verify flip", verdict["masked_runs"][0]["samples"][0])
|
||||
|
||||
def test_combined_status_api_error_blocks(self):
|
||||
# Fail-closed: combined_status ApiError means the check history is
|
||||
# unreadable — we cannot verify the flip, so block as masked.
|
||||
with mock.patch.object(lpfc, "recent_commits_on_branch", return_value=["sha1"]):
|
||||
with mock.patch.object(
|
||||
lpfc, "combined_status",
|
||||
side_effect=lpfc.ApiError("GET /statuses/sha → HTTP 500"),
|
||||
):
|
||||
verdict = lpfc.verify_flip(FLIP_FIXTURE, "main", 5)
|
||||
self.assertEqual(verdict["checked_commits"], 0)
|
||||
self.assertEqual(verdict["fail_runs"], [])
|
||||
# One masked_run from the ApiError, one from zero checked_commits.
|
||||
self.assertEqual(len(verdict["masked_runs"]), 2)
|
||||
self.assertIn("API error", verdict["masked_runs"][0]["samples"][0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -14,10 +14,17 @@
|
||||
# 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 → in candidate list; dismissed → excluded
|
||||
# T12 — jq filter: non-author APPROVED official current-head → 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
|
||||
# T18 — wrong-team review candidate does not block right-team comment approval
|
||||
# T15 — comment 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)
|
||||
#
|
||||
# Hostile-self-review (per feedback_assert_exact_not_substring):
|
||||
# this test MUST FAIL if the script is absent. Verified by running
|
||||
@@ -319,41 +326,50 @@ 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 included, dismissed excluded
|
||||
# T12 — jq filter: non-author APPROVED official current-head included; dismissed/stale/missing-official 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","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"}}]'
|
||||
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"}}]'
|
||||
|
||||
JQ_CMD=$(command -v jq 2>/dev/null || echo /tmp/jq)
|
||||
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"
|
||||
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"
|
||||
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 0
|
||||
# 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.
|
||||
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 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"
|
||||
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"
|
||||
|
||||
# T16 — comment-based approval via generic APPROVED keyword → exit 0
|
||||
# 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.
|
||||
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 0 (generic-approval comment + team member)" "0" "$T16_RC"
|
||||
assert_contains "T16 comment fallback notice" "comment-based approval" "$T16_OUT"
|
||||
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"
|
||||
|
||||
# T17 — no approval keywords in comments → exit 1
|
||||
echo
|
||||
@@ -363,16 +379,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 — 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.
|
||||
# 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.
|
||||
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 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"
|
||||
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"
|
||||
|
||||
# T19 — ai-sop-ack member APPROVED review must NOT count toward qa-review
|
||||
# or security-review (R1 hardening refinement, msg 1388c76f).
|
||||
@@ -393,6 +409,24 @@ 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"
|
||||
|
||||
@@ -7,10 +7,13 @@ name: gitea-merge-queue
|
||||
# the user-space queue bot, one PR per tick, using the non-bypass merge actor.
|
||||
#
|
||||
# Queue contract:
|
||||
# - add label `merge-queue` to an open same-repo PR
|
||||
# - auto-discovery (default): any open same-repo PR is considered — no
|
||||
# `merge-queue` label required (the label is optional metadata now)
|
||||
# - bot updates stale PR heads with current main, then waits for CI
|
||||
# - 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
|
||||
# - 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
|
||||
|
||||
on:
|
||||
# Schedule moved to operator-config:
|
||||
@@ -48,6 +51,19 @@ 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
|
||||
|
||||
@@ -61,11 +61,9 @@ name: Lint pre-flip continue-on-error
|
||||
# feedback_no_shared_persona_token_use.
|
||||
#
|
||||
# Phase contract (RFC internal#219 §1 ladder):
|
||||
# - This workflow lands at `continue-on-error: true` (Phase 3 —
|
||||
# surface defects without blocking). Follow-up PR flips it to
|
||||
# `false` ONLY after this workflow's own recent runs on `main`
|
||||
# are confirmed clean — exactly the discipline the workflow
|
||||
# itself enforces. Eat your own dogfood.
|
||||
# - Flipped to `continue-on-error: false` after Researcher live-verified
|
||||
# clean runs. The script's own 35 pytest tests pass and recent PR
|
||||
# history shows no masked regressions — the gate is now enforcing.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -97,10 +95,9 @@ jobs:
|
||||
name: Verify continue-on-error flips have run-log proof
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
# Phase 3 (RFC internal#219 §1): surface broken flips without blocking
|
||||
# the PR yet. Follow-up flips this to `false` once the workflow itself
|
||||
# has clean recent runs on main. mc#1982 interim — remove when CoE→false.
|
||||
continue-on-error: true # mc#1982
|
||||
# Fail-closed: the lint script is verified clean (35/35 tests pass,
|
||||
# Researcher live-check confirmed). Masking removed per mc#1982 close-out.
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- name: Check out PR head (full history for base-SHA access)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -8,26 +8,39 @@ against the latest `main`.
|
||||
|
||||
## Queue Contract
|
||||
|
||||
Add the `merge-queue` label to an open PR when it is ready to merge.
|
||||
**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.
|
||||
|
||||
The bot processes one PR per tick:
|
||||
|
||||
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
|
||||
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
|
||||
`/pulls/{n}/update?style=merge` endpoint and waits for CI on the new head.
|
||||
6. Merges only after the current PR head has required contexts green:
|
||||
- `CI / all-required (pull_request)`
|
||||
- `sop-checklist / all-items-acked (pull_request)`
|
||||
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).
|
||||
|
||||
The workflow is serialized with `concurrency`, so two queued PRs cannot be
|
||||
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
|
||||
merged against the same observed `main`.
|
||||
|
||||
## Operator Commands
|
||||
|
||||
Queue a PR:
|
||||
Queue a PR (optional — auto-discovery already considers every ready PR; the
|
||||
label is just visible metadata):
|
||||
|
||||
```bash
|
||||
curl -fsS -X POST \
|
||||
@@ -37,7 +50,8 @@ curl -fsS -X POST \
|
||||
-d '{"labels":["merge-queue"]}'
|
||||
```
|
||||
|
||||
Temporarily hold a queued PR:
|
||||
Keep a PR OUT of autonomous merging (opt-OUT — use `merge-queue-hold`,
|
||||
`do-not-auto-merge`, or `wip`):
|
||||
|
||||
```bash
|
||||
curl -fsS -X POST \
|
||||
@@ -56,9 +70,11 @@ 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 \
|
||||
REQUIRED_CONTEXTS='CI / all-required (pull_request),sop-checklist / all-items-acked (pull_request)' \
|
||||
python3 .gitea/scripts/gitea-merge-queue.py
|
||||
python3 .gitea/scripts/gitea-merge-queue.py --dry-run
|
||||
```
|
||||
|
||||
Dry run:
|
||||
|
||||
@@ -1050,12 +1050,13 @@ def test_reap_continues_on_per_sha_apierror(sr_module, monkeypatch, capsys):
|
||||
|
||||
|
||||
def test_main_soft_skips_when_commit_listing_times_out(sr_module, monkeypatch, capsys):
|
||||
"""A transient outage while listing recent commits should not paint main red.
|
||||
"""A transient outage while listing recent commits fails the tick visibly.
|
||||
|
||||
Per-SHA status read failures are already isolated inside `reap_branch`.
|
||||
The real 2026-05-14 failure was earlier: `/commits?sha=main&limit=30`
|
||||
timed out after all retries, aborting the tick. The next 5-minute tick can
|
||||
retry safely, so `main()` should emit an observable warning and return 0.
|
||||
retry safely, but the tick itself must be observable as red (exit 1 + error
|
||||
annotation) so the cron bot alerts on persistent infra issues.
|
||||
"""
|
||||
|
||||
monkeypatch.setattr(sr_module, "scan_workflows", lambda _: {"workflow-without-push": False})
|
||||
@@ -1068,9 +1069,9 @@ def test_main_soft_skips_when_commit_listing_times_out(sr_module, monkeypatch, c
|
||||
monkeypatch.setattr(sr_module, "list_recent_commit_shas", fake_list_recent_commit_shas)
|
||||
monkeypatch.setattr(sys, "argv", ["status-reaper.py"])
|
||||
|
||||
assert sr_module.main() == 0
|
||||
assert sr_module.main() == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "::warning::status-reaper skipped this tick" in captured.out
|
||||
assert "::error::status-reaper cannot run" in captured.out
|
||||
assert '"skipped": true' in captured.out
|
||||
assert '"skip_reason": "commit-list-api-error"' in captured.out
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -63,6 +63,31 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSearch_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery("WITH session_items AS").
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/session-search?q=test", bytes.NewBufferString(""))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
|
||||
|
||||
handler.SessionSearch(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on DB error, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Activity List source filter ----------
|
||||
|
||||
func TestActivityList_SourceCanvas(t *testing.T) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -602,6 +602,33 @@ func TestDelegationRecord_RejectsInvalidUUID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationRecord_DBInsertFails(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
h := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnError(fmt.Errorf("connection refused"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
body := `{"target_id":"550e8400-e29b-41d4-a716-446655440001","task":"hello","delegation_id":"del-xyz"}`
|
||||
c.Request = httptest.NewRequest("POST", "/delegations/record", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Record(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on DB insert failure, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationUpdateStatus_CompletedInsertsResultRow(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
@@ -337,7 +337,7 @@ func TestRegister_ProvisionerURLPreserved(t *testing.T) {
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-prov", "ws-prov", "http://localhost:8000", `{"name":"agent"}`, "push").
|
||||
WithArgs("ws-prov", "ws-prov", "http://localhost:8000", `{"name":"agent"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// DB returns provisioner URL (127.0.0.1) — should take precedence over agent-reported URL
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestRegisterHandler(t *testing.T) {
|
||||
|
||||
// Expect the upsert INSERT ... ON CONFLICT
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-123", "ws-123", "http://localhost:8000", `{"name":"test"}`, "push").
|
||||
WithArgs("ws-123", "ws-123", "http://localhost:8000", `{"name":"test"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect the SELECT url query (for cache URL logic)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
// kind_platform_root_integration_test.go — REAL Postgres gate for the
|
||||
// platform-agent participant kind (RFC docs/design/rfc-platform-agent.md).
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
|
||||
// go test -tags=integration ./internal/handlers/ -run Integration_PlatformKind -v
|
||||
//
|
||||
// CI: piggybacks on the handlers-postgres-integration workflow (path filter
|
||||
// includes workspace-server/internal/handlers/** and migrations/**).
|
||||
//
|
||||
// Why this is NOT a sqlmock test
|
||||
// ------------------------------
|
||||
// The invariant "a platform agent must be the org root (parent_id IS NULL),
|
||||
// which structurally also means at most one platform agent per org" is enforced
|
||||
// by the workspaces_platform_root_check CHECK constraint in migration
|
||||
// 20260606000000_workspaces_kind. sqlmock cannot execute DDL or evaluate a CHECK
|
||||
// constraint, so only a real Postgres can prove the constraint actually rejects
|
||||
// a non-root platform agent and accepts a root one. The Register handler's
|
||||
// isPlatformRootViolation()/409 path depends on this constraint firing.
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func integrationDB_PlatformKind(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
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() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestIntegration_PlatformKind_RootAllowed_NonRootRejected proves the three
|
||||
// guarantees of the kind column against a real Postgres:
|
||||
//
|
||||
// 1. a fresh workspace defaults to kind='workspace';
|
||||
// 2. a root row (parent_id IS NULL) may be kind='platform';
|
||||
// 3. a non-root row (parent_id set) may NOT be kind='platform' — the
|
||||
// workspaces_platform_root_check constraint rejects it (23514).
|
||||
func TestIntegration_PlatformKind_RootAllowed_NonRootRejected(t *testing.T) {
|
||||
conn := integrationDB_PlatformKind(t)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := fmt.Sprintf("itest-kind-%s", uuid.New().String()[:8])
|
||||
cleanup := func() {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`DELETE FROM workspaces WHERE name LIKE $1`, prefix+"%"); err != nil {
|
||||
t.Logf("cleanup (non-fatal): %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
cleanup() // pre-test hygiene in the shared integration DB
|
||||
|
||||
rootID := uuid.New().String()
|
||||
childID := uuid.New().String()
|
||||
|
||||
// 1. Default kind is 'workspace' when the column is omitted on INSERT.
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'online', NULL)
|
||||
`, rootID, prefix+"-root"); err != nil {
|
||||
t.Fatalf("seed root: %v", err)
|
||||
}
|
||||
var gotKind string
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT kind FROM workspaces WHERE id = $1`, rootID).Scan(&gotKind); err != nil {
|
||||
t.Fatalf("read kind: %v", err)
|
||||
}
|
||||
if gotKind != "workspace" {
|
||||
t.Fatalf("default kind = %q, want \"workspace\"", gotKind)
|
||||
}
|
||||
|
||||
// 2. The root row may become a platform agent.
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`UPDATE workspaces SET kind = 'platform' WHERE id = $1`, rootID); err != nil {
|
||||
t.Fatalf("promote root to platform: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// A child of the platform root (an ordinary workspace) inserts fine.
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'online', $3)
|
||||
`, childID, prefix+"-child", rootID); err != nil {
|
||||
t.Fatalf("seed child: %v", err)
|
||||
}
|
||||
|
||||
// 3. The non-root child may NOT be a platform agent — the CHECK rejects it.
|
||||
_, err := conn.ExecContext(ctx,
|
||||
`UPDATE workspaces SET kind = 'platform' WHERE id = $1`, childID)
|
||||
if err == nil {
|
||||
t.Fatalf("non-root child accepted kind='platform' — constraint did not fire")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "workspaces_platform_root_check") {
|
||||
t.Fatalf("non-root platform rejection wanted workspaces_platform_root_check, got: %v", err)
|
||||
}
|
||||
|
||||
// And the unknown-kind value is rejected by workspaces_kind_check.
|
||||
_, err = conn.ExecContext(ctx,
|
||||
`UPDATE workspaces SET kind = 'bogus' WHERE id = $1`, rootID)
|
||||
if err == nil || !strings.Contains(err.Error(), "workspaces_kind_check") {
|
||||
t.Fatalf("unknown kind wanted workspaces_kind_check rejection, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,20 @@ func (h *RegistryHandler) resolveDeliveryMode(ctx context.Context, workspaceID,
|
||||
return models.DeliveryModePush, nil
|
||||
}
|
||||
|
||||
// errPlatformNotRoot is the client-facing message when a register call tried to
|
||||
// mark a non-root workspace as a platform agent.
|
||||
const errPlatformNotRoot = "a platform agent must be the org root (parent_id must be null)"
|
||||
|
||||
// isPlatformRootViolation reports whether err is the DB rejecting a register
|
||||
// that tried to mark a non-root workspace as a platform agent (the
|
||||
// workspaces_platform_root_check CHECK constraint). The handler maps it to a
|
||||
// friendly HTTP 409 instead of a raw 500. The invariant — platform == org root,
|
||||
// which structurally also guarantees one platform agent per org — is enforced
|
||||
// race-proof at the DB level; this is just the friendly surface.
|
||||
func isPlatformRootViolation(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "workspaces_platform_root_check")
|
||||
}
|
||||
|
||||
// Returns a non-nil error suitable for including in a 400 Bad Request response.
|
||||
func validateAgentURL(rawURL string) error {
|
||||
if rawURL == "" {
|
||||
@@ -277,6 +291,14 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate explicit kind if the agent declared one; empty is allowed and
|
||||
// resolves to the row's existing value (or "workspace" default) in
|
||||
// resolveKind below. Only the platform-agent container declares 'platform'.
|
||||
if payload.Kind != "" && !models.IsValidKind(payload.Kind) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be 'workspace' or 'platform'"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// C18: prevent workspace URL hijacking on re-registration.
|
||||
@@ -390,9 +412,15 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
// the row. Without this guard, bulk deletes left tier-3 stragglers because
|
||||
// the last pre-teardown heartbeat flipped status back to 'online' after
|
||||
// Delete's UPDATE.
|
||||
// kind ($6) is the raw payload value (validated above; "" = unspecified).
|
||||
// COALESCE(NULLIF($6,''), …) means: an explicit kind wins; an unspecified
|
||||
// kind defaults to 'workspace' for a NEW row and KEEPS the existing kind on
|
||||
// re-register (so a platform agent re-registering without kind is never
|
||||
// downgraded). A non-root row asking for 'platform' is rejected by the
|
||||
// workspaces_platform_root_check constraint → friendly 409 below.
|
||||
_, err = db.DB.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5)
|
||||
INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode, kind)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5, COALESCE(NULLIF($6, ''), 'workspace'))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
url = CASE
|
||||
WHEN workspaces.url LIKE 'http://127.0.0.1%' THEN workspaces.url
|
||||
@@ -402,10 +430,15 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
status = 'online',
|
||||
last_heartbeat_at = now(),
|
||||
delivery_mode = EXCLUDED.delivery_mode,
|
||||
kind = COALESCE(NULLIF($6, ''), workspaces.kind),
|
||||
updated_at = now()
|
||||
WHERE workspaces.status IS DISTINCT FROM 'removed'
|
||||
`, payload.ID, payload.ID, urlForUpsert, agentCardStr, modeForUpsert)
|
||||
`, payload.ID, payload.ID, urlForUpsert, agentCardStr, modeForUpsert, payload.Kind)
|
||||
if err != nil {
|
||||
if isPlatformRootViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": errPlatformNotRoot})
|
||||
return
|
||||
}
|
||||
log.Printf("Registry register error: %v (id=%s)", err, payload.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
|
||||
return
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestRegister_DBError(t *testing.T) {
|
||||
|
||||
// DB insert fails
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-fail", "ws-fail", "http://localhost:8000", `{"name":"test"}`, "push").
|
||||
WithArgs("ws-fail", "ws-fail", "http://localhost:8000", `{"name":"test"}`, "push", "").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -647,7 +647,7 @@ func TestRegister_GuardAgainstResurrectingRemovedRow(t *testing.T) {
|
||||
// This regex-ish match requires the guard. If the handler ever drops
|
||||
// the clause the test fails because the emitted SQL won't match.
|
||||
mock.ExpectExec("ON CONFLICT.*WHERE workspaces.status IS DISTINCT FROM 'removed'").
|
||||
WithArgs("ws-resurrect", "ws-resurrect", "http://localhost:8000", `{"name":"x"}`, "push").
|
||||
WithArgs("ws-resurrect", "ws-resurrect", "http://localhost:8000", `{"name":"x"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0)) // 0 rows affected = correctly guarded
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs("ws-resurrect").
|
||||
@@ -917,7 +917,7 @@ func TestRegister_C18_BootstrapAllowedNoTokens(t *testing.T) {
|
||||
|
||||
// Workspace upsert proceeds normally.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-new", "ws-new", "http://localhost:9100", `{"name":"new-agent"}`, "push").
|
||||
WithArgs("ws-new", "ws-new", "http://localhost:9100", `{"name":"new-agent"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
@@ -1228,7 +1228,7 @@ func TestRegister_DBErrorResponseIsOpaque(t *testing.T) {
|
||||
|
||||
// DB upsert fails with a descriptive internal error.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`, "push").
|
||||
WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`, "push", "").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1476,7 +1476,7 @@ func TestRegister_PollMode_AcceptsEmptyURL(t *testing.T) {
|
||||
|
||||
// Upsert MUST run with empty URL (sql.NullString) and delivery_mode=poll.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"poll-agent"}`, "poll").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"poll-agent"}`, "poll", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// SELECT url for cache: returns NULL/empty for poll-mode rows. The
|
||||
@@ -1591,6 +1591,89 @@ func TestRegister_InvalidDeliveryMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_InvalidKind rejects payloads that declare an unrecognised kind —
|
||||
// only 'workspace' and 'platform' are valid. Mirrors the delivery_mode guard;
|
||||
// the rejection happens before any DB access.
|
||||
func TestRegister_InvalidKind(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"ws-x","url":"http://localhost:8000","agent_card":{"name":"a"},"kind":"bogus"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid kind: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "kind") {
|
||||
t.Errorf("expected error body to mention kind, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_PlatformKind_PersistsKind verifies that a workspace registering
|
||||
// with kind="platform" has that value written through the upsert (the platform
|
||||
// agent self-registers as the org root). The platform==root invariant itself is
|
||||
// enforced by the workspaces_platform_root_check DB constraint and exercised by
|
||||
// the integration test, which sqlmock cannot enforce.
|
||||
func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
const wsID = "ws-platform-agent"
|
||||
|
||||
// Bootstrap path — no live tokens.
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// delivery_mode="push" is set explicitly, so resolveDeliveryMode
|
||||
// short-circuits (no SELECT delivery_mode lookup). The upsert MUST carry
|
||||
// kind="platform" as the 6th arg.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, "http://localhost:9100", `{"name":"concierge"}`, "push", "platform").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://localhost:9100"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Token issuance — first-register path.
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(`SELECT platform_inbound_secret FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"platform_inbound_secret"}).AddRow(nil))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","delivery_mode":"push","kind":"platform","agent_card":{"name":"concierge"}}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("platform register: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_PollMode_PreservesExistingValue: when the row already
|
||||
// has delivery_mode=poll and the payload doesn't set it, the resolved
|
||||
// mode should be poll — i.e. "absent payload mode" must NOT silently
|
||||
@@ -1616,7 +1699,7 @@ func TestRegister_PollMode_PreservesExistingValue(t *testing.T) {
|
||||
// Upsert carries the resolved poll mode forward — even though
|
||||
// payload didn't restate it. URL still empty (poll-mode shape).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
@@ -1685,7 +1768,7 @@ func TestRegister_ExternalRuntime_DefaultsToPoll(t *testing.T) {
|
||||
AddRow(sql.NullString{}, "external"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
@@ -1744,7 +1827,7 @@ func TestRegister_KimiRuntime_DefaultsToPoll(t *testing.T) {
|
||||
AddRow(sql.NullString{}, "kimi-cli"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
@@ -1804,7 +1887,7 @@ func TestRegister_NonExternalRuntime_StillDefaultsToPush(t *testing.T) {
|
||||
AddRow(sql.NullString{}, "claude-code"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, "http://localhost:8000", `{"name":"a"}`, "push").
|
||||
WithArgs(wsID, wsID, "http://localhost:8000", `{"name":"a"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
|
||||
@@ -13,11 +13,16 @@ import (
|
||||
const DefaultMaxConcurrentTasks = 1
|
||||
|
||||
type Workspace struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Role sql.NullString `json:"role" db:"role"`
|
||||
Tier int `json:"tier" db:"tier"`
|
||||
Status string `json:"status" db:"status"`
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Role sql.NullString `json:"role" db:"role"`
|
||||
Tier int `json:"tier" db:"tier"`
|
||||
Status string `json:"status" db:"status"`
|
||||
// Kind: "workspace" (default) or "platform". A "platform" workspace is the
|
||||
// org-level concierge (the platform agent) that sits at the org root and is
|
||||
// the user's default A2A target. See migration
|
||||
// 20260606000000_workspaces_kind + RFC docs/design/rfc-platform-agent.md.
|
||||
Kind string `json:"kind" db:"kind"`
|
||||
SourceBundleID sql.NullString `json:"source_bundle_id" db:"source_bundle_id"`
|
||||
AgentCard json.RawMessage `json:"agent_card" db:"agent_card"`
|
||||
URL sql.NullString `json:"url" db:"url"`
|
||||
@@ -63,6 +68,21 @@ func IsValidDeliveryMode(s string) bool {
|
||||
return s == DeliveryModePush || s == DeliveryModePoll
|
||||
}
|
||||
|
||||
// Workspace kind constants. Matches the CHECK constraint in migration
|
||||
// 20260606000000_workspaces_kind. KindPlatform marks the org-level concierge
|
||||
// (the platform agent) which sits at the org root; see
|
||||
// docs/design/rfc-platform-agent.md.
|
||||
const (
|
||||
KindWorkspace = "workspace"
|
||||
KindPlatform = "platform"
|
||||
)
|
||||
|
||||
// IsValidKind reports whether s is a recognised workspace kind. Empty string is
|
||||
// NOT valid here — callers resolve the default (KindWorkspace) before calling.
|
||||
func IsValidKind(s string) bool {
|
||||
return s == KindWorkspace || s == KindPlatform
|
||||
}
|
||||
|
||||
type RegisterPayload struct {
|
||||
ID string `json:"id" binding:"required"`
|
||||
// URL is required for push-mode workspaces; optional / unused for
|
||||
@@ -76,6 +96,12 @@ type RegisterPayload struct {
|
||||
// value on the workspace row, or default to push for new rows".
|
||||
// When set, must be one of DeliveryModePush / DeliveryModePoll.
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
// Kind is optional. Empty string means "keep the existing value on the
|
||||
// workspace row, or default to KindWorkspace for new rows". When set, must
|
||||
// be one of KindWorkspace / KindPlatform. KindPlatform additionally requires
|
||||
// the row to be its own org root (parent_id IS NULL) and to be the only
|
||||
// platform agent in the org — enforced by the Register handler.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
type HeartbeatPayload struct {
|
||||
|
||||
@@ -34,6 +34,35 @@ func TestIsValidDeliveryMode_Invalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== IsValidKind ====================
|
||||
|
||||
func TestIsValidKind_Valid(t *testing.T) {
|
||||
for _, k := range []string{KindWorkspace, KindPlatform} {
|
||||
if !IsValidKind(k) {
|
||||
t.Errorf("IsValidKind(%q) = false, want true", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidKind_Invalid(t *testing.T) {
|
||||
cases := []struct {
|
||||
val string
|
||||
want bool
|
||||
}{
|
||||
{"", false}, // empty is not valid — callers resolve the default
|
||||
{"platforms", false}, // typo
|
||||
{"Platform", false}, // case-sensitive
|
||||
{"platform ", false}, // trailing space
|
||||
{"root", false}, // not a kind
|
||||
{"user", false}, // the user is implicit, not a workspace kind
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsValidKind(tc.val); got != tc.want {
|
||||
t.Errorf("IsValidKind(%q) = %v, want %v", tc.val, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WorkspaceStatus ====================
|
||||
|
||||
func TestWorkspaceStatus_String(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Reverse the participant-kind discriminator.
|
||||
-- Non-destructive: dropping the column makes every workspace an ordinary
|
||||
-- workspace again (the platform agent loses its marker but its row survives).
|
||||
DROP INDEX IF EXISTS idx_workspaces_kind;
|
||||
ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_platform_root_check;
|
||||
ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_kind_check;
|
||||
ALTER TABLE workspaces DROP COLUMN IF EXISTS kind;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Participant-kind discriminator for the org-level platform agent.
|
||||
-- (RFC: docs/design/rfc-platform-agent.md)
|
||||
--
|
||||
-- 'workspace' (default) = an ordinary workspace / agent.
|
||||
-- 'platform' = the org-level concierge (the "platform agent"). It is
|
||||
-- the single org root (parent_id IS NULL) and the user's
|
||||
-- default A2A chat target. Exactly one per org.
|
||||
--
|
||||
-- There is no org_id column — an "org" is the parent_id-chain root resolved by
|
||||
-- org_scope.go (orgRootID/sameOrg). "platform == org root" and "one platform
|
||||
-- agent per org" are therefore enforced in the Register/create handlers, not in
|
||||
-- pure SQL. This column is only the discriminator (default-target / billing
|
||||
-- exclusion / UX), defined once here and mirrored by the Go constants
|
||||
-- models.KindWorkspace / models.KindPlatform.
|
||||
--
|
||||
-- Backward-compatible: every existing row defaults to 'workspace'. The CHECK is
|
||||
-- added NOT VALID then validated so the ALTER can never fail on legacy data.
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'workspace';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_kind_check') THEN
|
||||
ALTER TABLE workspaces
|
||||
ADD CONSTRAINT workspaces_kind_check CHECK (kind IN ('workspace', 'platform')) NOT VALID;
|
||||
ALTER TABLE workspaces VALIDATE CONSTRAINT workspaces_kind_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- platform == org root, enforced at the DB level (race-proof). A platform agent
|
||||
-- MUST have parent_id IS NULL. Because an org is the subtree under a single
|
||||
-- parent_id IS NULL root (org_scope.go) and only a root may be 'platform', this
|
||||
-- also structurally guarantees at most ONE platform agent per org. The handler
|
||||
-- additionally pre-checks this to return a friendly 409 instead of a raw 23514.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_platform_root_check') THEN
|
||||
ALTER TABLE workspaces
|
||||
ADD CONSTRAINT workspaces_platform_root_check
|
||||
CHECK (kind <> 'platform' OR parent_id IS NULL) NOT VALID;
|
||||
ALTER TABLE workspaces VALIDATE CONSTRAINT workspaces_platform_root_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_kind ON workspaces(kind);
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user