diff --git a/docs/roadmaps/entries/PMAT-5843.yaml b/docs/roadmaps/entries/PMAT-5843.yaml new file mode 100644 index 0000000000..252e4b79a5 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5843.yaml @@ -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 diff --git a/docs/roadmaps/entries/PMAT-5846.yaml b/docs/roadmaps/entries/PMAT-5846.yaml new file mode 100644 index 0000000000..e0f6dd5bc1 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5846.yaml @@ -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 diff --git a/scripts/mq_feeder.sh b/scripts/mq_feeder.sh new file mode 100755 index 0000000000..e13b82667a --- /dev/null +++ b/scripts/mq_feeder.sh @@ -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 diff --git a/scripts/systemd/mq-feeder-summary.service b/scripts/systemd/mq-feeder-summary.service new file mode 100644 index 0000000000..fef22b25d4 --- /dev/null +++ b/scripts/systemd/mq-feeder-summary.service @@ -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 diff --git a/scripts/systemd/mq-feeder-summary.timer b/scripts/systemd/mq-feeder-summary.timer new file mode 100644 index 0000000000..2048dcc0f7 --- /dev/null +++ b/scripts/systemd/mq-feeder-summary.timer @@ -0,0 +1,6 @@ +[Unit] +Description=mq-feeder summary at :45 +[Timer] +OnCalendar=*:45:00 +[Install] +WantedBy=timers.target diff --git a/scripts/systemd/mq-feeder.service b/scripts/systemd/mq-feeder.service new file mode 100644 index 0000000000..6282f5e7c5 --- /dev/null +++ b/scripts/systemd/mq-feeder.service @@ -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 diff --git a/scripts/systemd/mq-feeder.timer b/scripts/systemd/mq-feeder.timer new file mode 100644 index 0000000000..98b888a67b --- /dev/null +++ b/scripts/systemd/mq-feeder.timer @@ -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