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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/roadmaps/entries/PMAT-5843.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
- id: PMAT-5843
github_issue: 5843
item_type: task
title: 'MQ feeder: every 5 min enqueue the oldest green non-draft PR when the merge queue is empty'
status: planned
priority: high
assigned_to: null
created: 2026-10-21T10:55:00Z
updated: 2026-10-21T10:55:00Z
spec: null
acceptance_criteria:
- 'scripts/mq_feeder.sh --self-test green; timer live; kicked PRs re-read at 30s and skipped'
phases: []
subtasks: []
estimated_effort: null
labels:
- kind:code
notes: null
epic: 5510
19 changes: 19 additions & 0 deletions docs/roadmaps/entries/PMAT-5846.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
- id: PMAT-5846
github_issue: 5846
item_type: task
title: 'arm_pr_automerge.sh: stale guard-tree check name (default must be x86-main)'
status: planned
priority: high
assigned_to: null
created: 2026-10-21T10:13:29Z
updated: 2026-10-21T10:13:29Z
spec: null
acceptance_criteria:
- 'default GUARD_CHECK_NAME names a check run that exists on PR heads; --self-test fixtures updated'
phases: []
subtasks: []
estimated_effort: null
labels:
- kind:code
notes: null
epic: 5510
87 changes: 87 additions & 0 deletions scripts/mq_feeder.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# mq_feeder.sh -- PMAT-5843. If the merge queue on main is EMPTY, enqueue the oldest green non-draft PR
# through scripts/arm_pr_automerge.sh and post one line to the cop inbox. Run by a local systemd user timer
# every 5 min (not a workflow). Operator, verbatim (10:29Z): "a job checks MQ every 5 min; if empty, it
# enqueues the oldest green non-draft PR (arm_pr_automerge.sh) and posts to the cop inbox. Reversible, 2 h window."
# Gotcha: on a CLEAN PR `gh pr merge --squash --auto` prints "merge strategy is set by the merge queue" and
# does nothing; arm_pr_automerge.sh uses plain `gh pr merge`. This script then READS mergeQueueEntry back.
# Kill switch: touch "$STATE/disabled" (or `systemctl --user disable --now mq-feeder.timer`).
# Usage: mq_feeder.sh [--dry-run] | --self-test Exit: 0 ok/noop, 2 ENV
set -uo pipefail
REPO="${MQ_FEEDER_REPO:-paiml/aprender}"
STATE="${MQ_FEEDER_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/mq-feeder}"
INBOX="${MQ_FEEDER_INBOX:-/mnt/nvme-raid0/cop-inbox/inbox.md}"
ARM="${MQ_FEEDER_ARM:-$(dirname "$0")/arm_pr_automerge.sh}"
# guard-tree is a section inside x86-main, not a check run on PR heads (arm_pr_automerge.sh refused every PR as "absent"): judge x86-main
export GUARD_CHECK_NAME="${GUARD_CHECK_NAME:-x86-main}"
TRIES="${MQ_FEEDER_TRIES:-3}"
dry=0
Q='query($o:String!,$n:String!){repository(owner:$o,name:$n){mergeQueue(branch:"main"){entries(first:1){totalCount}} pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:ASC}){nodes{number isDraft baseRefName mergeStateStatus autoMergeRequest{enabledAt} mergeQueueEntry{position}}}}}'
# candidates JSON -> "QUEUE_BUSY" | one PR number per line, oldest first (CLEAN, non-draft, base main, not armed, not queued)
pick() {
jq -r '.data.repository as $r
| if ($r.mergeQueue.entries.totalCount // 0) > 0 then "QUEUE_BUSY"
else $r.pullRequests.nodes[] | select(.isDraft==false and .baseRefName=="main" and .mergeStateStatus=="CLEAN" and .autoMergeRequest==null and .mergeQueueEntry==null) | .number end' <<<"$1"
}
# gate_paths FILES -> 0 when any path is gate content (a gate change needs 2 non-author seats bound to the head)
gate_paths() { grep -qE '^(\.github/workflows/|ci/sections\.yml|scripts/(check_|arm_pr_automerge))' <<<"$1"; }
needs_seats() { # PR HEAD -> prints reason and returns 0 when a gate-content PR lacks 2 seats bound to HEAD
local f s; f=$(gh api "repos/$REPO/pulls/$1/files" --paginate --jq '.[].filename' 2>/dev/null) || return 1
gate_paths "$f" || return 1
s=$(gh api "repos/$REPO/pulls/$1/reviews" --paginate --jq "[.[]|select(.body|test(\"[Ss]eat\"))|select(.commit_id==\"$2\")]|length" 2>/dev/null || echo 0)
[ "${s:-0}" -ge 2 ] && return 1
echo "needs seats (gate content, ${s:-0}/2 seat reviews bound to ${2:0:10})"
}
inbox() { mkdir -p "$STATE"; printf '%s %s\n' "$(date +%s)" "$1" >> "$STATE/acts.log"; printf '%s | %s | %s | %s\n' "$(date -u +%H:%MZ)" mq-feeder PMAT-5843 "$1" >> "$INBOX"; }
if [ "${1:-}" = --self-test ]; then
fail=0
mk() { printf '{"data":{"repository":{"mergeQueue":{"entries":{"totalCount":%s}},"pullRequests":{"nodes":[%s]}}}}' "$1" "$2"; }
pr() { printf '{"number":%s,"isDraft":%s,"baseRefName":"%s","mergeStateStatus":"%s","autoMergeRequest":%s,"mergeQueueEntry":null}' "$1" "$2" "$3" "$4" "$5"; }
eq() { [ "$2" = "$3" ] || { echo "FAIL $1: got '$2' want '$3'"; fail=1; }; }
eq 'busy queue -> nothing enqueued' "$(pick "$(mk 2 "$(pr 1 false main CLEAN null)")")" QUEUE_BUSY
eq 'oldest clean first' "$(pick "$(mk 0 "$(pr 7 false main CLEAN null),$(pr 9 false main CLEAN null)")" | head -1)" 7
eq 'draft skipped' "$(pick "$(mk 0 "$(pr 7 true main CLEAN null),$(pr 9 false main CLEAN null)")" | head -1)" 9
eq 'BEHIND/BLOCKED/DIRTY skipped' "$(pick "$(mk 0 "$(pr 1 false main BEHIND null),$(pr 2 false main BLOCKED null),$(pr 3 false main DIRTY null)")")" ''
eq 'non-main base skipped' "$(pick "$(mk 0 "$(pr 4 false feat CLEAN null)")")" ''
eq 'already armed skipped' "$(pick "$(mk 0 "$(pr 5 false main CLEAN '{"enabledAt":"x"}')")")" ''
eq 'no PRs' "$(pick "$(mk 0 '')")" ''
gate_paths $'docs/x.md\nscripts/check_foo.sh' || { echo "FAIL gate_paths check_"; fail=1; }
gate_paths $'docs/x.md\ncrates/a/src/lib.rs' && { echo "FAIL gate_paths non-gate"; fail=1; }
[ "$fail" -eq 0 ] && echo "ok: self-test"; exit "$fail"
fi
[ "${1:-}" = --dry-run ] && dry=1
if [ "${1:-}" = --summary ]; then
mkdir -p "$STATE"; since=$(($(date +%s) - 3600))
t=$(awk -v s="$since" '$1>=s' "$STATE/ticks.log" 2>/dev/null | wc -l); e=$(awk -v s="$since" '$1>=s && /enqueued oldest/' "$STATE/acts.log" 2>/dev/null | wc -l); k=$(awk -v s="$since" '$1>=s && /KICKED/' "$STATE/acts.log" 2>/dev/null | wc -l)
inbox "summary 1h: $t ticks, $e enqueued, $k kicked"; exit 0
fi
mkdir -p "$STATE" || exit 2
[ -e "$STATE/disabled" ] && exit 0
date +%s >> "$STATE/ticks.log"
rem=$(gh api rate_limit --jq .resources.graphql.remaining 2>/dev/null) || exit 2
[ "${rem:-0}" -ge 500 ] || exit 0
json=$(gh api graphql -f query="$Q" -f o="${REPO%/*}" -f n="${REPO#*/}" 2>/dev/null) || exit 2
cands=$(pick "$json") || exit 2
[ "$cands" = QUEUE_BUSY ] && exit 0
[ -n "$cands" ] || exit 0
n=0 why=""
for pr in $cands; do
grep -qx "$pr" "$STATE/kicked" 2>/dev/null && continue # kicked by the queue once: the owner must push a fix and clear it
n=$((n + 1)); [ "$n" -le "$TRIES" ] || break
head=$(gh pr view "$pr" --repo "$REPO" --json headRefOid --jq .headRefOid 2>/dev/null || true)
if [ -n "$head" ] && r=$(needs_seats "$pr" "$head"); then why="$why #$pr: $r;"; printf '%s skip #%s %s\n' "$(date +%s)" "$pr" "$r" >> "$STATE/acts.log"; continue; fi
mc=$(gh api "repos/$REPO/pulls/$pr/commits" --paginate --jq '[.[]|select((.parents|length)>1)]|length' 2>/dev/null | paste -sd+ | bc 2>/dev/null || true)
if [ "${mc:-0}" -gt 0 ] 2>/dev/null; then r="has $mc merge commit(s); REBASE needs clean commits (operator 07:57Z)"; why="$why #$pr: $r;"; printf '%s skip #%s %s\n' "$(date +%s)" "$pr" "$r" >> "$STATE/acts.log"; continue; fi
if [ "$dry" = 1 ]; then echo "would arm #$pr"; exit 0; fi
out=$(bash "$ARM" "$pr" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then
sleep "${MQ_FEEDER_RECHECK:-30}"
st=$(gh api graphql -f query='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){state mergeQueueEntry{position} timelineItems(last:3,itemTypes:[REMOVED_FROM_MERGE_QUEUE_EVENT]){nodes{... on RemovedFromMergeQueueEvent{reason createdAt}}}}}}' -f o="${REPO%/*}" -f n="${REPO#*/}" -F p="$pr" --jq '.data.repository.pullRequest | if .state=="MERGED" then "merged" elif .mergeQueueEntry then "queued pos \(.mergeQueueEntry.position)" else "KICKED: \(.timelineItems.nodes[-1].reason // "unknown")" end' 2>/dev/null || echo unread)
case "$st" in KICKED*) echo "$pr" >> "$STATE/kicked"; inbox "queue was empty: enqueued #$pr, then $st within ${MQ_FEEDER_RECHECK:-30}s; skipped until removed from $STATE/kicked" ;; *) inbox "queue was empty: enqueued oldest green PR #$pr ($st)" ;; esac
exit 0
fi
if [ "$rc" -eq 3 ]; then why="$why #$pr: $(printf '%s' "$out" | grep -m1 -E "^refuse" | cut -c1-110);"; printf '%s skip #%s %s\n' "$(date +%s)" "$pr" "$(printf '%s' "$out" | grep -m1 -E "^refuse" | cut -c1-110)" >> "$STATE/acts.log"; continue; fi
inbox "queue empty, arm of #$pr failed rc=$rc: $(printf '%s' "$out" | tail -n 1 | cut -c1-120)"; exit 0
done
inbox "queue empty, no PR enqueued: $((n > TRIES ? TRIES : n)) CLEAN candidate(s) refused:$why"
exit 0
5 changes: 5 additions & 0 deletions scripts/systemd/mq-feeder-summary.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[Unit]
Description=mq-feeder hourly summary line (PMAT-5843)
[Service]
Type=oneshot
ExecStart=/mnt/nvme-raid0/wt/w5382-mqf/scripts/mq_feeder.sh --summary
6 changes: 6 additions & 0 deletions scripts/systemd/mq-feeder-summary.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[Unit]
Description=mq-feeder summary at :45
[Timer]
OnCalendar=*:45:00
[Install]
WantedBy=timers.target
5 changes: 5 additions & 0 deletions scripts/systemd/mq-feeder.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[Unit]
Description=mq-feeder: enqueue the oldest green PR when the merge queue is empty (PMAT-5843)
[Service]
Type=oneshot
ExecStart=/mnt/nvme-raid0/wt/w5382-mqf/scripts/mq_feeder.sh
8 changes: 8 additions & 0 deletions scripts/systemd/mq-feeder.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[Unit]
Description=mq-feeder tick, every 5 min (PMAT-5843)
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=10s
[Install]
WantedBy=timers.target
Loading