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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions product/skills/land-stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ guard before any write (label, thread-resolve, queue, merge).
Mergify reports `Merge queue is ready` until a `@mergifyio queue` comment
(or the dashboard) actually queues the PR.

Queued PRs can still be rejected later, and the queue says why. Read its
own comment instead of guessing:

```sh
python3 scripts/verify_stack.py --repo <owner/name> --queue-feedback <n> ...
```

Exit 0 means each PR is merged or in the queue. Exit 1 names each rejected
PR with its kind (`timeout`, `conflict`, `checks-failed`, `other`), the
reason Mergify gave, the checks that failed or were still running, and the
next step. Repair from that, not from the PR's own CI: a `conflict` needs a
rebase, a `checks-failed` names the check to fix, and a `timeout` is not a
defect in the PR, so requeue it and, if it repeats, compare the queue's
`checks_timeout` with how long the named check normally runs.

4. **Never batch merges without checking each result.** A merge command can
look silent on both success and some failure paths; a silent-looking run is
not proof of a merge.
Expand Down
158 changes: 158 additions & 0 deletions product/skills/land-stack/scripts/verify_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,24 @@

Queue-state exit 0: every PR merged or queued. Exit 1: a PR is not queued.
Exit 2: a PR's queue state could not be read.
After a queue write, `--queue-feedback` reads Mergify's own status comment on
each PR and says in-queue, rejected, merged, or unchecked. A rejection names
its kind (timeout, conflict, checks-failed, other), the reason Mergify gave,
the checks that were failing or still running, and what to do next.

verify_stack.py --repo owner/name --queue-feedback 7006 7007
verify_stack.py --feedback-json comments.json --queue-feedback 7006

