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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Firstmate's skills live in two separate places with different audiences:
- [docs/codex-app-backend.md](docs/codex-app-backend.md) - the current blocked Codex App backend boundary and rollout contract.
- [docs/verification/runtime-backends.md](docs/verification/runtime-backends.md) - active maintainer verification for runtime backend guarantees.
- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for watching and merging GitLab merge requests on arbitrary instances.
- [docs/bitbucket-backend.md](docs/bitbucket-backend.md) - current setup, authentication, green-check policy, and merge behavior for Bitbucket Cloud as a third PR provider.
- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits.
- [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations.
- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, omp, Grok, Cursor, and unknown harness fallback.
Expand Down
36 changes: 36 additions & 0 deletions bin/fm-crew-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,24 @@ mr_read_record_bounded() { # <host> <path> <number>
FM_PR_RECORD_MERGED=$merged
}

bitbucket_read_record_bounded() { # <workspace> <repo> <number>
local record state merged
# shellcheck disable=SC2016 # The inner script expands after bash -c receives positional args.
if ! record=$(fm_run_timed 5 bash -c '
. "$1"
fm_pr_bitbucket_read_record "$2" "$3" "$4" "$5" || exit 1
printf "state=%s\nmerged=%s\n" "$FM_PR_RECORD_STATE" "$FM_PR_RECORD_MERGED"
' _ "$SCRIPT_DIR/fm-pr-lib.sh" "$FM_HOME" "$1" "$2" "$3" 2>/dev/null); then
return 1
fi
state=$(printf '%s\n' "$record" | sed -n 's/^state=//p' | head -1)
merged=$(printf '%s\n' "$record" | sed -n 's/^merged=//p' | head -1)
[ -n "$state" ] || return 1
[ "$merged" = true ] || [ "$merged" = false ] || return 1
FM_PR_RECORD_STATE=$state
FM_PR_RECORD_MERGED=$merged
}

passed_pr_detail() {
local provider url host path number owner repo raw_pr state_lc
raw_pr=$(strip_quotes "$(nm_field pr)")
Expand Down Expand Up @@ -429,6 +447,24 @@ passed_pr_detail() {
*) printf 'run passed: PR state %s' "$state_lc" ;;
esac
;;
bitbucket)
owner=${path%%/*}
repo=${path#*/}
if ! bitbucket_read_record_bounded "$owner" "$repo" "$number"; then
printf 'run passed: PR state unknown (unreadable)'
return
fi
if [ "$FM_PR_RECORD_MERGED" = true ]; then
printf 'run passed: PR merged'
return
fi
state_lc=$(printf '%s' "$FM_PR_RECORD_STATE" | tr '[:upper:]' '[:lower:]')
case "$state_lc" in
open) printf 'run passed: PR open' ;;
declined) printf 'run passed: PR closed' ;;
*) printf 'run passed: PR state %s' "$state_lc" ;;
esac
;;
*)
printf 'run passed: PR state unknown (unreadable: %s)' "$url"
;;
Expand Down
21 changes: 19 additions & 2 deletions bin/fm-pr-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
# exact pr_head=<sha> when available, then atomically arm a static merge poll.
# The watcher check source is byte-for-byte bin/fm-pr-poll.sh; task and PR data
# live only in a private sidecar and are never interpolated into shell source.
# A GitHub pull request URL and a GitLab merge request URL are both accepted,
# including a merge request on a self-hosted GitLab instance.
# A GitHub pull request URL, a GitLab merge request URL (including on a
# self-hosted instance), and a Bitbucket Cloud pull request URL are all
# accepted.
# A GitHub pull request the forge reports as a draft is refused, naming the draft
# state and recording and arming nothing: a draft cannot be merged, so a poll armed on it
# would wait for an event that cannot occur while nobody is asked to act.
Expand Down Expand Up @@ -67,6 +68,22 @@ if [ "$PROVIDER" = gitlab ] && ! command -v glab >/dev/null 2>&1; then
echo "error: watching a GitLab merge request requires glab on PATH" >&2
exit 1
fi
# The same reasoning applies to Bitbucket, which the poll reads with curl and
# jq under an access token rather than a CLI (bin/fm-pr-poll.sh, bitbucket_token).
if [ "$PROVIDER" = bitbucket ]; then
BITBUCKET_ARM_MISSING=
command -v curl >/dev/null 2>&1 || BITBUCKET_ARM_MISSING="curl"
if ! command -v jq >/dev/null 2>&1; then
BITBUCKET_ARM_MISSING="${BITBUCKET_ARM_MISSING:+$BITBUCKET_ARM_MISSING and }jq"
fi
if [ -z "$BITBUCKET_ARM_MISSING" ] && ! fm_pr_bitbucket_token "$FM_HOME" >/dev/null 2>&1; then
BITBUCKET_ARM_MISSING="a Bitbucket access token (FM_BITBUCKET_TOKEN or the home's .env)"
fi
if [ -n "$BITBUCKET_ARM_MISSING" ]; then
echo "error: watching a Bitbucket pull request requires $BITBUCKET_ARM_MISSING" >&2
exit 1
fi
fi

