Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ Delivery mode and `yolo` are orthogonal.
Never merge a red PR under either setting; destructive, irreversible, and security-sensitive merges still escalate.
Without a current explicit captain instruction that states the concrete merge, that default stands, and standing `yolo` cannot authorize a red merge; section 1 owns when such an instruction overrides a Firstmate-written standing rule within its exact scope.
Load `ask-user-authority` before deciding any ask-user finding; the implementation worker never answers its own finding.
Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards.
Use `bin/fm-pr-merge.sh` for every task PR merge so its guards and outcome-aware recording apply, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards.
After an autonomous merge, give the captain a one-line full-URL or local-main outcome.

### Validate
Expand Down
209 changes: 201 additions & 8 deletions bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Merge a task's PR or MR after recording pr= and any available pr_head= through
# Merge a task's PR or MR while recording pr= and any available pr_head= through
# bin/fm-pr-check.sh, so teardown can verify landed work after squash merges.
# The full canonical URL is parsed by bin/fm-pr-lib.sh. A GitHub pull request is
# addressed through gh-axi by the derived owner and repository; a GitLab merge
Expand All @@ -8,6 +8,19 @@
#
# Merge method on GitHub defaults to --squash when the caller passes none of
# --squash, --merge, --rebase, or --method after the optional -- separator.
# After gh-axi returns success, GitHub's live state is read back and accepted
# only when the pull request is merged or in the merge queue. gh-axi's view
# surface does not expose isInMergeQueue, so this verification uses gh's
# GraphQL API and requires gh on PATH. The gh-axi success output is withheld
# until this read proves the real outcome. If the pull request remains open and
# the base branch has an effective merge_queue rule, the refusal names the
# queue's configured merge method and the exact -- --auto --<method> retry
# flags. No method is selected for the caller. A gh-axi command failure keeps
# the prior behavior of recording the PR for a later merge poll, and an outcome
# read that cannot complete records it too, because recording arms that poll
# rather than claiming a merge. Metadata is withheld only when the read
# succeeds and proves the pull request neither merged nor queued, so a
# false-success response cannot make teardown treat unlanded work as landed.
# GitLab adds no method flag at all: its merge method is the project's own
# setting, which the merge API applies, and imposing squash there would override
# that convention rather than mirror the GitHub default.
Expand Down Expand Up @@ -129,6 +142,9 @@ if [ "$PROVIDER" = gitlab ]; then
echo "error: merging a GitLab merge request requires $GITLAB_MISSING on PATH" >&2
exit 1
fi
elif ! command -v gh >/dev/null 2>&1; then
echo "error: verifying a GitHub pull request merge requires gh on PATH" >&2
exit 1
fi

# The recorded head is read before bin/fm-pr-check.sh rewrites the metadata,
Expand All @@ -138,12 +154,6 @@ if [ "$PROVIDER" = gitlab ]; then
RECORDED_HEAD=$(grep '^pr_head=' "$META" | tail -1 | cut -d= -f2- || true)
fi

"$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL"
grep -qxF "pr=$URL" "$META" || {
echo "error: PR metadata recording failed" >&2
exit 1
}

# Pre-merge conditions for a GitLab merge request, read from one live view of
# the merge request. Sets FM_PR_MERGE_HEAD to the verified head on success and
# returns non-zero after reporting every condition that failed.
Expand Down Expand Up @@ -245,15 +255,198 @@ FIELDS
FM_PR_MERGE_HEAD=$live_head
}

# Read one live GitHub pull request view after gh-axi returns. The selected
# fields distinguish a landed pull request from a merge-queue entry and retain
# the concrete state needed for a refusal. Sets the four FM_PR_GITHUB_* values
# only after all fields have been read exactly once.
FM_PR_GITHUB_STATE=
FM_PR_GITHUB_MERGED=
FM_PR_GITHUB_QUEUED=
FM_PR_GITHUB_BASE=
github_read_outcome() {
local fields line
local total=0 named=0
local state='' merged='' queued='' base=''

# shellcheck disable=SC2016 # GraphQL variables are literal query syntax.
if ! fields=$(gh api graphql \
-f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){state merged isInMergeQueue baseRefName}}}' \
-F "owner=$PR_OWNER" -F "repo=$PR_REPO" -F "number=$PR_NUMBER" \
--jq '.data.repository.pullRequest | "state=" + (.state // ""), "merged=" + (.merged | tostring), "queued=" + (.isInMergeQueue | tostring), "base=" + (.baseRefName // "")' \
2>/dev/null) || [ -z "$fields" ]; then
echo "error: could not read the GitHub pull request outcome after the merge attempt" >&2
return 1
fi
while IFS= read -r line; do
total=$((total + 1))
case "$line" in
state=*) state=${line#state=} ;;
merged=*) merged=${line#merged=} ;;
queued=*) queued=${line#queued=} ;;
base=*) base=${line#base=} ;;
*) continue ;;
esac
named=$((named + 1))
done <<FIELDS
$fields
FIELDS
if [ "$named" -ne 4 ] || [ "$total" -ne 4 ] || [ -z "$state" ] \
|| { [ "$merged" != true ] && [ "$merged" != false ]; } \
|| { [ "$queued" != true ] && [ "$queued" != false ]; } \
|| [ -z "$base" ]; then
echo "error: could not read the GitHub pull request outcome after the merge attempt" >&2
return 1
fi