Queue-feedback exit 0: every PR merged, in the queue, or waiting to enter it. Exit 1: a PR was
rejected. Exit 2: a PR's queue comment could not be read.
Never uses `gh pr list --head <branch>` -- discovering by branch name is the
unsafe path the skill exists to prevent.
"""
from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from typing import Callable
Expand Down Expand Up @@ -151,6 +162,135 @@ def report_queue_state(keys: list[str], entries: dict) -> int:
return 2
return 0

STATUS_MARK = "# Merge Queue Status"
PAYLOAD_RE = re.compile(r"Mergify Payload -\*-\s*(\{.*\})\s*-\*- Mergify Payload End", re.S)
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
REASON_RE = re.compile(r"## Reason\s*\n(.*?)(?=\n## |\Z)", re.S)
FAILING_CHECK_RE = re.compile(r"^- \u274c \[`([^`]+)`\]", re.M)
STILL_RUNNING_RE = re.compile(r"These checks were still running:\s*\n((?:- `[^`]+`\s*\n?)+)")

FEEDBACK_ACTIONS = {
"timeout": "A check was still running when the queue's checks_timeout ran out. Requeue with `@mergifyio queue`; if it keeps timing out, the timeout in .mergify.yml is shorter than the check's normal run time.",
"conflict": "GitHub refused the merge. Rebase onto the trunk and push; Mergify requeues on the update.",
"checks-failed": "Read the failing check named here, fix the PR, and push; Mergify requeues on the update.",
"ahead-failed": "An earlier batch failed and this PR is being checked again. No action unless it is rejected next.",
"other": "Read the reason, then fix the PR or requeue with `@mergifyio queue`.",
}


def _unchecked(reason: str) -> dict:
return {"state": "unchecked", "kind": "", "reason": reason, "action": "", "failing_checks": [],
"still_running": [], "rule": "", "eta": ""}


def _feedback_kind(reason: str) -> str:
low = reason.lower()
if "ahead in the queue failed" in low:
return "ahead-failed"
if "merge conflicts" in low:
return "conflict"
if "checks timeout" in low:
return "timeout"
if "failing checks" in low:
return "checks-failed"
return "other"


def queue_feedback(comments: list[dict], head_at: str | None = None) -> dict:
status = [c for c in comments if STATUS_MARK in (c.get("body") or "")]
if not status:
return _unchecked(f"no {STATUS_MARK[2:]} comment from Mergify")
latest = max(status, key=lambda c: c.get("id", 0))
body = latest.get("body") or ""
found = PAYLOAD_RE.search(body)
if not found:
return _unchecked("Merge Queue Status comment has no readable Mergify payload")
try:
payload = json.loads(found.group(1))
except json.JSONDecodeError as exc:
return _unchecked(f"Mergify payload is not valid JSON: {exc}")
state = payload.get("state")
text = HTML_COMMENT_RE.sub("", body)
reason_match = REASON_RE.search(text)
section = reason_match.group(1).strip() if reason_match else ""
paragraphs = [para.strip() for para in section.split("\n\n") if para.strip()]
informative = [para for para in paragraphs if not para.endswith("has been dequeued")]
reason = (informative or paragraphs or [""])[0]
running = STILL_RUNNING_RE.search(text)
still_running = re.findall(r"`([^`]+)`", running.group(1)) if running else []
result = {
"kind": _feedback_kind(section) if section else "",
"reason": reason,
"failing_checks": FAILING_CHECK_RE.findall(text),
"still_running": still_running,
"rule": payload.get("queue_rule_name") or "",
"eta": payload.get("estimated_time_of_merge") or "",
}
if state == "dequeued":
if head_at and (latest.get("updated_at") or "") and head_at > latest["updated_at"]:
result.update(state="waiting", kind="", reason="the head commit is newer than the queue's last status; Mergify will check the new head", action="")
return result
kind = result["kind"] or "other"
result.update(state="rejected", kind=kind, action=FEEDBACK_ACTIONS[kind])
return result
if state == "checking":
result.update(state="in-queue", action="")
return result
if state == "waiting":
result.update(state="waiting", reason="waiting for queue conditions (for example the new head's checks)", action="")
return result
return _unchecked(f"unrecognised Mergify queue state: {state!r}")


def fetch_queue_comments(repo: str | None, number: int) -> dict:
prefix = f"repos/{repo}" if repo else "repos/{owner}/{repo}"
pr = json.loads(subprocess.run(["gh", "api", f"{prefix}/pulls/{number}"],
capture_output=True, text=True, check=True).stdout)
pages = subprocess.run(
["gh", "api", f"{prefix}/issues/{number}/comments", "--paginate", "--jq",
'[.[]|select(.user.login=="mergify[bot]")|{id,created_at,updated_at,body}]'],
capture_output=True, text=True, check=True).stdout
comments = [row for line in pages.splitlines() if line.strip() for row in json.loads(line)]
head = subprocess.run(["gh", "api", f"{prefix}/commits/{pr['head']['sha']}", "--jq", ".commit.committer.date"],
capture_output=True, text=True, check=True).stdout.strip()
return {"merged": bool(pr.get("merged")), "comments": comments, "head_at": head}


def report_queue_feedback(keys: list[str], entries: dict) -> int:
worst = 0
for key in keys:
entry = entries.get(key)
if entry is None:
fb, merged = _unchecked("no payload for this PR"), False
else:
merged = bool(entry.get("merged")) if isinstance(entry, dict) else False
comments = entry.get("comments", []) if isinstance(entry, dict) else entry
head_at = entry.get("head_at") if isinstance(entry, dict) else None
fb = queue_feedback(comments, head_at=head_at)
if merged:
print(f"{'merged':<10} #{key}")
continue
if fb["state"] == "in-queue":
extra = f" (rule {fb['rule']}, ETA {fb['eta']})" if fb["rule"] else ""
note = f" -- {fb['reason']}" if fb["kind"] == "ahead-failed" else ""
print(f"{'in-queue':<10} #{key}{extra}{note}")
elif fb["state"] == "waiting":
print(f"{'waiting':<10} #{key} {fb['reason']}")
elif fb["state"] == "rejected":
print(f"{'rejected':<10} #{key} {fb['kind']}: {fb['reason']}")
if fb["failing_checks"]:
print(f"{'':<10} failing checks: {', '.join(fb['failing_checks'])}")
if fb["still_running"]:
print(f"{'':<10} still running at the timeout: {', '.join(fb['still_running'])}")
print(f"{'':<10} next: {fb['action']}")
worst = max(worst, 1)
else:
print(f"{'unchecked':<10} #{key} {fb['reason']}")
if worst == 0:
worst = 2
return worst


def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("numbers", nargs="*", help="PR numbers, bottom of stack first")
Expand All @@ -163,8 +303,26 @@ def main(argv: list[str] | None = None) -> int:
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("--queue-state", action="store_true", help="after a queue write: is each PR merged or queued?")
ap.add_argument("--queue-json", help="replay saved pull + check-run payloads keyed by PR for --queue-state")
ap.add_argument("--queue-feedback", action="store_true", help="read Mergify's status comment: in-queue, rejected (with reason), merged")
ap.add_argument("--feedback-json", help="replay saved Mergify comments keyed by PR for --queue-feedback")
args = ap.parse_args(argv)

if args.queue_feedback:
keys = args.numbers
if not keys:
ap.error("give PR numbers for --queue-feedback")
if args.feedback_json:
with open(args.feedback_json, encoding="utf-8") as fh:
entries = json.load(fh)
else:
entries = {}
for key in keys:
try:
entries[key] = fetch_queue_comments(args.repo, int(key))
except (subprocess.CalledProcessError, ValueError, KeyError, json.JSONDecodeError) as exc:
print(f"unchecked #{key} could not read queue comments: {exc}", file=sys.stderr)
return report_queue_feedback(keys, entries)

if args.queue_state:
keys = args.numbers
if not keys:
Expand Down
Loading
Loading