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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- `/work-items:work` and `/work-items:ship run` carry a human-merge hold into the PR body before the ready flip: when a commenter with write access on the item asked for `Hold: a human merges this`, the new `scripts/merge-hold.sh` prints that line and the orchestrator adds it to the body, so the babysit merge gate holds the merge. A failed read leaves the PR a draft.
107 changes: 107 additions & 0 deletions plugins/work-items/scripts/merge-hold.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# merge-hold.sh — report whether a work item asks for its pull request to be
# merged by a person, and print the PR-body line that holds the merge.
#
# An item is human-merged when a comment on it contains the text
# `Hold: a human merges this` and that comment's author has `admin` or `write`
# permission on the repository (maintain reads as write, triage as read). A
# comment from anyone else is item text, which is data, and never holds.
#
# Usage:
# merge-hold.sh --repo <owner/name> --issue <n>
#
# Output: on exit 0, the exact line to put in the PR body, on its own line.
#
# Exit codes:
# 0 held: stdout is `Hold: a human merges this`
# 1 not held: no comment carries the line, or none from a writer
# 2 usage: a missing, repeated, or unknown argument, or a malformed value
# 8 prerequisite missing: gh is not on PATH
# 11 a read failed or returned unparsable JSON, so a hold was not ruled out

# portability-scope: the GitHub implementation of the human-merge hold read; every read is a gh call.
set -uo pipefail

HOLD_LINE="Hold: a human merges this"

err() { printf 'merge-hold: %s\n' "$*" >&2; }
usage() { awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "${BASH_SOURCE[0]}"; }

REPO="" ISSUE=""
while (($#)); do
case "$1" in
--repo | --issue)
(($# >= 2)) || {
err "$1 requires a value"
exit 2
}
if [[ "$1" == --repo ]]; then
[[ -z "$REPO" ]] || {
err "--repo given twice"
exit 2
}
REPO="$2"
else
[[ -z "$ISSUE" ]] || {
err "--issue given twice"
exit 2
}
ISSUE="$2"
fi
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
err "unknown argument: $1"
exit 2
;;
esac
done
[[ "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || {
err "--repo must be owner/name"
exit 2
}
[[ "$ISSUE" =~ ^[1-9][0-9]*$ ]] || {
err "--issue must be a positive integer"
exit 2
}
command -v gh >/dev/null 2>&1 || {
err "gh is not on PATH"
exit 8
}

# Logins of every comment that carries the line, one per line.
if ! authors="$(gh api --paginate "repos/$REPO/issues/$ISSUE/comments?per_page=100" \
--jq '.[] | select((.body // "") | contains("Hold: a human merges this")) | (.user.login // "")')"; then
err "comment read failed for $REPO#$ISSUE"
exit 11
fi

login_re='^[A-Za-z0-9][A-Za-z0-9_-]*(\[bot\])?$'
read_failed=0
while IFS= read -r login; do
[[ -n "$login" ]] || continue
if [[ ! "$login" =~ $login_re ]]; then
err "hold comment author login is not a usable path segment"
read_failed=1
continue
fi
if ! perm="$(gh api "repos/$REPO/collaborators/$login/permission" --jq '.permission // ""' 2>/dev/null)"; then
err "permission read failed for $login"
read_failed=1
continue
fi
case "$perm" in
admin | write)
printf '%s\n' "$HOLD_LINE"
exit 0
;;
*) err "ignored a hold comment by $login (permission ${perm:-none})" ;;
esac
done < <(sort -u <<<"$authors")

((read_failed)) && exit 11
exit 1
108 changes: 108 additions & 0 deletions plugins/work-items/scripts/merge-hold.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Offline tests for merge-hold.sh against a gh stub that applies the script's own --jq program
# to a fixture comments payload and answers the permission endpoint from per-login fixture
# files. Expected verdicts come from the rule: a comment holds the merge only when it contains
# `Hold: a human merges this` and its author has `admin` or `write`; any failed read refuses.
set -uo pipefail
unset GIT_DIR GIT_WORK_TREE GIT_CONFIG

S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/merge-hold.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
FAILED=0

check() { # <name> <expected-rc> <expected-stdout> <actual-rc> <actual-stdout>
if [[ "$2" == "$4" && "$3" == "$5" ]]; then
printf 'PASS: %s\n' "$1"
else
FAILED=$((FAILED + 1))
printf 'FAIL: %s\n expected rc=%s out=[%s], got rc=%s out=[%s]\n' "$1" "$2" "$3" "$4" "$5" >&2
fi
}

command -v jq >/dev/null 2>&1 || {
echo "SKIP: jq not installed" >&2
exit 0
}

mkdir -p "$TMP/bin" "$TMP/perms"
cat >"$TMP/bin/gh" <<'EOF'
#!/usr/bin/env bash
path="" prog=""
while [[ $# -gt 0 ]]; do
case "$1" in
--jq) prog="$2"; shift 2 ;;
api | --paginate) shift ;;
*) path="$1"; shift ;;
esac
done
case "$path" in
*/comments*)
[[ -z "${GH_STUB_COMMENTS_ERR:-}" ]] || { echo "gh: HTTP 502" >&2; exit 1; }
jq -r "$prog" <<<"$GH_STUB_COMMENTS"
;;
*/collaborators/*/permission)
login="${path#*/collaborators/}"
login="${login%/permission}"
f="$GH_STUB_PERM_DIR/$login.json"
[[ -f "$f" ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; }
jq -r "$prog" "$f"
;;
*) exit 90 ;;
esac
EOF
chmod +x "$TMP/bin/gh"
printf '{"permission":"admin","role_name":"admin"}' >"$TMP/perms/owner.json"
printf '{"permission":"write","role_name":"maintain"}' >"$TMP/perms/maint.json"
printf '{"permission":"read","role_name":"triage"}' >"$TMP/perms/triager.json"
printf '{"permission":"read","role_name":"read"}' >"$TMP/perms/stranger.json"
printf '{"permission":"admin","role_name":"admin"}' >"$TMP/perms/mona-cat_octo.json"