FM_PR_GITHUB_STATE=$state
FM_PR_GITHUB_MERGED=$merged
FM_PR_GITHUB_QUEUED=$queued
FM_PR_GITHUB_BASE=$base
}

# Read the effective merge-queue method for the observed base branch. An
# unreadable rules response does not hide the already-concrete outcome refusal;
# it only means no queue-specific retry can be proven.
FM_PR_GITHUB_QUEUE_METHOD=
github_urlencode_path_segment() {
local LC_ALL=C input=$1 encoded='' char octet hex
while [ -n "$input" ]; do
char=${input%"${input#?}"}
input=${input#?}
case "$char" in
[-._~a-zA-Z0-9]) encoded=$encoded$char ;;
*)
printf -v octet '%d' "'$char"
[ "$octet" -ge 0 ] || octet=$((octet + 256))
printf -v hex '%02X' "$octet"
encoded=$encoded%$hex
;;
esac
done
printf '%s' "$encoded"
}

FM_PR_GITHUB_QUEUE_METHOD=
FM_PR_GITHUB_QUEUE_AMBIGUOUS=false
FM_PR_GITHUB_QUEUE_METHODS=
github_read_queue_method() {
local methods line method='' candidate count=0 branch_path
FM_PR_GITHUB_QUEUE_METHOD=
FM_PR_GITHUB_QUEUE_AMBIGUOUS=false
FM_PR_GITHUB_QUEUE_METHODS=
branch_path=$(github_urlencode_path_segment "$FM_PR_GITHUB_BASE")
if ! methods=$(gh api \
--paginate "repos/$PR_OWNER/$PR_REPO/rules/branches/$branch_path" \
--jq '.[] | select(.type == "merge_queue") | "merge_method=" + (.parameters.merge_method // "")' \
2>/dev/null); then
return 1
fi
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
merge_method=*)
candidate=${line#merge_method=}
case "$candidate" in
MERGE|SQUASH|REBASE) ;;
*) return 1 ;;
esac
if [ -z "$method" ]; then
method=$candidate
FM_PR_GITHUB_QUEUE_METHODS=$candidate
elif [ "$method" != "$candidate" ]; then
FM_PR_GITHUB_QUEUE_AMBIGUOUS=true
case ",$FM_PR_GITHUB_QUEUE_METHODS," in
*",$candidate,"*) ;;
*) FM_PR_GITHUB_QUEUE_METHODS="$FM_PR_GITHUB_QUEUE_METHODS, $candidate" ;;
esac
fi
;;
*) return 1 ;;
esac
count=$((count + 1))
done <<METHODS
$methods
METHODS
[ "$count" -gt 0 ] || return 1
[ "$FM_PR_GITHUB_QUEUE_AMBIGUOUS" = false ] || return 1
FM_PR_GITHUB_QUEUE_METHOD=$method
}

record_pr_metadata() {
if ! "$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL"; then
return 1
fi
grep -qxF "pr=$URL" "$META" || {
echo "error: PR metadata recording failed" >&2
return 1
}
}

github_report_unmerged_outcome() {
printf 'error: GitHub merge outcome was not successful: state=%s, merged=%s, isInMergeQueue=%s\n' \
"$FM_PR_GITHUB_STATE" "$FM_PR_GITHUB_MERGED" "$FM_PR_GITHUB_QUEUED" >&2
if [ "$FM_PR_GITHUB_STATE" != OPEN ] || [ "$FM_PR_GITHUB_MERGED" != false ] \
|| [ "$FM_PR_GITHUB_QUEUED" != false ]; then
return 0
fi
if github_read_queue_method; then
case "$FM_PR_GITHUB_QUEUE_METHOD" in
MERGE) queue_method=merge ;;
SQUASH) queue_method=squash ;;
REBASE) queue_method=rebase ;;
esac
printf 'error: base branch %s requires the merge queue; retry with: %s %s %s -- --auto --%s\n' \
"$FM_PR_GITHUB_BASE" "$0" "$ID" "$URL" "$queue_method" >&2
elif [ "$FM_PR_GITHUB_QUEUE_AMBIGUOUS" = true ]; then
printf 'error: base branch %s has conflicting merge queue methods (%s); exact retry flags are ambiguous\n' \
"$FM_PR_GITHUB_BASE" "$FM_PR_GITHUB_QUEUE_METHODS" >&2
fi
}