# The draft state is read before anything is recorded or armed. Only a positive
# draft reading refuses, because an unreadable one must not block arming.
Expand Down
181 changes: 173 additions & 8 deletions bin/fm-pr-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
# URLs before constructing task paths or performing any side effect.
#
# The stored identity is provider-tagged: provider, url, host, path, number.
# "path" is the full project path, which is owner/repository on GitHub and an
# arbitrarily nested group/subgroup/project namespace on GitLab. A GitLab
# project can sit at any depth, so no owner/repository pair can address one and
# the sidecar carries the whole path instead. GitLab also runs on self-hosted
# instances, so the host is part of that identity rather than a constant. Every
# consumer re-derives the identity from the stored URL and refuses any record
# whose parts do not reconstruct that exact URL.
# "path" is the full project path, which is owner/repository on GitHub and
# workspace/repository on Bitbucket Cloud, and an arbitrarily nested
# group/subgroup/project namespace on GitLab. A GitLab project can sit at any
# depth, so no owner/repository pair can address one and the sidecar carries
# the whole path instead. GitLab also runs on self-hosted instances, so the
# host is part of that identity rather than a constant; Bitbucket Cloud is one
# fixed host (bitbucket.org) like GitHub, and Bitbucket Server/Data Center -
# a different API on a different host - is out of scope and never parses here.
# Every consumer re-derives the identity from the stored URL and refuses any
# record whose parts do not reconstruct that exact URL.
#
# A validated exact merged result is retired through a private receipt only
# after its durable wake is appended.
Expand Down Expand Up @@ -119,13 +122,15 @@ fm_task_id_creation_valid() {
# github.com is refused here even though its shape is otherwise valid: it is
# GitHub's own host and never a GitLab instance, so a URL like
# https://github.com/o/r/-/merge_requests/1 (a typo'd or spoofed GitHub URL)
# would otherwise be armed as a GitLab watch that can never succeed.
# would otherwise be armed as a GitLab watch that can never succeed. bitbucket.org
# is refused the same way and for the same reason.
fm_pr_gitlab_host_valid() {
local host=${1-} label
local LC_ALL=C
local -a labels
[ "${#host}" -ge 1 ] && [ "${#host}" -le 253 ] || return 1
[ "$host" != github.com ] || return 1
[ "$host" != bitbucket.org ] || return 1
case "$host" in
.*|*.|*..*|*[!a-z0-9.-]*) return 1 ;;
esac
Expand Down Expand Up @@ -160,6 +165,20 @@ fm_pr_gitlab_path_valid() {
done
}

# A Bitbucket Cloud workspace or repository slug: 1-62 characters, no leading
# or trailing hyphen, no bare "." or "..", and no character outside
# [A-Za-z0-9._-]. Bitbucket Cloud is one fixed host (bitbucket.org) addressing a
# fixed two-segment workspace/repository path, unlike GitLab's arbitrarily
# nested namespace, so no per-instance host or path-depth handling is needed.
fm_pr_bitbucket_slug_valid() {
local slug=${1-}
local LC_ALL=C
[ "${#slug}" -ge 1 ] && [ "${#slug}" -le 62 ] || return 1
case "$slug" in
.|..|-*|*-|*[!A-Za-z0-9._-]*) return 1 ;;
esac
}

