diff --git a/README.md b/README.md index 601063d1843..fd98c872aeb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 86239e8b95b..939bdc4b388 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -361,6 +361,24 @@ mr_read_record_bounded() { # FM_PR_RECORD_MERGED=$merged } +bitbucket_read_record_bounded() { # + 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)") @@ -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" ;; diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 9c65c5084b9..f0392965ae2 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -3,8 +3,9 @@ # exact pr_head= 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. @@ -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. diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 20385f4fb3d..7f8c78160f6 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -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. @@ -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 @@ -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 @@ -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. @@ -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() { # + 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() { # + 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="), 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() { # + 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() { # + 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 <&2 exit 2 fi +if [ "${#ALLOW_RED[@]}" -gt 0 ] && [ "$PROVIDER" = bitbucket ]; then + echo "error: --allow-red does not apply to Bitbucket, where a merge already requires every present commit status to be green" >&2 + exit 2 +fi caller_has_merge_method() { local arg @@ -378,6 +409,20 @@ if [ "$PROVIDER" = github ]; then exit 1 fi fi +BITBUCKET_MISSING= +if [ "$PROVIDER" = bitbucket ]; then + command -v curl >/dev/null 2>&1 || BITBUCKET_MISSING="curl" + if ! command -v jq >/dev/null 2>&1; then + BITBUCKET_MISSING="${BITBUCKET_MISSING:+$BITBUCKET_MISSING and }jq" + fi + if [ -z "$BITBUCKET_MISSING" ] && ! fm_pr_bitbucket_token "$FM_HOME" >/dev/null 2>&1; then + BITBUCKET_MISSING="a Bitbucket access token (FM_BITBUCKET_TOKEN or the home's .env)" + fi + if [ -n "$BITBUCKET_MISSING" ]; then + echo "error: merging a Bitbucket pull request requires $BITBUCKET_MISSING" >&2 + exit 1 + fi +fi # The recorded head is read before bin/fm-pr-check.sh rewrites the metadata, # because that script re-records pr= and drops a pr_head= it cannot resolve. @@ -1115,6 +1160,303 @@ gitlab_confirm_merged() { [ "$state" = merged ] } +# Bitbucket Cloud's commit hash field is documented with the pattern +# [0-9a-f]{7,} rather than a fixed length (developer.atlassian.com's Pull +# Request and Commit Status schemas, verified 2026), so a Bitbucket head is +# validated against that pattern rather than fm_pr_head_valid's fixed 40/64. +fm_pr_bitbucket_hash_valid() { + local hash=${1-} + local LC_ALL=C + case "$hash" in + '') return 1 ;; + esac + [ "${#hash}" -ge 7 ] && [ "${#hash}" -le 64 ] || return 1 + case "$hash" in + *[!0-9a-f]*) return 1 ;; + esac +} + +# Every commit status on the given Bitbucket commit that is not SUCCESSFUL, one +# "keystate" pair per line, followed by a final "TOTAL=" line giving the +# number of statuses actually read - the caller consumes this through command +# substitution, a subshell boundary no global variable survives, so the count +# rides in the captured output instead. A FAILED or STOPPED status is not +# green, and neither is one still INPROGRESS, so a pending status also stays +# red rather than being read as passing. Exits nonzero when the statuses cannot +# be read at all (including when a page fails mid-walk), so a malformed or +# truncated answer is a failed read and never an empty red set. The total lets +# the caller tell an empty green set (every status passed) apart from no status +# having reported at all. +bitbucket_commit_statuses_not_green() { + local workspace=$1 repo=$2 commit=$3 + local page_url page_json rows line key state total=0 + page_url="/repositories/$workspace/$repo/commit/$commit/statuses?pagelen=100" + while [ -n "$page_url" ]; do + page_json=$(fm_pr_bitbucket_api_get "$FM_HOME" "$page_url") || return 1 + if ! rows=$(printf '%s' "$page_json" | jq -r ' + if type == "object" and (.values | type) == "array" then + (.values[] | "key=" + ((.key // "") | tostring) + "\t" + "state=" + ((.state // "") | tostring)), + "next=" + ((.next // "") | tostring) + else + error("invalid paginated commit-status response") + end' 2>/dev/null); then + return 1 + fi + page_url= + while IFS= read -r line; do + case "$line" in + next=*) + line=${line#next=} + case "$line" in + https://api.bitbucket.org/2.0*) page_url=${line#https://api.bitbucket.org/2.0} ;; + esac + continue + ;; + esac + key=${line%%$'\t'*} + state=${line#*$'\t'} + key=${key#key=} + state=${state#state=} + [ -n "$state" ] || return 1 + total=$((total + 1)) + [ "$state" = SUCCESSFUL ] || printf '%s\n' "${key:-(unnamed status)}" + done <&2 + return 1 + } + if ! fields=$(printf '%s' "$json" | jq -r ' + if type == "object" then + "state=" + ((.state // "") | tostring), + "head=" + ((.source.commit.hash // "") | tostring), + "destination=" + ((.destination.branch.name // "") | tostring) + else + error("pull request payload is not an object") + end' 2>/dev/null); then + echo "error: could not read the Bitbucket pull request state before merging" >&2 + return 1 + fi + while IFS= read -r line; do + total=$((total + 1)) + case "$line" in + state=*) state=${line#state=} ;; + head=*) live_head=${line#head=} ;; + destination=*) destination=${line#destination=} ;; + *) continue ;; + esac + named=$((named + 1)) + done <&2 + return 1 + fi + if ! fm_pr_bitbucket_hash_valid "$live_head"; then + echo "error: could not read the Bitbucket pull request head commit before merging" >&2 + return 1 + fi + + [ "$state" = OPEN ] \ + || refusals="$refusals - state is \"${state:-unreadable}\", not OPEN +" + + if ! raw_statuses=$(bitbucket_commit_statuses_not_green "$PR_OWNER" "$PR_REPO" "$live_head"); then + echo "error: could not read the Bitbucket commit statuses before merging" >&2 + return 1 + fi + status_total_line=$(printf '%s\n' "$raw_statuses" | tail -1) + case "$status_total_line" in + TOTAL=*) status_total=${status_total_line#TOTAL=} ;; + *) + echo "error: could not read the Bitbucket commit statuses before merging" >&2 + return 1 + ;; + esac + case "$status_total" in + ''|*[!0-9]*) + echo "error: could not read the Bitbucket commit statuses before merging" >&2 + return 1 + ;; + esac + red=$(printf '%s\n' "$raw_statuses" | sed '$d') + if [ "$status_total" -eq 0 ]; then + refusals="$refusals - no commit status has reported for the current head $live_head +" + else + while IFS= read -r name; do + [ -n "$name" ] || continue + refusals="$refusals - status '$name' is not SUCCESSFUL +" + done <&2 + printf '%s' "$refusals" >&2 + return 1 + fi + printf 'verified: %s is open, with every present commit status SUCCESSFUL at head %s\n' \ + "$URL" "$live_head" >&2 + FM_PR_MERGE_HEAD=$live_head +} + +# The merge_strategy this run requests, from the same --squash/--merge/--method +# vocabulary caller_merge_method reads, translated to Bitbucket Cloud's enum +# (merge_commit, squash, fast_forward, squash_fast_forward, rebase_fast_forward, +# rebase_merge). Bitbucket's own documented API default is merge_commit, which +# this uses when the caller names no method, unlike GitHub's squash-by-default: +# GitHub's flag vocabulary maps directly onto its own three methods, so a +# comparable default here is the provider's own rather than firstmate's GitHub +# convention. --rebase has no single unambiguous Bitbucket equivalent (rebase +# can land as either rebase_fast_forward or rebase_merge), so it is refused +# rather than guessed; --method names any of the six values directly. +bitbucket_merge_strategy() { + local caller_method + caller_method=$(caller_merge_method "$@") + case "$caller_method" in + '') printf 'merge_commit' ;; + merge) printf 'merge_commit' ;; + rebase) + echo "error: --rebase has no single defined mapping for Bitbucket; pass --method merge_commit|squash|fast_forward|squash_fast_forward|rebase_fast_forward|rebase_merge explicitly" >&2 + return 1 + ;; + merge_commit|squash|fast_forward|squash_fast_forward|rebase_fast_forward|rebase_merge) + printf '%s' "$caller_method" + ;; + *) + echo "error: unrecognised Bitbucket merge strategy '$caller_method'" >&2 + return 1 + ;; + esac +} + +# Whether the caller's own extra arguments asked to delete the source branch, +# reusing GitLab's --remove-source-branch spelling as firstmate's own +# cross-provider vocabulary for this script (nothing is literally forwarded to +# a Bitbucket CLI; there is none). reject_protected_forge_args already refuses +# this flag unless --attended-override was passed for an explicit captain +# instruction, so reaching here at all means that authority was given. +bitbucket_close_source_branch_requested() { + local arg + for arg in "$@"; do + case "$arg" in + --remove-source-branch|--remove-source-branch=true) return 0 ;; + esac + done + return 1 +} + +# POST the merge request. On a 200, the response body is the merged pull +# request object itself (a synchronous merge). On a 202, the merge is +# asynchronous: Bitbucket returns no landed outcome yet, only a pollable task, +# which is deliberately not chased here - the confirm step below and the +# already-armed background poll (bin/fm-pr-poll.sh) are what eventually observe +# state=MERGED, so this never reports a landed outcome on unproved say-so, per +# the same "confirm before reporting" contract as the GitLab path. Any other +# status (409 conflict, 555 timeout, etc.) is refused with the forge's own body +# quoted, mirroring the not-green/refusal reporting style used elsewhere here. +FM_PR_BITBUCKET_MERGE_HTTP_STATUS= +bitbucket_merge_request() { + local strategy close_source body response status + strategy=$(bitbucket_merge_strategy "$@") || return 1 + close_source=false + bitbucket_close_source_branch_requested "$@" && close_source=true + body=$(printf '{"merge_strategy":"%s","close_source_branch":%s}' "$strategy" "$close_source") + if ! response=$(fm_pr_bitbucket_api_post "$FM_HOME" \ + "/repositories/$PR_OWNER/$PR_REPO/pullrequests/$PR_NUMBER/merge" "$body"); then + echo "error: could not reach the Bitbucket merge endpoint" >&2 + return 1 + fi + status=$(printf '%s\n' "$response" | sed -n 's/^http_status=//p' | tail -1) + FM_PR_BITBUCKET_MERGE_HTTP_STATUS=$status + case "$status" in + 200|202) return 0 ;; + *) + printf 'error: the Bitbucket merge request for %s was refused (http_status=%s)\n' "$URL" "$status" >&2 + printf '%s\n' "$response" | sed '/^http_status=/d' >&2 + return 1 + ;; + esac +} + +# One live re-read after the merge call returns success, exactly as +# gitlab_confirm_merged does: only a confirmed MERGED state is reported as +# landed. An unconfirmed read - whether the merge was synchronous (200, and +# this should already agree) or asynchronous (202, and Bitbucket may still be +# working the task) - leaves the outcome unreported and the merge poll armed, +# never reporting an unproved merge. +bitbucket_confirm_merged() { + local json state + if ! json=$(fm_pr_bitbucket_api_get "$FM_HOME" "/repositories/$PR_OWNER/$PR_REPO/pullrequests/$PR_NUMBER"); then + printf 'actionable: Bitbucket accepted the merge request for %s but its landed state could not be confirmed; the merge poll remains armed\n' \ + "$URL" >&2 + return 2 + fi + if ! state=$(printf '%s' "$json" | jq -r \ + 'if type == "object" and (.state | type == "string") then .state else error("invalid state") end' \ + 2>/dev/null); then + printf 'actionable: Bitbucket accepted the merge request for %s but its landed state could not be confirmed; the merge poll remains armed\n' \ + "$URL" >&2 + return 2 + fi + if [ "$state" = MERGED ]; then + return 0 + fi + printf 'actionable: Bitbucket accepted the merge request for %s (http_status=%s) but it does not yet read back as merged (state=%s); the merge poll remains armed\n' \ + "$URL" "$FM_PR_BITBUCKET_MERGE_HTTP_STATUS" "$state" >&2 + return 2 +} + +# The merge endpoint has no head-SHA precondition (unlike gh's +# --match-head-commit and glab's --sha), so this closes the same race by hand: +# one more live read of the head immediately before the merge call, refusing if +# it no longer matches the head bitbucket_verify_mergeable verified. +bitbucket_head_unchanged() { + local json live_head + json=$(fm_pr_bitbucket_api_get "$FM_HOME" "/repositories/$PR_OWNER/$PR_REPO/pullrequests/$PR_NUMBER") || { + echo "error: could not re-read the Bitbucket pull request head immediately before merging" >&2 + return 1 + } + live_head=$(printf '%s' "$json" | jq -r \ + 'if type == "object" then (.source.commit.hash // "") else empty end' 2>/dev/null) + if ! fm_pr_bitbucket_hash_valid "$live_head"; then + echo "error: could not re-read the Bitbucket pull request head immediately before merging" >&2 + return 1 + fi + if [ "$live_head" != "$FM_PR_MERGE_HEAD" ]; then + printf 'error: refusing to merge %s: the head moved to %s after verification (verified %s); refusing rather than merging unverified commits\n' \ + "$URL" "$live_head" "$FM_PR_MERGE_HEAD" >&2 + return 1 + fi +} + # Record before either forge call. This arms the merge poll without claiming a # landed outcome, so even a provider read failure after a real merge cannot # leave teardown without the PR identity it needs to verify the result. @@ -1220,6 +1562,31 @@ case "$PROVIDER" in gitlab_confirm_merged || gitlab_confirm_rc=$? [ "$gitlab_confirm_rc" -eq 0 ] || exit 0 ;; + bitbucket) + bitbucket_verify_mergeable || exit 1 + # The away record is locked first, so this last presence and authority read + # and the forge command below share one live-owner critical section. + hold_away_record_for_merge || exit 1 + away_status=0 + require_current_away_authority || away_status=$? + [ "$away_status" -eq 0 ] || exit "$away_status" + bitbucket_head_unchanged || exit 1 + merge_status=0 + bitbucket_merge_request "$@" || merge_status=$? + if [ "$merge_status" -ne 0 ]; then + fm_afk_contract_lock_release || true + fm_lock_release "$MERGE_CONTROL_LOCK" || true + MERGE_CONTROL_LOCK= + exit "$merge_status" + fi + persist_accepted_merge_authority || exit 1 + fm_afk_contract_lock_release || true + fm_lock_release "$MERGE_CONTROL_LOCK" || true + MERGE_CONTROL_LOCK= + bitbucket_confirm_rc=0 + bitbucket_confirm_merged || bitbucket_confirm_rc=$? + [ "$bitbucket_confirm_rc" -eq 0 ] || exit 0 + ;; *) echo "error: invalid PR merge request" >&2 exit 2 diff --git a/bin/fm-pr-poll.sh b/bin/fm-pr-poll.sh index ed705ce7073..ff253d71397 100755 --- a/bin/fm-pr-poll.sh +++ b/bin/fm-pr-poll.sh @@ -4,12 +4,57 @@ # otherwise, including on every error, so a failed lookup can never be read as # a merge. The provider-tagged identity is data in the sidecar and is never # interpolated into this source: these bytes are identical for every task. -# Each provider is read through its own standard CLI, gh for GitHub and glab -# for GitLab, so an upstream checkout needs no extra tooling to follow either. +# GitHub is read through gh and GitLab through glab, so an upstream checkout +# needs no extra tooling to follow either. Bitbucket Cloud has no comparable +# CLI, so it is read with curl and jq instead, authenticated with a Workspace +# or Repository Access Token read from FM_BITBUCKET_TOKEN or the calling +# home's gitignored .env (bitbucket_token below, the same lookup +# bin/fm-pr-lib.sh's fm_pr_bitbucket_token owns); a home with neither stays +# silent rather than polling unauthenticated. FM_HOME is read from the +# environment when the caller already set it (bin/fm-watch.sh's --validated +# invocation does), and otherwise derived from this script's own path, which +# is $FM_HOME/state/.check.sh when copied out as a task's own poll. set -u LC_ALL=C export LC_ALL +bitbucket_home() { + local state_dir + if [ -n "${FM_HOME:-}" ]; then + printf '%s' "$FM_HOME" + return 0 + fi + case "$0" in + *.check.sh) + state_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd) || return 1 + [ -n "$state_dir" ] || return 1 + printf '%s' "${state_dir%/state}" + ;; + *) return 1 ;; + esac +} + +bitbucket_token() { + local home line val + if [ -n "${FM_BITBUCKET_TOKEN:-}" ]; then + printf '%s' "$FM_BITBUCKET_TOKEN" + return 0 + fi + home=$(bitbucket_home) || return 1 + [ -n "$home" ] && [ -f "$home/.env" ] || return 1 + line=$(grep -E '^[[:space:]]*(export[[:space:]]+)?FM_BITBUCKET_TOKEN=' "$home/.env" 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" +} + if [ "$#" -eq 6 ] && [ "$1" = --validated ]; then provider=$2 url=$3 @@ -105,6 +150,40 @@ case "$provider" in state=$(printf '%s\n' "$raw" | sed -n 's/^state:[[:space:]]*//p' | head -1) || exit 0 [ "$state" = merged ] && printf '%s\n' merged ;; + bitbucket) + [ "$host" = bitbucket.org ] || exit 0 + workspace=${path%%/*} + repo=${path#*/} + [ "${#workspace}" -ge 1 ] && [ "${#workspace}" -le 62 ] || exit 0 + case "$workspace" in + .|..|-*|*-|*[!A-Za-z0-9._-]*) exit 0 ;; + esac + [ "${#repo}" -ge 1 ] && [ "${#repo}" -le 62 ] || exit 0 + case "$repo" in + .|..|-*|*-|*[!A-Za-z0-9._-]*) exit 0 ;; + esac + [ "$url" = "https://bitbucket.org/$workspace/$repo/pull-requests/$number" ] || exit 0 + command -v curl >/dev/null 2>&1 || exit 0 + command -v jq >/dev/null 2>&1 || exit 0 + token=$(bitbucket_token) || exit 0 + [ -n "$token" ] || exit 0 + tmp=$(mktemp "${TMPDIR:-/tmp}/fm-pr-poll-bitbucket.XXXXXX") || exit 0 + cfg=$(mktemp "${TMPDIR:-/tmp}/fm-pr-poll-bitbucket-cfg.XXXXXX") || { rm -f "$tmp"; exit 0; } + 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/repositories/$workspace/$repo/pullrequests/$number" 2>/dev/null) \ + || { rm -f "$tmp" "$cfg"; exit 0; } + rm -f "$cfg" + case "$http_status" in + 2??) ;; + *) rm -f "$tmp"; exit 0 ;; + esac + state=$(jq -r 'if type == "object" and (.state | type == "string") then .state else empty end' "$tmp" 2>/dev/null) + rm -f "$tmp" + [ "$state" = MERGED ] && printf '%s\n' merged + ;; *) exit 0 ;; esac exit 0 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 44e8da93bcc..98b17f95fc8 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -377,6 +377,7 @@ family_for_basename() { printf '%s\n' backend-dispatch ;; fm-check-unregister.test.sh|fm-pr-check-security.test.sh|fm-pr-merge.test.sh|\ + fm-pr-bitbucket.test.sh|\ fm-pr-reviewers.test.sh|fm-pr-state.test.sh|\ fm-review-diff.test.sh|fm-teardown.test.sh|fm-x-mode.test.sh) printf '%s\n' pr-forge diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 31cf64aa43f..4536c2bd352 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -2399,7 +2399,7 @@ while :; do triage_log "PR poll for $id changed before its validated check; skipping the stale snapshot" continue fi - run_check_capture "$SCRIPT_DIR/fm-pr-poll.sh" --validated \ + FM_HOME="$FM_HOME" run_check_capture "$SCRIPT_DIR/fm-pr-poll.sh" --validated \ "$provider" "$url" "$host" "$path" "$number" || exit 1 out=$FM_CHECK_RESULT elif fm_custom_check_snapshot_prepare "$STATE" "$id"; then diff --git a/docs/architecture.md b/docs/architecture.md index b01f62dc5d5..3155c63bd8d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -367,8 +367,13 @@ These are accepted limitations, not oversights; durable authority, landing re-ve `bin/fm-afk-contract.sh` owns the lock contract, while `tests/fm-afk-contract.test.sh` and `tests/fm-pr-merge.test.sh` pin the serialization and fail-closed merge behavior. A `https:////-/merge_requests/` URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) invokes `glab mr merge -R https:///`, so the instance comes from the URL, and adds no merge-method flag because the project's own merge method applies. That path merges only after one live read of the merge request confirms it is open, mergeable, conflict-free, with blocking discussions resolved and a successful pipeline at the current head, and it binds the merge to that verified head; recorded metadata is never the authority for those conditions because a rebase leaves it stale. +A `https://bitbucket.org///pull-requests/` URL (see [docs/bitbucket-backend.md](bitbucket-backend.md)) is read and merged with curl against the Bitbucket Cloud REST API v2.0 and no CLI, authenticated with a Workspace or Repository Access Token read from `FM_BITBUCKET_TOKEN` or the calling home's gitignored `.env`. +Bitbucket Cloud exposes no required-checks flag, so "green" is a policy definition there: every present commit status on the live head must be SUCCESSFUL and none INPROGRESS or FAILED/STOPPED, with at least one status required to have reported at all, and the merge endpoint's own server-side checks are relied on for conflicts. +Bitbucket's merge endpoint has no head-SHA precondition of its own, so the head is re-read and compared immediately before the merge call, refusing if it moved. +The merge call may return synchronously (200) or asynchronously (202, a pollable task this path does not chase); either way one live re-read confirms `state=MERGED` before anything is reported landed, mirroring the GitLab path exactly. After either forge command returns, the script confirms the PR or MR actually landed, and only a confirmed landing records a landed outcome; a queued or unconfirmed request records none and leaves its poll armed. On GitLab an auto-merge-queued or unconfirmed request is reported without failing the run. +On Bitbucket an unconfirmed request after either a synchronous or asynchronous merge call is reported the same way, leaving the already-armed poll to observe the eventual landing. On GitHub an outcome that is neither merged nor queued is refused loudly and non-zero, naming the observed state, and in attended posture a base branch that requires the merge queue is refused with the concrete `--attended-override -- --auto --` retry flags its configured method requires rather than having a merge method chosen on the caller's behalf. When the forge already accepted exactly those flags and the pull request still has not entered the queue, that refusal points at the queue state to re-check instead of echoing back the flags the caller just ran. An auto-merge request is held to the same standard: `--auto` that leaves the pull request neither merged nor queued is refused rather than reported as success. diff --git a/docs/bitbucket-backend.md b/docs/bitbucket-backend.md new file mode 100644 index 00000000000..fde38632a53 --- /dev/null +++ b/docs/bitbucket-backend.md @@ -0,0 +1,79 @@ +# Bitbucket Cloud as a third PR provider + +Bitbucket Cloud is firstmate's third first-class PR provider alongside GitHub and GitLab. +It is addressed with `curl` against the Bitbucket Cloud REST API v2.0, with no CLI dependency and no MCP server, because Bitbucket Cloud has no comparable `gh`/`glab`-style CLI firstmate can rely on. +Bitbucket Server / Data Center is a different product with a different API and is explicitly out of scope; every path below is Bitbucket Cloud (`bitbucket.org`) only. + +## Identity and URL shape + +A Bitbucket Cloud pull request is addressed by `https://bitbucket.org///pull-requests/`. +`bin/fm-pr-lib.sh`'s `fm_pr_url_parse` tags it `provider=bitbucket`, `host=bitbucket.org`, `path=/`, `number=`, and additionally sets `FM_PR_OWNER`/`FM_PR_REPO` to the workspace and repository the same way a GitHub URL does. +Unlike GitLab's arbitrarily nested namespace, a Bitbucket Cloud project always sits at exactly that one two-segment path. +`fm_pr_bitbucket_slug_valid` enforces 1-62 characters, no leading or trailing hyphen, no bare `.` or `..`, and no character outside `[A-Za-z0-9._-]` for both the workspace and the repository slug. +`bitbucket.org` is refused by `fm_pr_gitlab_host_valid` the same way `github.com` already is. +So a GitLab-shaped URL under `bitbucket.org` (e.g. `.../-/merge_requests/1`) can never be armed as a self-hosted GitLab watch that can never succeed. + +## Authentication + +A Workspace or Repository Access Token is sent as `Authorization: Bearer `. +The header is written to a `chmod 600` curl config file (`curl -K -`-style, one per call) rather than passed as a literal `-H` argument, so the token never appears on the process's argv where other same-uid processes could read it (`ps`, `/proc//cmdline`); the config file is removed immediately after the call in every one of the three call sites (`fm_pr_bitbucket_api_get`, `fm_pr_bitbucket_api_post`, and `bin/fm-pr-poll.sh`'s inlined Bitbucket branch). +Bitbucket Cloud's deprecated app passwords are deliberately not supported. +`bin/fm-pr-lib.sh`'s `fm_pr_bitbucket_token` resolves the token in the same "ambient environment wins, the calling home's gitignored `.env` is the opt-in fallback" shape as the Relay pairing token and the mail-plane credentials (`docs/configuration.md` "Mail plane"). +`FM_BITBUCKET_TOKEN` in the environment wins; otherwise a `FM_BITBUCKET_TOKEN=` line (optionally `export`-prefixed, optionally quoted) in the home's `.env` is read. +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. +`bin/fm-pr-check.sh` refuses to arm a watch and `bin/fm-pr-merge.sh` refuses to merge, both before anything is recorded, exactly the way each already refuses when `glab` is missing for GitLab. + +## Reading state: PR, statuses, and green + +Two GET reads, both through `bin/fm-pr-lib.sh`'s `fm_pr_bitbucket_api_get` (` `, base `https://api.bitbucket.org/2.0`): + +- `GET /repositories/{workspace}/{repo}/pullrequests/{id}`. + `.state` is `OPEN`, `MERGED`, `DECLINED`, or `SUPERSEDED`. + `.source.commit.hash` is the head commit: a full 40-character hex SHA-1 in every response observed, though the schema's documented pattern is only `[0-9a-f]{7,}`, so `fm_pr_bitbucket_hash_valid` in `bin/fm-pr-merge.sh` accepts 7-64 lowercase hex characters rather than assuming a fixed length. + `.destination.branch.name` is the target branch, and `.merge_commit.hash` appears once merged. +- `GET /repositories/{workspace}/{repo}/commit/{sha}/statuses`. + A paginated list (`.values[]`, `.next`) of commit status objects, each with a `.key` (the status's stable identifier, Bitbucket's analogue of a GitHub check name) and a `.state` of `SUCCESSFUL`, `FAILED`, `INPROGRESS`, or `STOPPED`. + +Bitbucket Cloud exposes no required-checks flag the way GitHub's branch protection or GitLab's pipeline-required setting do, so "green" is a policy definition rather than something the forge states directly. +`bitbucket_commit_statuses_not_green` in `bin/fm-pr-merge.sh` requires every present status to be `SUCCESSFUL`, refuses on any `FAILED`, `STOPPED`, or still-`INPROGRESS` status, and also refuses when zero statuses have reported for the head at all. +That last refusal mirrors GitLab's refusal of a `null` head pipeline rather than reading total silence as vacuously green. +The merge endpoint's own server-side checks (conflicts, branch restrictions) are relied on for everything this preflight cannot see, the same way GitLab's live `has_conflicts`/`detailed_merge_status` read is more direct than what GitHub's rollup alone proves. + +## Merging: no head precondition, and a possibly-asynchronous result + +`POST /repositories/{workspace}/{repo}/pullrequests/{id}/merge` with a JSON body of `{"merge_strategy": "...", "close_source_branch": true|false}`. +Two invariants the implementation preserves, using the GitLab path's pattern rather than a weaker one: + +1. **No native required-checks flag.** Covered above: green is every present status `SUCCESSFUL`, none `INPROGRESS`/`FAILED`/`STOPPED`, and at least one status must have reported, with the merge endpoint's own checks relied on for the rest. +2. **No head-SHA precondition, and a possibly-asynchronous result.** Unlike `gh pr merge --match-head-commit` and `glab mr merge --sha`, Bitbucket's merge endpoint takes no head-binding parameter at all. + So `bitbucket_head_unchanged` in `bin/fm-pr-merge.sh` re-reads the live head immediately before the merge call and refuses if it no longer matches the head `bitbucket_verify_mergeable` verified, closing the same race by hand that the other two providers close through the forge itself. + The merge call itself may return synchronously (HTTP 200, body is the merged pull request object) or asynchronously (HTTP 202, no landed outcome yet, only a `Location` header naming a poll-able `/pullrequests/{id}/merge/task-status/{task_id}` endpoint that returns `{"task_status": "PENDING"|"SUCCESS", ...}`). + This implementation does not chase that task-status endpoint. + After the merge call returns, `bitbucket_confirm_merged` does one live re-read of the pull request itself (the same resource `bitbucket_verify_mergeable` already reads), and only `state=MERGED` is ever reported as landed, exactly mirroring `gitlab_confirm_merged`'s one-shot confirm-or-leave-the-poll-armed shape. + An unconfirmed result (whether from a synchronous 200 that has not yet propagated or a still-pending 202 task) is reported as `actionable` with the merge poll left armed. + `bin/fm-pr-poll.sh`'s own Bitbucket branch (same GET, same token resolution) is what eventually observes `state=MERGED` and produces the durable outcome, the same relationship the GitLab path already has to its poll. + +The default `merge_strategy` is `merge_commit`, Bitbucket Cloud's own documented API default. +This is not firstmate's GitHub-specific squash-by-default convention, because GitHub's own flag vocabulary maps directly onto its own three methods while Bitbucket's six-value enum does not. +`--squash` and `--merge` map to Bitbucket's `squash` and `merge_commit`. +`--method ` passes any of the six enum values (`merge_commit`, `squash`, `fast_forward`, `squash_fast_forward`, `rebase_fast_forward`, `rebase_merge`) directly. +`--rebase` has no single unambiguous Bitbucket equivalent and is refused rather than guessed. +`--allow-red` does not apply to Bitbucket, the same restriction GitLab already has, because every present status is already required green with no per-name waiver concept to reuse. + +## Watching: `bin/fm-pr-poll.sh`'s Bitbucket branch + +The static, byte-identical watcher poll (`bin/fm-pr-poll.sh`) reads GitHub through `gh` and GitLab through `glab`. +Bitbucket has no comparable CLI, so its branch uses `curl` and `jq` directly, re-deriving every URL component from the validated sidecar exactly as the other two branches do. +Because `curl` needs a token and has no credential store of its own, the poll needs to resolve `FM_HOME` to find a `.env` fallback. +It uses the environment's `FM_HOME` when the caller already set one (`bin/fm-watch.sh`'s validated invocation passes it explicitly, the one change this required outside the PR-provider files themselves), and otherwise derives it from its own path (`$FM_HOME/state/.check.sh` when copied out as a task's own poll, so the parent of the containing `state/` directory is the home). +A home with no token configured - and any other read or parse failure - stays silent exactly like a missing `glab` does for GitLab. +The poll's silence-on-every-error contract means an authentication gap must never be read as "not merged" turning into a false negative that never wakes. +It is instead simply indistinguishable from "not merged yet" until the token is supplied, the same tradeoff GitLab already accepts for a missing `glab`. + +## Bounded research this task settled + +- **Access-token type and scopes**: a Workspace or Repository Access Token (`Authorization: Bearer `), never a deprecated app password. + Reading a pull request and its statuses needs `repository`/`pullrequest` read scope, and merging needs `pullrequest:write`. +- **The exact 202 shape**: confirmed against Bitbucket Cloud's own OpenAPI-derived reference (`developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/`). + A 202 carries no body of its own but a `Location` header pointing at `/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/merge/task-status/{task_id}`, which returns `{"task_status": "PENDING", ...}` while pending and `{"task_status": "SUCCESS", "merge_result": , ...}` once done. + This implementation does not poll that endpoint (see above); it is documented here so a future change that does chase it starts from a confirmed shape rather than guessing. diff --git a/docs/configuration.md b/docs/configuration.md index 4803123e1bc..41f9a3c4c66 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1151,6 +1151,7 @@ FM_IMAP_HOST= # mail-plane IMAP server hostname FM_IMAP_PORT=993 # mail-plane IMAP server port FM_SMTP_HOST= # mail-plane SMTP server hostname FM_SMTP_PORT=465 # mail-plane SMTP server port +FM_BITBUCKET_TOKEN= # Bitbucket Cloud Workspace or Repository Access Token; .env opt-in for reading and merging Bitbucket Cloud pull requests FMX_PAIRING_TOKEN= # Relay pairing token; .env opt-in authorizes replies and eligible lifecycle actions FMX_RELAY_URL=https://myfirstmate.io # optional Relay endpoint override, mainly for local relay development FMX_ENV_FILE= # optional alternate .env file for direct Relay client invocations; bootstrap still checks $FM_HOME/.env diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index e459e95006a..ddc2dfaa87c 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -360,6 +360,10 @@ "path": "docs/fm-test-portable-shards.md", "audience": "maintainer-verification" }, + { + "path": "docs/bitbucket-backend.md", + "audience": "operator-current" + }, { "path": "docs/gitlab-merge-watch.md", "audience": "maintainer-verification" diff --git a/docs/scripts.md b/docs/scripts.md index dba766bed75..8aee6e247c0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -131,7 +131,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-contributions.sh` | Observe owned publications, retain exact-head judgments, measure required actors, and wake on maintainer signals | | `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll; refuses a GitHub draft | -| `fm-pr-merge.sh` | Record PR metadata, merge a task's canonical full GitHub or GitLab URL, then refuse an outcome it cannot prove landed or queued | +| `fm-pr-merge.sh` | Record PR metadata, merge a task's canonical full GitHub, GitLab, or Bitbucket Cloud URL, then refuse an outcome it cannot prove landed or queued | | `fm-pr-state.sh` | Read-only: print one line per GitHub pull-request blocker it can see, reporting on checks that have reported rather than verdicting merge-readiness | | `fm-pr-reviewers.sh` | Read-only: suggest reviewers from GitHub's own author mapping of recent commits on a pull request's changed files, never requesting one | | `fm-merge-outcome-lib.sh` | Publish a confirmed merge's durable, role-routed supervision outcome | diff --git a/tests/fm-pr-bitbucket.test.sh b/tests/fm-pr-bitbucket.test.sh new file mode 100644 index 00000000000..f340fee1c58 --- /dev/null +++ b/tests/fm-pr-bitbucket.test.sh @@ -0,0 +1,462 @@ +#!/usr/bin/env bash +# Tests for Bitbucket Cloud as a third PR provider: URL parsing, the token +# resolution contract, a mocked REST record read, and bin/fm-pr-merge.sh's +# Bitbucket pre-merge conditions and merge/confirm path. GitHub and GitLab +# behavior is pinned by tests/fm-pr-check-security.test.sh and +# tests/fm-pr-merge.test.sh; this file owns Bitbucket only. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +fm_git_identity fmtest fmtest@example.invalid + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-pr-lib.sh" + +PR_MERGE="$ROOT/bin/fm-pr-merge.sh" +TMP_ROOT=$(fm_test_tmproot fm-pr-bitbucket) +BASE_PATH=$PATH +JQ_BIN=$(command -v jq) || fail "these tests read Bitbucket JSON with the real jq, which was not found" + +WS=my-workspace +REPO=my-repo +BB_URL="https://bitbucket.org/$WS/$REPO/pull-requests/9" +BB_HEAD=1111111111111111111111111111111111111111 +BB_STALE_HEAD=2222222222222222222222222222222222222222 + +# --- URL parsing (bitbucket-specific edge cases beyond the shared matrix in +# tests/fm-pr-check-security.test.sh) ---------------------------------------- + +test_url_parse_basic() { + fm_pr_url_parse "$BB_URL" || fail "parser rejected a canonical Bitbucket pull request URL" + [ "$FM_PR_PROVIDER" = bitbucket ] || fail "wrong provider" + [ "$FM_PR_HOST" = bitbucket.org ] || fail "wrong host" + [ "$FM_PR_PATH" = "$WS/$REPO" ] || fail "wrong path" + [ "$FM_PR_OWNER" = "$WS" ] || fail "wrong owner (workspace)" + [ "$FM_PR_REPO" = "$REPO" ] || fail "wrong repo" + [ "$FM_PR_NUMBER" = 9 ] || fail "wrong number" + pass "parser tags a canonical Bitbucket pull request URL correctly" +} + +test_url_parse_rejects_mismatched_case() { + ! fm_pr_url_parse "https://BITBUCKET.ORG/$WS/$REPO/pull-requests/1" \ + || fail "parser accepted an uppercase bitbucket.org host" + ! fm_pr_url_parse "https://bitbucket.org/$WS/$REPO/pull-requests/01" \ + || fail "parser accepted a zero-padded pull request number" + ! fm_pr_url_parse "https://bitbucket.org/$WS/$REPO/pull-requests/" \ + || fail "parser accepted a missing pull request number" + ! fm_pr_url_parse "https://bitbucket.org//$REPO/pull-requests/1" \ + || fail "parser accepted an empty workspace segment" + pass "parser rejects malformed Bitbucket URL shapes" +} + +# --- fm_pr_bitbucket_token: environment wins, .env is the opt-in fallback -- + +test_token_environment_wins() { + local home + home="$TMP_ROOT/token-env"; mkdir -p "$home" + printf 'FM_BITBUCKET_TOKEN=from-env-file\n' > "$home/.env" + [ "$(FM_BITBUCKET_TOKEN=from-ambient-env fm_pr_bitbucket_token "$home")" = from-ambient-env ] \ + || fail "ambient environment token did not win over .env" + pass "the ambient environment token wins over .env" +} + +test_token_env_file_fallback() { + local home + home="$TMP_ROOT/token-envfile"; mkdir -p "$home" + printf 'export FM_BITBUCKET_TOKEN="quoted-token"\n' > "$home/.env" + [ "$(unset FM_BITBUCKET_TOKEN; fm_pr_bitbucket_token "$home")" = quoted-token ] \ + || fail ".env fallback did not resolve an exported, quoted token" + pass "the .env fallback resolves an exported, quoted token" +} + +test_token_absent_refuses() { + local home + home="$TMP_ROOT/token-absent"; mkdir -p "$home" + ! (unset FM_BITBUCKET_TOKEN; fm_pr_bitbucket_token "$home" >/dev/null 2>&1) \ + || fail "token resolution succeeded with neither an ambient env var nor a .env entry" + pass "no token configured is a clean refusal, never an unauthenticated request" +} + +# --- fake curl: a minimal Bitbucket Cloud REST API v2.0 double ------------- +# +# Routes on the trailing path shape ("/pullrequests/", ".../statuses", +# ".../merge") and the -X method, writes the configured body to the -o file, +# and prints the configured HTTP status the way real curl's -w '%{http_code}' +# does. Every call is logged so a test can assert on what was actually sent +# (path, method, Authorization header, and POST body). +make_fake_curl() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +ofile="" method=GET data="" url="" auth="" +while [ $# -gt 0 ]; do + case "$1" in + -o) ofile=$2; shift 2 ;; + -X) method=$2; shift 2 ;; + --data-binary) data=$2; shift 2 ;; + -H) + case "$2" in + Authorization:*) auth=$2 ;; + esac + shift 2 + ;; + -sS|-s) shift ;; + -w) shift 2 ;; + http://*|https://*) url=$1; shift ;; + *) shift ;; + esac +done +if [ -n "${FAKE_CURL_LOG:-}" ]; then + { echo "method=$method"; echo "url=$url"; echo "auth=$auth"; echo "data=$data"; } >> "$FAKE_CURL_LOG" +fi +case "$url" in + */pullrequests/*/merge) + [ -n "$ofile" ] && printf '%s' "${FAKE_MERGE_BODY:-}" > "$ofile" + printf '%s' "${FAKE_MERGE_CODE:-200}" + ;; + */statuses*) + [ -n "$ofile" ] && printf '%s' "${FAKE_STATUSES_BODY:-{\"values\":[],\"next\":null\}}" > "$ofile" + printf '%s' "${FAKE_STATUSES_CODE:-200}" + ;; + */pullrequests/*) + [ -n "$ofile" ] && printf '%s' "${FAKE_PR_BODY:-}" > "$ofile" + printf '%s' "${FAKE_PR_CODE:-200}" + ;; + *) + printf '%s' 404 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/curl" + ln -sf "$JQ_BIN" "$fakebin/jq" + printf '%s\n' "$fakebin" +} + +# --- fm_pr_bitbucket_read_record -------------------------------------------- + +test_read_record_open() { + local home fakebin + home="$TMP_ROOT/read-open"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + if ! PATH="$fakebin:$BASE_PATH" FAKE_PR_BODY='{"state":"OPEN"}' \ + FM_BITBUCKET_TOKEN=tok bash -c '. "$1"; fm_pr_bitbucket_read_record "$2" "$3" "$4" "$5"' \ + _ "$ROOT/bin/fm-pr-lib.sh" "$home" "$WS" "$REPO" 9 \ + > "$home/out" 2>/dev/null; then + fail "read record failed for an open pull request" + fi + pass "fm_pr_bitbucket_read_record reads an open pull request without error" +} + +test_read_record_merged_true_only_on_merged_state() { + local home fakebin record state merged + home="$TMP_ROOT/read-merged"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + record=$(PATH="$fakebin:$BASE_PATH" FAKE_PR_BODY='{"state":"MERGED"}' \ + FM_BITBUCKET_TOKEN=tok 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" + ' _ "$ROOT/bin/fm-pr-lib.sh" "$home" "$WS" "$REPO" 9 2>/dev/null) \ + || fail "read record failed for a merged pull request" + state=$(printf '%s\n' "$record" | sed -n 's/^state=//p') + merged=$(printf '%s\n' "$record" | sed -n 's/^merged=//p') + [ "$state" = MERGED ] || fail "wrong state for a merged pull request" + [ "$merged" = true ] || fail "merged flag was not true for state=MERGED" + + record=$(PATH="$fakebin:$BASE_PATH" FAKE_PR_BODY='{"state":"DECLINED"}' \ + FM_BITBUCKET_TOKEN=tok 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" + ' _ "$ROOT/bin/fm-pr-lib.sh" "$home" "$WS" "$REPO" 9 2>/dev/null) \ + || fail "read record failed for a declined pull request" + merged=$(printf '%s\n' "$record" | sed -n 's/^merged=//p') + [ "$merged" = false ] || fail "merged flag was true for a non-merged state" + pass "fm_pr_bitbucket_read_record reports merged=true only for state=MERGED" +} + +test_read_record_non_2xx_refuses() { + local home fakebin + home="$TMP_ROOT/read-401"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + ! PATH="$fakebin:$BASE_PATH" FAKE_PR_CODE=401 FM_BITBUCKET_TOKEN=tok bash -c ' + . "$1" + fm_pr_bitbucket_read_record "$2" "$3" "$4" "$5" + ' _ "$ROOT/bin/fm-pr-lib.sh" "$home" "$WS" "$REPO" 9 2>/dev/null \ + || fail "read record succeeded on a non-2xx HTTP status" + pass "a non-2xx HTTP status is a clean refusal, never a false read" +} + +# --- fm-pr-merge.sh: Bitbucket pre-merge conditions and merge path --------- + +make_case() { + local name=$1 case_dir fakebin + case_dir="$TMP_ROOT/$name" + fakebin="$case_dir/fakebin" + mkdir -p "$case_dir/state" "$case_dir/home/data" "$case_dir/home/config" "$fakebin" + cp "$ROOT/.tasks.toml" "$case_dir/home/.tasks.toml" + printf '%s\n' '## In flight' '' '## Queued' '' '## Done' \ + > "$case_dir/home/data/backlog.md" + fm_write_meta "$case_dir/state/task-b1.meta" \ + "window=fm-task-b1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=no-mistakes" + printf 'FM_BITBUCKET_TOKEN=test-token\n' > "$case_dir/home/.env" + printf '%s\n' "$case_dir" +} + +run_merge() { # + local case_dir=$1 + shift + FM_HOME="$case_dir/home" FM_STATE_OVERRIDE="$case_dir/state" \ + PATH="$case_dir/fakebin:$BASE_PATH" \ + "$PR_MERGE" task-b1 "$BB_URL" "$@" +} + +write_pr_and_statuses() { # [state] [status_state] + local case_dir=$1 state=${2:-OPEN} status_state=${3:-SUCCESSFUL} + printf '{"state":"%s","source":{"commit":{"hash":"%s"}},"destination":{"branch":{"name":"main"}}}\n' \ + "$state" "$BB_HEAD" > "$case_dir/pr.json" + if [ "$status_state" = NONE ]; then + printf '{"values":[],"next":null}\n' > "$case_dir/statuses.json" + else + printf '{"values":[{"key":"build","state":"%s"}],"next":null}\n' \ + "$status_state" > "$case_dir/statuses.json" + fi +} + +# The bitbucket fake for the fm-pr-merge.sh path drives PR state, statuses, and +# the merge outcome from the case directory's own JSON files, and records +# every merge POST body so a test can assert on merge_strategy/close_source_branch. +add_bitbucket_mock() { + local case_dir=$1 + cat > "$case_dir/fakebin/curl" <> "\$case_dir/curl.log" +case "\$url" in + */pullrequests/*/merge) + [ ! -e "\$case_dir/merge-fails" ] || { printf '{"type":"error","error":{"message":"conflict"}}' > "\$ofile"; printf '409'; exit 0; } + : > "\$case_dir/merge-called" + if [ -e "\$case_dir/merge-async" ]; then + [ -n "\$ofile" ] && printf '{}' > "\$ofile" + printf '202' + else + [ -n "\$ofile" ] && cat "\$case_dir/merge-result.json" > "\$ofile" + printf '200' + fi + ;; + */commit/*/statuses*) + [ -n "\$ofile" ] && cat "\$case_dir/statuses.json" > "\$ofile" + printf '200' + ;; + */pullrequests/*) + if [ -e "\$case_dir/merge-called" ] && [ -e "\$case_dir/pr-merged-after.json" ]; then + [ -n "\$ofile" ] && cat "\$case_dir/pr-merged-after.json" > "\$ofile" + else + [ -n "\$ofile" ] && cat "\$case_dir/pr.json" > "\$ofile" + fi + printf '200' + ;; + *) + printf '404' + ;; +esac +exit 0 +SH + chmod +x "$case_dir/fakebin/curl" + ln -sf "$JQ_BIN" "$case_dir/fakebin/jq" +} + +test_merge_refuses_when_not_open() { + local case_dir out rc=0 + case_dir=$(make_case merge-not-open) + add_bitbucket_mock "$case_dir" + write_pr_and_statuses "$case_dir" MERGED SUCCESSFUL + out=$(run_merge "$case_dir" 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "merge did not refuse a non-open pull request" + printf '%s\n' "$out" | grep -q 'not OPEN' \ + || fail "refusal did not name the non-open state: $out" + [ ! -e "$case_dir/merge-called" ] || fail "merge was called despite a non-open pull request" + pass "a non-open pull request refuses the merge before calling the forge" +} + +test_merge_refuses_on_red_status() { + local case_dir out rc=0 + case_dir=$(make_case merge-red-status) + add_bitbucket_mock "$case_dir" + write_pr_and_statuses "$case_dir" OPEN FAILED + out=$(run_merge "$case_dir" 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "merge did not refuse a FAILED commit status" + printf '%s\n' "$out" | grep -q "status 'build' is not SUCCESSFUL" \ + || fail "refusal did not name the failing status: $out" + [ ! -e "$case_dir/merge-called" ] || fail "merge was called despite a red status" + pass "a FAILED commit status refuses the merge" +} + +test_merge_refuses_on_inprogress_status() { + local case_dir out rc=0 + case_dir=$(make_case merge-inprogress-status) + add_bitbucket_mock "$case_dir" + write_pr_and_statuses "$case_dir" OPEN INPROGRESS + out=$(run_merge "$case_dir" 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "merge did not refuse an INPROGRESS commit status" + [ ! -e "$case_dir/merge-called" ] || fail "merge was called despite a still-running status" + pass "an INPROGRESS commit status is not green and refuses the merge" +} + +test_merge_refuses_on_no_status_reported() { + local case_dir out rc=0 + case_dir=$(make_case merge-no-status) + add_bitbucket_mock "$case_dir" + write_pr_and_statuses "$case_dir" OPEN NONE + out=$(run_merge "$case_dir" 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "merge did not refuse a head with no reported commit status" + printf '%s\n' "$out" | grep -q 'no commit status has reported' \ + || fail "refusal did not name the absent status signal: $out" + [ ! -e "$case_dir/merge-called" ] || fail "merge was called despite no reported status" + pass "total silence from commit statuses is refused rather than read as green" +} + +test_merge_succeeds_synchronously() { + local case_dir out rc=0 + case_dir=$(make_case merge-sync) + add_bitbucket_mock "$case_dir" + write_pr_and_statuses "$case_dir" OPEN SUCCESSFUL + printf '{"state":"MERGED","source":{"commit":{"hash":"%s"}},"destination":{"branch":{"name":"main"}},"merge_commit":{"hash":"deadbeef"}}\n' \ + "$BB_HEAD" > "$case_dir/merge-result.json" + cp "$case_dir/merge-result.json" "$case_dir/pr-merged-after.json" + out=$(run_merge "$case_dir" 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "a green, open, synchronously-merged pull request was not accepted: $out" + [ -e "$case_dir/merge-called" ] || fail "the merge endpoint was never called" + grep -q 'merge_strategy":"merge_commit"' "$case_dir/curl.log" \ + || fail "the default merge_strategy was not merge_commit: $(cat "$case_dir/curl.log")" + grep -qxF "pr=$BB_URL" "$case_dir/state/task-b1.meta" \ + || fail "pr= was not recorded in task metadata" + pass "a green Bitbucket pull request merges synchronously and is confirmed landed" +} + +test_merge_squash_method_selected() { + local case_dir out rc=0 + case_dir=$(make_case merge-squash) + add_bitbucket_mock "$case_dir" + write_pr_and_statuses "$case_dir" OPEN SUCCESSFUL + printf '{"state":"MERGED","source":{"commit":{"hash":"%s"}},"destination":{"branch":{"name":"main"}}}\n' \ + "$BB_HEAD" > "$case_dir/merge-result.json" + cp "$case_dir/merge-result.json" "$case_dir/pr-merged-after.json" + out=$(run_merge "$case_dir" --squash 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "a --squash merge was refused: $out" + grep -q 'merge_strategy":"squash"' "$case_dir/curl.log" \ + || fail "--squash did not select the squash merge_strategy: $(cat "$case_dir/curl.log")" + pass "--squash selects Bitbucket's squash merge_strategy" +} + +test_merge_accepted_async_leaves_poll_armed() { + local case_dir out rc=0 + case_dir=$(make_case merge-async) + add_bitbucket_mock "$case_dir" + write_pr_and_statuses "$case_dir" OPEN SUCCESSFUL + : > "$case_dir/merge-async" + # After the merge call, the confirm re-read still shows OPEN (the async task + # has not finished): only the merge POST is answered with 202. + out=$(run_merge "$case_dir" 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "an accepted-but-unconfirmed async merge should not fail the run: $out" + [ -e "$case_dir/merge-called" ] || fail "the merge endpoint was never called" + printf '%s\n' "$out" | grep -q 'does not yet read back as merged' \ + || fail "the unconfirmed outcome was not reported as actionable: $out" + grep -qxF "pr=$BB_URL" "$case_dir/state/task-b1.meta" \ + || fail "pr= was not recorded even though the merge poll must stay armed" + [ -e "$case_dir/state/task-b1.check.sh" ] \ + || fail "the merge poll was not armed for an unconfirmed async merge" + pass "an accepted asynchronous merge is never reported landed until confirmed; the poll stays armed" +} + +test_merge_head_moved_refuses() { + local case_dir out rc=0 + case_dir=$(make_case merge-head-moved) + cat > "$case_dir/fakebin/curl" <> "\$case_dir/curl.log" +calls=\$(wc -l < "\$case_dir/curl.log") +case "\$url" in + */commit/*/statuses*) + [ -n "\$ofile" ] && cat "\$case_dir/statuses.json" > "\$ofile" + printf '200' + ;; + */pullrequests/*) + # First PR read (preflight) reports the original head; every later read + # (the immediate pre-merge race check) reports a moved head. + if [ "\$calls" -le 2 ]; then + cat "\$case_dir/pr.json" > "\$ofile" + else + cat "\$case_dir/pr-moved.json" > "\$ofile" + fi + printf '200' + ;; + *) + printf '404' + ;; +esac +exit 0 +SH + chmod +x "$case_dir/fakebin/curl" + ln -sf "$JQ_BIN" "$case_dir/fakebin/jq" + write_pr_and_statuses "$case_dir" OPEN SUCCESSFUL + printf '{"state":"OPEN","source":{"commit":{"hash":"%s"}},"destination":{"branch":{"name":"main"}}}\n' \ + "$BB_STALE_HEAD" > "$case_dir/pr-moved.json" + out=$(run_merge "$case_dir" 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "merge did not refuse when the head moved between verification and merge" + printf '%s\n' "$out" | grep -q 'the head moved to' \ + || fail "refusal did not name the moved head: $out" + [ ! -e "$case_dir/merge-called" ] || fail "merge was called despite the head moving" + pass "a head that moves between verification and merge refuses rather than merging unverified commits" +} + +test_url_parse_basic +test_url_parse_rejects_mismatched_case +test_token_environment_wins +test_token_env_file_fallback +test_token_absent_refuses +test_read_record_open +test_read_record_merged_true_only_on_merged_state +test_read_record_non_2xx_refuses +test_merge_refuses_when_not_open +test_merge_refuses_on_red_status +test_merge_refuses_on_inprogress_status +test_merge_refuses_on_no_status_reported +test_merge_succeeds_synchronously +test_merge_squash_method_selected +test_merge_accepted_async_leaves_poll_armed +test_merge_head_moved_refuses diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 9ac386d9019..2792d8235c2 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -416,6 +416,33 @@ https://gitlab.com/group/sub/deep/project/-/merge_requests/42|gitlab.com|group/s https://gitlab.example.co.uk/g/p/-/merge_requests/7|gitlab.example.co.uk|g/p|7 https://code.internal/team/tools/ci-runner/-/merge_requests/123456|code.internal|team/tools/ci-runner|123456 EOF + while IFS='|' read -r url workspace repo number; do + [ -n "$url" ] || continue + fm_pr_url_parse "$url" || fail "parser rejected a canonical Bitbucket pull request URL" + [ "$FM_PR_PROVIDER" = bitbucket ] || fail "parser did not tag a Bitbucket pull request URL as bitbucket" + [ "$FM_PR_URL" = "$url" ] || fail "parser changed a canonical Bitbucket pull request URL" + [ "$FM_PR_HOST" = bitbucket.org ] || fail "parser returned wrong Bitbucket host" + [ "$FM_PR_PATH" = "$workspace/$repo" ] || fail "parser returned wrong Bitbucket project path" + [ "$FM_PR_OWNER" = "$workspace" ] || fail "parser returned wrong Bitbucket workspace" + [ "$FM_PR_REPO" = "$repo" ] || fail "parser returned wrong Bitbucket repository" + [ "$FM_PR_NUMBER" = "$number" ] || fail "parser returned wrong Bitbucket pull request number" + done <<'EOF' +https://bitbucket.org/ws/repo/pull-requests/1|ws|repo|1 +https://bitbucket.org/my-workspace/my-repo.name_1/pull-requests/42|my-workspace|my-repo.name_1|42 +https://bitbucket.org/Ws_2/Repo-Name/pull-requests/123456|Ws_2|Repo-Name|123456 +EOF + ! fm_pr_url_parse https://bitbucket.org/ws/repo/-/merge_requests/1 \ + || fail "parser accepted a GitLab-shaped path under bitbucket.org" + ! fm_pr_url_parse https://bitbucket.org/-ws/repo/pull-requests/1 \ + || fail "parser accepted a Bitbucket workspace with a leading hyphen" + ! fm_pr_url_parse https://bitbucket.org/ws/repo-/pull-requests/1 \ + || fail "parser accepted a Bitbucket repository with a trailing hyphen" + ! fm_pr_url_parse https://bitbucket.org/ws/./pull-requests/1 \ + || fail "parser accepted a bare '.' Bitbucket repository segment" + ! fm_pr_url_parse https://bitbucket.org/ws/repo/pull-requests/0 \ + || fail "parser accepted a zero Bitbucket pull request number" + ! fm_pr_url_parse https://bitbucket.org/ws/repo/pull_requests/1 \ + || fail "parser accepted a misspelled Bitbucket path segment" fm_pr_url_parse https://github.com/a/b/pull/1 || fail "parser rejected canonical URL" [ "$FM_PR_PROVIDER" = github ] || fail "parser did not tag a pull request URL as github" [ "$FM_PR_HOST" = github.com ] || fail "parser returned wrong GitHub host"