case "$PROVIDER" in
github)
merge_output=
merge_args=()
if ! caller_has_merge_method "$@"; then
merge_args=(--squash)
fi
gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@"
if merge_output=$(gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" \
"${merge_args[@]+"${merge_args[@]}"}" "$@" 2>&1); then
:
else
merge_status=$?
[ -z "$merge_output" ] || printf '%s\n' "$merge_output" >&2
record_pr_metadata || exit "$merge_status"
if github_read_outcome; then
if [ "$FM_PR_GITHUB_MERGED" != true ] && [ "$FM_PR_GITHUB_QUEUED" != true ]; then
github_report_unmerged_outcome
fi
fi
exit "$merge_status"
fi
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if ! github_read_outcome; then
# The merge call returned success, so the pull request may well have
# landed. Recording it arms the later merge poll and is not a success
# claim, so it must survive a read that proves nothing either way; the
# refusal itself is unchanged.
record_pr_metadata || :
exit 1
fi
if [ "$FM_PR_GITHUB_MERGED" = true ]; then
record_pr_metadata || exit 1
printf 'verified: %s is merged (state=%s, merged=%s, isInMergeQueue=%s)\n' \
"$URL" "$FM_PR_GITHUB_STATE" "$FM_PR_GITHUB_MERGED" "$FM_PR_GITHUB_QUEUED"
elif [ "$FM_PR_GITHUB_QUEUED" = true ]; then
record_pr_metadata || exit 1
printf 'verified: %s is queued (state=%s, merged=%s, isInMergeQueue=%s)\n' \
"$URL" "$FM_PR_GITHUB_STATE" "$FM_PR_GITHUB_MERGED" "$FM_PR_GITHUB_QUEUED"
else
github_report_unmerged_outcome
exit 1
fi
;;
gitlab)
record_pr_metadata || exit 1
gitlab_verify_mergeable || exit 1
# --sha binds the merge to the head this run verified, so a push that lands
# in between is refused by GitLab instead of merged unverified. --yes only
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,8 @@ A ship brief records its mode as a fixed machine-readable line and the spawn ref
When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull/<n>/head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning.
Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch.
This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting.
PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI.
PR-based task merges go through `bin/fm-pr-merge.sh`; for a GitHub forge call that exits zero, it records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` unless GitHub's live post-call read proves the pull request neither merged nor queued.
When that read cannot complete at all, or when GitHub's forge call fails outright, the helper still records the PR to arm its merge poll without claiming landed work, while GitLab records metadata before its guarded merge attempt.
The helper requires a full canonical URL and rejects malformed URLs or repo override flags before recording merge state.
A `https://github.com/<owner>/<repo>/pull/<n>` URL invokes `gh-axi pr merge <n> --repo <owner>/<repo>`, defaults to `--squash`, and preserves explicit merge-method flags.
A `https://<host>/<path>/-/merge_requests/<n>` URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) invokes `glab mr merge <n> -R https://<host>/<path>`, so the instance comes from the URL, and adds no merge-method flag because the project's own merge method applies.
Expand Down
2 changes: 1 addition & 1 deletion docs/gitlab-merge-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ No armed watch is lost by upgrading.

## Merging a merge request

`bin/fm-pr-merge.sh` now merges a GitLab merge request through the same recording and the same guards a GitHub pull request gets.
`bin/fm-pr-merge.sh` now merges a GitLab merge request through the shared recording helper and GitLab's own live pre-merge guards.
Every run below used a throwaway `FM_HOME`, so no live task record was touched, and a `glab` wrapper that refused any `merge` subcommand outright, so no merge could reach the forge even if a check were wrong.
That wrapper is why the open fixture merge request could be used as evidence at all: it is `mergeable` with discussions resolved, so the pipeline conditions are the only thing between it and a real merge.

Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars |
| `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls |
| `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll |
| `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub or GitLab URL |
| `fm-pr-merge.sh` | Guard and merge a task's canonical full GitHub or GitLab URL; see [architecture.md](architecture.md) for forge-specific recording and verification |
| `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode |
| `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes |
| `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort |
Expand Down
10 changes: 10 additions & 0 deletions tests/fm-pr-check-security.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ SH
cat > "$fakebin/gh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$FM_TEST_GH_LOG"
case "${1:-} ${2:-}" in
"api graphql")
printf '%s\n' \
'state=MERGED' \
'merged=true' \
'queued=false' \
'base=main'
exit 0
;;
esac
case " $* " in
*" headRefOid "*) printf '%s\n' "${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}" ;;
*" state "*)
Expand Down
Loading