# Parse a canonical PR or MR URL into the provider-tagged identity. Validation
# is strict and per provider: the GitHub username and repository rules are
# unchanged, and GitLab gets its own host and namespace rules rather than a
Expand Down Expand Up @@ -195,6 +214,27 @@ fm_pr_url_parse() {
FM_PR_NUMBER=${BASH_REMATCH[3]}
return 0
fi
# Bitbucket Cloud is one fixed host addressing a fixed two-segment
# workspace/repository path (never nested and never self-hosted the way
# GitLab is), so it is checked before the generic GitLab pattern below in the
# same style github.com is: a fixed host with its own exact path shape.
pattern='^https://bitbucket\.org/([A-Za-z0-9._-]{1,62})/([A-Za-z0-9._-]{1,62})/pull-requests/([1-9][0-9]*)$'
if [[ "$raw" =~ $pattern ]]; then
fm_pr_bitbucket_slug_valid "${BASH_REMATCH[1]}" || return 1
fm_pr_bitbucket_slug_valid "${BASH_REMATCH[2]}" || return 1
FM_PR_PROVIDER=bitbucket
FM_PR_URL=$raw
FM_PR_HOST=bitbucket.org
FM_PR_PATH="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
# Consumed by bin/fm-pr-merge.sh, which addresses Bitbucket Cloud by
# workspace/repository, the same way it addresses GitHub by owner/repository.
# shellcheck disable=SC2034
FM_PR_OWNER=${BASH_REMATCH[1]}
# shellcheck disable=SC2034
FM_PR_REPO=${BASH_REMATCH[2]}
FM_PR_NUMBER=${BASH_REMATCH[3]}
return 0
fi
# The path class contains "/" and "-", so this match is greedy to the last
# "/-/merge_requests/". Any earlier separator therefore lands inside the
# captured path, where the reserved "-" segment is refused.
Expand Down Expand Up @@ -999,6 +1039,131 @@ FIELDS
FM_PR_RECORD_MERGED=$merged
}

# Bitbucket Cloud REST API v2.0 access token, read at call time in the same
# "ambient environment wins, the calling home's gitignored .env is the opt-in
# fallback" shape as the Relay pairing token and mail-plane credentials
# (docs/configuration.md "Mail plane"; bin/fm-env-lib.sh's fmx_env_get). A
# Workspace or Repository Access Token is expected; Bitbucket Cloud's deprecated
# app passwords are deliberately not supported. Unlike gh and glab, curl has no
# credential store of its own to fall back to, so a caller with no token
# configured gets a clean refusal rather than an unauthenticated request.
fm_pr_bitbucket_token() { # <fm_home>
local home=${1-} file line val
if [ -n "${FM_BITBUCKET_TOKEN:-}" ]; then
printf '%s' "$FM_BITBUCKET_TOKEN"
return 0
fi
[ -n "$home" ] || return 1
file="$home/.env"
[ -f "$file" ] || return 1
line=$(grep -E '^[[:space:]]*(export[[:space:]]+)?FM_BITBUCKET_TOKEN=' "$file" 2>/dev/null | tail -n1) || return 1
[ -n "$line" ] || return 1
val=${line#*=}
val=${val#"${val%%[![:space:]]*}"}
val=${val%"${val##*[![:space:]]}"}
case "$val" in
\"*\") val=${val#\"}; val=${val%\"} ;;
\'*\') val=${val#\'}; val=${val%\'} ;;
esac
[ -n "$val" ] || return 1
printf '%s' "$val"
}

# One authenticated GET against the Bitbucket Cloud REST API v2.0 base
# (https://api.bitbucket.org/2.0). Prints the response body on a 2xx status and
# returns nonzero on any curl failure, missing token, or non-2xx status, so a
# caller never mistakes an error page or empty body for a valid payload.
fm_pr_bitbucket_api_get() { # <fm_home> <api-path>
local home=$1 api_path=$2 token http_status body tmp cfg
command -v curl >/dev/null 2>&1 || return 1
token=$(fm_pr_bitbucket_token "$home") || return 1
[ -n "$token" ] || return 1
tmp=$(mktemp "${TMPDIR:-/tmp}/fm-pr-bitbucket.XXXXXX") || return 1
cfg=$(mktemp "${TMPDIR:-/tmp}/fm-pr-bitbucket-cfg.XXXXXX") || { rm -f "$tmp"; return 1; }
chmod 600 "$cfg" 2>/dev/null
printf 'header = "Authorization: Bearer %s"\n' "$token" > "$cfg"
http_status=$(curl -sS -K "$cfg" -o "$tmp" -w '%{http_code}' \
-H 'Accept: application/json' \
"https://api.bitbucket.org/2.0$api_path" 2>/dev/null) || { rm -f "$tmp" "$cfg"; return 1; }
rm -f "$cfg"
body=$(cat "$tmp" 2>/dev/null)
rm -f "$tmp"
case "$http_status" in
2??) ;;
*) return 1 ;;
esac
printf '%s' "$body"
}