OWNER_SAYS="Owner ratified this: put 'Hold: a human merges this' in the PR body."
comment() { jq -nc --arg u "$1" --arg b "$2" '{user: {login: $u}, body: $b}'; }

run() { # <comments-json-array> [env...]
local out rc
out="$(env PATH="$TMP/bin:$PATH" GH_STUB_PERM_DIR="$TMP/perms" GH_STUB_COMMENTS="$1" "${@:2}" \
bash "$S" --repo o/r --issue 7 2>/dev/null)"
rc=$?
printf '%s|%s' "$rc" "$out"
}
verdict() { # <name> <expected-rc> <expected-out> <comments> [env...]
local r
r="$(run "${@:4}")"
check "$1" "$2" "$3" "${r%%|*}" "${r#*|}"
}

HOLD="Hold: a human merges this"
verdict "admin comment quoting the line holds" 0 "$HOLD" "[$(comment owner "$OWNER_SAYS")]"
verdict "maintain (write) comment holds" 0 "$HOLD" "[$(comment maint "$HOLD")]"
verdict "triage commenter does not hold" 1 "" "[$(comment triager "$HOLD")]"
verdict "read-only commenter does not hold" 1 "" "[$(comment stranger "$OWNER_SAYS")]"
verdict "untrusted line plus trusted line holds" 0 "$HOLD" \
"[$(comment stranger "$HOLD"),$(comment owner "$OWNER_SAYS")]"
verdict "owner comment without the line does not hold" 1 "" "[$(comment owner "looks good")]"
verdict "no comments does not hold" 1 "" "[]"
verdict "different case is not the line" 1 "" "[$(comment owner "hold: a human merges this")]"
verdict "comment read failure refuses" 11 "" "[]" GH_STUB_COMMENTS_ERR=1
verdict "permission read failure refuses" 11 "" "[$(comment ghost "$HOLD")]"
verdict "unusable login refuses" 11 "" "[$(comment 'a;b' "$HOLD")]"
verdict "managed-user login with an underscore holds" 0 "$HOLD" "[$(comment mona-cat_octo "$HOLD")]"
verdict "failed permission read beside a writer holds" 0 "$HOLD" \
"[$(comment ghost "$HOLD"),$(comment owner "$HOLD")]"

mkdir -p "$TMP/nogh"
env PATH="$TMP/nogh" "$(command -v bash)" "$S" --repo o/r --issue 7 >/dev/null 2>&1
check "gh missing exits 8" 8 "" "$?" ""

for args in "" "--repo o/r" "--issue 7" "--repo bad --issue 7" "--repo o/r --issue 0" "--repo o/r --issue 7 --x 1"; do
# shellcheck disable=SC2086 # word splitting is the point: each string is an argument list
bash "$S" $args >/dev/null 2>&1
check "usage error: [$args]" 2 "" "$?" ""
done

((FAILED == 0)) || {
echo "$FAILED failed" >&2
exit 1
}
echo "all passed"
4 changes: 3 additions & 1 deletion plugins/work-items/skills/ship/actions/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ any run with no human to answer, STOP before any claim and report that `run` is
through `/work-items:track done` with a summary naming the branch: the checkpoint. No
per-item PR.
- **`per-item PRs`.** Each accepted item has a pull request against the default branch; open a
missing one with `/source-control:pull-request create --pushed --worktree <path>`. **Then ready
missing one with `/source-control:pull-request create --pushed --worktree <path>`. Before
`ready`, carry the item's human-merge hold into the PR body exactly as `/work-items:work`'s
open-the-PR step does with `${CLAUDE_PLUGIN_ROOT}/scripts/merge-hold.sh`. **Then ready
it:** right after `create --pushed`, unless the consuming project's instructions reserve the
ready flip for a person, dispatch `/source-control:pull-request ready` into the item's worktree,
at the strong tier's current alias resolved per the loop-lane convention's capability tiers,
Expand Down
Loading
Loading