# One authenticated POST with a JSON body against the Bitbucket Cloud REST API
# v2.0 base. Prints the response body, then the final HTTP status on its own
# trailing line ("http_status=<code>"), because the merge endpoint's 202
# (accepted, asynchronous) is a distinct outcome from its 200 (merged
# synchronously) that the caller must tell apart, and neither is an error.
fm_pr_bitbucket_api_post() { # <fm_home> <api-path> <json-body>
local home=$1 api_path=$2 json_body=$3 token http_status body tmp cfg
command -v curl >/dev/null 2>&1 || return 1
token=$(fm_pr_bitbucket_token "$home") || return 1
[ -n "$token" ] || return 1
tmp=$(mktemp "${TMPDIR:-/tmp}/fm-pr-bitbucket.XXXXXX") || return 1
cfg=$(mktemp "${TMPDIR:-/tmp}/fm-pr-bitbucket-cfg.XXXXXX") || { rm -f "$tmp"; return 1; }
chmod 600 "$cfg" 2>/dev/null
printf 'header = "Authorization: Bearer %s"\n' "$token" > "$cfg"
http_status=$(curl -sS -K "$cfg" -o "$tmp" -w '%{http_code}' -X POST \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data-binary "$json_body" \
"https://api.bitbucket.org/2.0$api_path" 2>/dev/null) || { rm -f "$tmp" "$cfg"; return 1; }
rm -f "$cfg"
body=$(cat "$tmp" 2>/dev/null)
rm -f "$tmp"
case "$http_status" in
[1-5][0-9][0-9]) ;;
*) return 1 ;;
esac
printf '%s\nhttp_status=%s\n' "$body" "$http_status"
}

fm_pr_bitbucket_read_record() { # <fm_home> <workspace> <repo> <number>
local home=$1 workspace=$2 repo=$3 number=$4 json fields line
local total=0 named=0 state='' merged=''
FM_PR_RECORD_STATE=
FM_PR_RECORD_MERGED=
command -v jq >/dev/null 2>&1 || return 1
json=$(fm_pr_bitbucket_api_get "$home" "/repositories/$workspace/$repo/pullrequests/$number") || return 1
if ! fields=$(printf '%s' "$json" | jq -r '
if type == "object" and (.state | type == "string") and .state != "" then
"state=" + .state,
"merged=" + (if .state == "MERGED" then "true" else "false" end)
else
error("invalid pull request state")
end' 2>/dev/null); then
return 1
fi
while IFS= read -r line; do
total=$((total + 1))
case "$line" in
state=*) state=${line#state=} ;;
merged=*) merged=${line#merged=} ;;
*) continue ;;
esac
named=$((named + 1))
done <<FIELDS
$fields
FIELDS
if [ "$named" -ne 2 ] || [ "$total" -ne 2 ] || [ -z "$state" ] \
|| { [ "$merged" != true ] && [ "$merged" != false ]; }; then
return 1
fi

# Consumed by bin/fm-crew-state.sh passed_pr_detail.
# shellcheck disable=SC2034
FM_PR_RECORD_STATE=$state
# Consumed by bin/fm-crew-state.sh passed_pr_detail.
# shellcheck disable=SC2034
FM_PR_RECORD_MERGED=$merged
}

fm_pr_poll_retirement_data_valid() {
local state=$1 id=$2 state_device data data_hash data_identity
state_device=$(fm_pr_file_device "$state") || return 1
Expand Down
Loading