-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbb
More file actions
executable file
·3613 lines (3276 loc) · 147 KB
/
Copy pathbb
File metadata and controls
executable file
·3613 lines (3276 loc) · 147 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
#
# bb - Bitbucket CLI wrapper
#
# A local CLI for Bitbucket Cloud that wraps the REST API.
# Requires: curl, jq
#
# Configuration:
# Set BB_USER, BB_TOKEN, and BB_WORKSPACE in ~/.config/bb/config
# or as environment variables.
#
# BB_WORKSPACE must be set (no default).
#
# Token setup:
# 1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
# 2. Create an API token
# 3. Set BB_USER to your Bitbucket email address
# 4. Set BB_TOKEN to the generated token
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BB_API="https://api.bitbucket.org/2.0"
# --- Config loading ---
load_config() {
# Snapshot env-provided values BEFORE sourcing the config files.
# `source ~/.config/bb/config` runs `BB_USER=...` etc., which would
# otherwise clobber values the user exported in their shell —
# inverting the documented precedence (env vars are meant to be
# highest priority) and diverging from the Python bb_api.load_config,
# which resolves env first. We re-apply these snapshots after
# sourcing so the env still wins.
local _env_user="${BB_USER:-}" _env_token="${BB_TOKEN:-}"
local _env_ws="${BB_WORKSPACE:-}" _env_api="${BB_API_BASE:-}"
if [[ -f "$HOME/.config/bb/config" ]]; then
# shellcheck source=/dev/null
source "$HOME/.config/bb/config"
fi
if [[ -f "$SCRIPT_DIR/.env" ]]; then
# shellcheck source=/dev/null
source "$SCRIPT_DIR/.env"
fi
# Re-apply env snapshots (highest priority), matching the documented
# order and the Python resolve() behaviour.
[[ -n "$_env_user" ]] && BB_USER="$_env_user"
[[ -n "$_env_token" ]] && BB_TOKEN="$_env_token"
[[ -n "$_env_ws" ]] && BB_WORKSPACE="$_env_ws"
[[ -n "$_env_api" ]] && BB_API_BASE="$_env_api"
# Wire BB_API_BASE into the base URL every curl call uses. Without
# this the variable was snapshotted + re-applied but never consulted,
# so `BB_API_BASE=https://staging... bb prs` silently hit production
# — and bb_api.py honours it, so the CLI was the odd one out. Strip a
# trailing slash to match Python's api_base.rstrip("/") normalisation
# (avoids "//path").
if [[ -n "${BB_API_BASE:-}" ]]; then
BB_API="${BB_API_BASE%/}"
fi
# BB_WORKSPACE is now OPTIONAL: when running inside a Bitbucket git
# checkout, resolve_repo auto-detects the workspace from the origin
# remote, and the -w/--workspace flag or a "workspace/slug" argument
# can supply it per-command. Only BB_USER + BB_TOKEN are mandatory
# (auth). A command that needs a workspace but can't resolve one
# from any source fails at that point (resolve_repo / repo_path)
# with a clear, actionable message.
if [[ -z "${BB_USER:-}" || -z "${BB_TOKEN:-}" ]]; then
echo "Error: BB_USER and BB_TOKEN must be set." >&2
echo "" >&2
echo "Quick setup:" >&2
echo " mkdir -p ~/.config/bb" >&2
echo " cat > ~/.config/bb/config <<EOF" >&2
echo "BB_USER=your-email@example.com" >&2
echo "BB_TOKEN=your-api-token" >&2
echo "BB_WORKSPACE=your-workspace # optional — auto-detected in a git checkout" >&2
echo "EOF" >&2
echo "" >&2
echo "Create an API token at:" >&2
echo " https://id.atlassian.com/manage-profile/security/api-tokens" >&2
echo "" >&2
echo "BB_USER is your Bitbucket account email address." >&2
exit 1
fi
}
# --- API helpers ---
#
# curl's `-f` is deliberately absent from every request below. `-f`
# suppresses the response body on 4xx/5xx, and Bitbucket's error body is
# the only place the actual cause appears: a 403 names the exact scope
# the token is missing AND the scopes it carries, under
# `error.detail.{required,granted}`. Discarding that leaves a bare exit
# code, which is why commands used to compensate with hardcoded guesses
# ("if this is a 403, the token probably lacks X") that were speculative
# and wrong whenever the real cause differed (expired token, wrong
# workspace slug, deleted resource, rate limit).
#
# `-f`'s exit-code contract is preserved so callers do not have to
# change: HTTP >= 400 returns 22, transport failures (DNS / TLS /
# connection refused) return curl's own exit code. Only stderr gains the
# real message.
#
# --fail-with-body would do this in one flag but needs curl 7.76+ (2021);
# the status is captured via `-w` instead so the floor stays where the
# rest of the script's (bash 3.2 / macOS system tooling) floor is.
# Redact credential-shaped substrings from anything echoed to the
# terminal: the token itself, and URL-embedded `user:secret@host` forms
# an upstream proxy or redirect target could echo back. Mirrors the
# Python `_safe_text` chokepoint, so a leak through an error body needs
# a new vector on both surfaces rather than just one.
_redact() {
local text="$1"
if [[ -n "${BB_TOKEN:-}" ]]; then
text="${text//"$BB_TOKEN"/[redacted]}"
fi
printf '%s' "$text" | sed -E 's#([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@[:space:]]+:[^/@[:space:]]*@#\1[redacted]@#g'
}
# Print the API's own explanation of a failed request to stderr.
# Bitbucket's envelope is {"type":"error","error":{"message":…,"detail":…}}
# where `detail` is either a string or, for a scope denial, the object
# {"required":[…],"granted":[…]} — the single most actionable payload the
# API returns, and the reason this function exists.
_print_api_error() {
local status="$1" body="$2" method="$3" path="$4"
echo "Error: HTTP ${status} on ${method} ${path}" >&2
local msg detail required granted excerpt
if msg=$(printf '%s' "$body" | jq -re '.error.message' 2>/dev/null); then
echo " $(_redact "$msg")" >&2
detail=$(printf '%s' "$body" \
| jq -r 'if (.error.detail | type) == "string" then .error.detail else empty end' \
2>/dev/null) || detail=""
if [[ -n "$detail" ]]; then
echo " $(_redact "$detail")" >&2
fi
required=$(printf '%s' "$body" \
| jq -r '(.error.detail.required // []) | join(", ")' 2>/dev/null) || required=""
granted=$(printf '%s' "$body" \
| jq -r '(.error.detail.granted // []) | join(", ")' 2>/dev/null) || granted=""
if [[ -n "$required" ]]; then
echo " required scopes: ${required}" >&2
fi
if [[ -n "$granted" ]]; then
echo " granted scopes: ${granted}" >&2
fi
elif [[ -n "$body" ]]; then
# Not a Bitbucket error envelope (an HTML error page from a proxy,
# a gateway timeout, ...). A bounded excerpt still beats silence.
excerpt="${body:0:500}"
echo " $(_redact "$excerpt")" >&2
if [[ "${#body}" -gt 500 ]]; then
echo " ... (body truncated at 500 characters)" >&2
fi
fi
# Scopes are fixed when a token is issued: granting one later does not
# apply to an already-issued token, so a 401/403 always ends at the
# same place. This is the only non-API text printed here, and it is
# not a guess about the cause — the cause is quoted above it.
if [[ "$status" == "401" ]]; then
echo " The token is invalid, expired, or revoked. Issue a new one at" >&2
echo " https://id.atlassian.com/manage-profile/security/api-tokens" >&2
elif [[ "$status" == "403" ]]; then
echo " A token's scopes are fixed at creation. To add one, create or" >&2
echo " rotate the token at" >&2
echo " https://id.atlassian.com/manage-profile/security/api-tokens" >&2
fi
return 0
}
# Perform a request and echo the response body with the HTTP status
# appended on its own line. Prints no diagnosis of its own (beyond the
# BB_DEBUG trace) so callers can decide what a given status means —
# `bb pipelines-status`, for instance, treats 404 as "never configured"
# rather than an error. Returns curl's exit code on a transport failure.
#
# Body and status are recovered with the suffix/prefix split below rather
# than by reading two streams. The status is appended LAST because an
# empty body (a 204, say) then still yields a parseable "\n204" — leading
# a response with the status would collapse to an unsplittable "204".
_bb_http() {
local method="$1" path="$2" data="${3:-}"
shift 3
local -a args
args=(-s -w '\n%{http_code}' -u "${BB_USER}:${BB_TOKEN}")
if [[ "$method" != "GET" ]]; then
args+=(-X "$method")
fi
if [[ -n "$data" ]]; then
args+=(-H "Content-Type: application/json" -d "$data")
fi
local raw rc=0
raw=$(curl "${args[@]}" "${BB_API}${path}" "$@") || rc=$?
if [[ "$rc" -ne 0 ]]; then
echo "Error: request failed before any HTTP response (curl exit ${rc})." >&2
echo " ${method} ${path}" >&2
echo " This is a connectivity error, not an API rejection." >&2
return "$rc"
fi
if [[ -n "${BB_DEBUG:-}" ]]; then
# Endpoint + status only. The token is never part of a URL (it
# rides in the Basic auth header), so nothing here is sensitive.
echo "[bb] ${method} ${path} -> ${raw##*$'\n'}" >&2
fi
printf '%s' "$raw"
}
# The default policy over _bb_http: 2xx writes the body to stdout,
# anything else reports the API's own error and returns 22.
_bb_request() {
local method="$1" path="$2" data="${3:-}"
shift 3
local raw rc=0
raw=$(_bb_http "$method" "$path" "$data" "$@") || rc=$?
if [[ "$rc" -ne 0 ]]; then
return "$rc"
fi
local status="${raw##*$'\n'}"
local body="${raw%$'\n'*}"
case "$status" in
2*)
printf '%s' "$body"
;;
*)
_print_api_error "$status" "$body" "$method" "$path"
return 22
;;
esac
}
bb_get() {
local path="$1"
shift
_bb_request GET "$path" "" "$@"
}
bb_post() {
local path="$1"
local data="${2:-}"
_bb_request POST "$path" "$data"
}
bb_put() {
local path="$1"
local data="${2:-}"
_bb_request PUT "$path" "$data"
}
bb_delete() {
local path="$1"
_bb_request DELETE "$path" ""
}
# Resolve the (workspace, repo-slug) pair for a command from its
# optional repo argument, and publish the result by SETTING two
# variables in the CALLER's scope:
# repo — the repo slug
# BB_WORKSPACE — the workspace to operate in
#
# This is called WITHOUT command substitution (i.e. `resolve_repo "$1"`,
# not `repo=$(resolve_repo "$1")`) precisely so it runs in the caller's
# shell and its assignments to `repo` (a caller `local`, reached via
# bash dynamic scope) and `BB_WORKSPACE` (global) actually propagate.
# A subshell — which is what the old `detect_repo` ran in — cannot set
# the parent's workspace, which is why workspace auto-detect needs this
# shape.
#
# Resolution precedence (highest first) — mirrors the Python
# _resolve_repo contract in mcp_server.py:
# 1. -w/--workspace flag (BB_WORKSPACE_OVERRIDE, set pre-dispatch)
# 2. explicit "workspace/slug" (overrides workspace for this call)
# 3. git origin auto-detect (workspace + slug from the remote URL)
# 4. BB_WORKSPACE default (bare "slug" arg, or env/config default)
# 5. error (nothing resolved a workspace)
resolve_repo() {
local arg="${1:-}"
# A -w/--workspace flag locks the workspace: it beats both the git
# origin and any "ws/slug" arg. Detected by BB_WORKSPACE_OVERRIDE
# being set (the dispatcher records it before load_config).
local ws_locked=""
[[ -n "${BB_WORKSPACE_OVERRIDE:-}" ]] && ws_locked=1
if [[ -z "$arg" ]]; then
# (3) Auto-detect from the git origin remote.
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null || true)
if [[ -z "$remote_url" ]]; then
echo "Error: no repo specified and not in a git repository." >&2
echo " Pass a repo (bb <cmd> myrepo) or workspace/repo" >&2
echo " (bb <cmd> acme/myrepo), or run inside a git checkout." >&2
exit 1
fi
# Strip one trailing slash so `.../repo/` parses, then match the
# tail. Greedy [^/]+ is fine here because we strip `.git`
# afterward (bash ERE has no non-greedy quantifier, unlike the
# Python _REMOTE_TAIL regex — same end result via the %.git
# parameter expansion below).
remote_url="${remote_url%/}"
if [[ "$remote_url" =~ [:/]([^/:]+)/([^/]+)$ ]]; then
local detected_ws="${BASH_REMATCH[1]}"
local detected_repo="${BASH_REMATCH[2]%.git}"
repo="$detected_repo"
# Flag wins over git-detected workspace; else use git's.
[[ -z "$ws_locked" ]] && BB_WORKSPACE="$detected_ws"
else
echo "Error: could not parse workspace/repo from origin URL." >&2
exit 1
fi
elif [[ "$arg" == */* ]]; then
# (2) Explicit "workspace/slug" override.
local arg_ws="${arg%%/*}"
local arg_repo="${arg#*/}"
if [[ "$arg_repo" == */* ]]; then
echo "Error: repo must be 'slug' or 'workspace/slug' (one '/'), got '$arg'." >&2
exit 1
fi
repo="$arg_repo"
# The -w flag still wins over an inline ws/slug (flag is the
# most explicit, per-invocation signal).
[[ -z "$ws_locked" ]] && BB_WORKSPACE="$arg_ws"
else
# (4) Bare slug → use whatever BB_WORKSPACE already resolved to
# (flag override, else env/config default). repo_path enforces
# that BB_WORKSPACE is actually set and well-formed.
repo="$arg"
fi
}
# Resolve JUST the workspace for workspace-level commands that take no
# repo argument (e.g. `bb repos`). Sets BB_WORKSPACE in the caller's
# scope. Same precedence as resolve_repo, minus the slug:
# 1. -w/--workspace flag (BB_WORKSPACE_OVERRIDE locks it)
# 2. git origin auto-detect (workspace from the remote URL)
# 3. BB_WORKSPACE default (env / config)
# 4. error
resolve_workspace() {
# -w flag locks the workspace (most explicit signal).
[[ -n "${BB_WORKSPACE_OVERRIDE:-}" ]] && return
# git origin wins over the config default — "operate on where I am".
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null || true)
if [[ -n "$remote_url" ]]; then
remote_url="${remote_url%/}"
if [[ "$remote_url" =~ [:/]([^/:]+)/([^/]+)$ ]]; then
BB_WORKSPACE="${BASH_REMATCH[1]}"
return
fi
fi
# Fall back to env/config BB_WORKSPACE; error if nothing resolved one.
if [[ -z "${BB_WORKSPACE:-}" ]]; then
echo "Error: no workspace resolved." >&2
echo " Set BB_WORKSPACE (env or ~/.config/bb/config), pass" >&2
echo " -w <workspace>, or run inside a Bitbucket git checkout." >&2
exit 1
fi
}
# Resolve positional args for PR commands of shape `bb <verb> [repo] <id> [extras...]`.
# Sets caller-scope:
# repo -- via resolve_repo (caller must `local repo`)
# pr_id -- the PR id (caller must `local pr_id`)
# pr_args_consumed -- 1 or 2 (caller `shift $pr_args_consumed` to reach extras)
#
# Heuristic: if $1 is purely digits, treat it as the id and auto-detect the
# repo from git. Otherwise $1 is the [repo] slot and $2 is the id (the
# existing positional form, unchanged). This makes `bb pr 42` Just Work
# from inside a checkout instead of treating 42 as a repo slug. Mirrors
# the state-recognition heuristic in cmd_pr_list (bb prs MERGED).
#
# Tradeoff: a repo literally named with pure digits (e.g. slug "42") would
# be shadowed by the heuristic. Acceptable: Bitbucket slugs by convention
# are lowercase-hyphenated, pure-digit slugs are vanishingly rare, and the
# explicit "workspace/42" form remains as a clean escape hatch (the
# heuristic only triggers on the bare-digits form).
_resolve_pr_args() {
case "${1:-}" in
''|*[!0-9]*)
resolve_repo "${1:-}"
pr_id="${2:-}"
pr_args_consumed=2
;;
*)
resolve_repo ""
pr_id="$1"
pr_args_consumed=1
;;
esac
# Validate non-empty ids here so every PR command inherits the guard.
# An empty pr_id is left to each command's own usage-error check;
# a non-empty one must be a positive integer before it reaches a URL.
[[ -n "$pr_id" ]] && _require_pr_id "$pr_id"
}
repo_path() {
local repo="$1"
# Validate inputs at the boundary so a malformed slug doesn't
# silently construct a wrong URL (/repositories//foo or
# /repositories/../foo). Mirrors the bb_api.repo_path Python
# validation — both surfaces enforce the same contract.
#
# Whitespace check: reject if the value EQUALS its whitespace-
# stripped form's emptiness. Catches all-whitespace AND mixed-
# whitespace (e.g. ` acme ` which the previous `^[[:space:]]+$`
# regex let through). `tr -d '[:space:]'` is true parity with
# Python's `.strip()`.
local _ws_stripped _repo_stripped
_ws_stripped="$(printf '%s' "$BB_WORKSPACE" | tr -d '[:space:]')"
_repo_stripped="$(printf '%s' "$repo" | tr -d '[:space:]')"
if [[ -z "$_ws_stripped" || "$_ws_stripped" != "$BB_WORKSPACE" ]]; then
echo "Error: BB_WORKSPACE must be a non-empty, non-whitespace string." >&2
kill -TERM $$
fi
if [[ -z "$_repo_stripped" || "$_repo_stripped" != "$repo" ]]; then
echo "Error: repo must be a non-empty, non-whitespace string." >&2
kill -TERM $$
fi
if [[ "$BB_WORKSPACE" == *"/"* || "$repo" == *"/"* ]]; then
echo "Error: workspace and repo must not contain '/'." >&2
kill -TERM $$
fi
if [[ "$BB_WORKSPACE" == "." || "$BB_WORKSPACE" == ".." ]]; then
echo "Error: workspace must not be '.' or '..'." >&2
kill -TERM $$
fi
if [[ "$repo" == "." || "$repo" == ".." ]]; then
echo "Error: repo must not be '.' or '..'." >&2
kill -TERM $$
fi
# `exit 1` inside a `$(repo_path ...)` command substitution only
# terminates the subshell — the caller would proceed with an
# empty path. `kill -TERM $$` terminates the parent script so
# the validation actually halts execution.
echo "/repositories/${BB_WORKSPACE}/${repo}"
}
# Validate a build_number argument is a positive integer before
# splicing into a jq filter or URL. jq treats unquoted non-numeric
# identifiers as undefined-function references (e.g. `select(.x == abc)`
# becomes `abc/0 is not defined`), aborting under `set -e` with no
# curated error. A crafted value like `1) | $ENV.BB_TOKEN, .uuid` can
# also exfil environment via the jq filter's $ENV — validate the shape
# at the boundary so neither failure mode can fire.
_require_build_number() {
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
echo "Error: build_number must be a positive integer (got ${1:-empty})." >&2
exit 1
fi
}
# Validate a PR id before it is interpolated into a request URL path
# (pullrequests/{id}, .../merge, .../approve, .../decline). Mirrors the
# Python _validate_pr_id / _is_positive_int contract (positive integer).
# Without this, a non-numeric id manipulates the URL path on mutation
# endpoints — the bash side previously trusted it while Python did not.
_require_pr_id() {
if ! [[ "$1" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: pr-id must be a positive integer (got ${1:-empty})." >&2
exit 1
fi
}
# Validate a pipeline step index before it is interpolated into a jq
# PROGRAM (.values[${step_index}].uuid). This is a security boundary,
# NOT just a usability check: an unvalidated step index is a jq injection
# surface — a value like `0].uuid,$ENV.BB_TOKEN,.values[0` makes jq emit
# the token, breaking the "BB_TOKEN never echoed" posture. Sibling of
# _require_build_number (which closed the same class for build_number in
# PR #8); step_index was missed. Mirrors the Python step_index guard
# (non-negative int, used as a list index, never interpolated).
_require_step_index() {
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
echo "Error: step-index must be a non-negative integer (got ${1:-empty})." >&2
exit 1
fi
}
# Validate a user-supplied result count before it is interpolated into a
# query string (pagelen=${count}). Same query-param-injection class as
# _require_pr_state (which closed `bb prs 'OPEN&pagelen=1000'`): a count
# like `10&role=admin` would smuggle extra params. Mirrors the Python
# _is_positive_int guard on count.
_require_count() {
if ! [[ "$1" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: count must be a positive integer (got ${1:-empty})." >&2
exit 1
fi
}
# Validate a resolved workspace slug at the boundary before it's
# interpolated into a workspace-level request URL (e.g.
# /workspaces/{ws}/projects). Mirrors the inline contract in
# bb_ops.projects_list / repos_list: reject empty / whitespace, embedded
# '/', and '.' / '..'. The repo-level commands get this for free via
# repo_path, but the workspace-only commands (cmd_projects) don't route
# through it, so the check is centralised here instead of duplicated.
#
# The whitespace check uses `tr -d '[:space:]'` (the same idiom repo_path
# uses for BB_WORKSPACE) and rejects if the stripped form is empty OR
# differs from the input. Note this is STRICTER than Python's `.strip()`:
# it rejects ANY whitespace including interior (`a b`), not just
# leading/trailing. That's intentional and safe for a workspace slug,
# which can never legitimately contain a space — unlike a `--project KEY`,
# where cmd_repo_update strips leading/trailing only (true `.strip()`).
_require_reviewer_uuid() {
# Bitbucket identifies PR reviewers ONLY by account UUID. A display
# name or nickname is accepted by nothing, and sending one costs a
# round trip to learn that; reject it here with a pointer to the
# lookup instead. `bb members` prints the UUID column this wants.
#
# Both brace forms are accepted: `bb members` and the API emit the
# braced `{8-4-4-4-12}`, but users routinely strip the braces when
# copying. Exactly one MATCHED pair is stripped before the check, so a
# half-brace (`{abc…` with no closer) still fails.
local raw="${1:-}"
local core="$raw"
if [[ "$raw" == "{"*"}" ]]; then
core="${raw#\{}"
core="${core%\}}"
fi
if [[ ! "$core" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
echo "Error: --reviewer must be a Bitbucket account UUID (got '$raw')." >&2
echo " Reviewers are identified by UUID, not by name or nickname." >&2
echo " Run 'bb members' to list workspace members and their UUIDs." >&2
exit 1
fi
# Canonicalise to the BRACED form, which is what the API returns. The
# reviewer-set arithmetic in pr-update compares these strings against
# `.reviewers[].uuid` and `.participants[].user.uuid` from a live
# response, so an accepted-but-bare value would match nothing: the
# removal would silently no-op and an add would append a duplicate.
# Accepting both forms only works if they converge here.
#
# Set in the CALLER's scope rather than echoed: `exit 1` inside a
# `$( )` command substitution kills only the subshell, so a rejected
# value would slip through as an empty string. Same reason resolve_repo
# assigns instead of printing.
REVIEWER_UUID="{${core}}"
}
_require_workspace() {
local ws="$1"
local stripped
stripped="$(printf '%s' "$ws" | tr -d '[:space:]')"
if [[ -z "$stripped" || "$stripped" != "$ws" ]]; then
echo "Error: workspace must be a non-empty, non-whitespace string (got '$ws')." >&2
exit 1
fi
if [[ "$ws" == */* ]]; then
echo "Error: workspace must not contain '/' (got '$ws')." >&2
exit 1
fi
if [[ "$ws" == "." || "$ws" == ".." ]]; then
echo "Error: workspace must not be '.' or '..' (got '$ws')." >&2
exit 1
fi
}
# Allowlist the PR state before it's interpolated into the request URL.
# Mirrors the Python _KNOWN_PR_STATES boundary check (bb_ops.py) — both
# surfaces reject anything outside the four valid states. Also closes a
# query-param injection surface: without this, `bb prs my-repo
# 'OPEN&pagelen=1000'` would smuggle extra query params into the URL.
_require_pr_state() {
case "$1" in
OPEN|MERGED|DECLINED|SUPERSEDED) ;;
*)
echo "Error: state must be one of OPEN, MERGED, DECLINED, SUPERSEDED (got '$1')." >&2
exit 1
;;
esac
}
# --- Formatting helpers ---
format_state() {
local state="$1"
case "$state" in
COMPLETED) echo "DONE" ;;
SUCCESSFUL) echo "PASS" ;;
RUNNING) echo "RUN " ;;
PENDING) echo "WAIT" ;;
FAILED) echo "FAIL" ;;
ERROR) echo "ERR " ;;
STOPPED) echo "STOP" ;;
PAUSED) echo "HOLD" ;;
HALTED) echo "HALT" ;;
OPEN) echo "OPEN" ;;
MERGED) echo "MRGD" ;;
DECLINED) echo "DECL" ;;
SUPERSEDED) echo "SUPD" ;;
*) echo "$state" ;;
esac
}
format_duration() {
local seconds="$1"
if [[ "$seconds" -lt 60 ]]; then
echo "${seconds}s"
elif [[ "$seconds" -lt 3600 ]]; then
echo "$((seconds / 60))m $((seconds % 60))s"
else
echo "$((seconds / 3600))h $((seconds % 3600 / 60))m"
fi
}
# =========================================================================
# PIPELINE COMMANDS
# =========================================================================
cmd_pipelines() {
local repo
resolve_repo "${1:-}"
local count="${2:-10}"
_require_count "$count"
echo "Pipelines for ${BB_WORKSPACE}/${repo}:"
echo ""
local response
response=$(bb_get "$(repo_path "$repo")/pipelines/?sort=-created_on&pagelen=${count}")
printf " %-7s %-6s %-22s %-18s %-12s %s\n" "BUILD" "STATE" "BRANCH" "TRIGGER" "DATE" "DURATION"
printf " %-7s %-6s %-22s %-18s %-12s %s\n" "-----" "-----" "------" "-------" "----" "--------"
echo "$response" | jq -r '
.values[] |
[
(.build_number | tostring),
.state.name,
(.state.result.name // .state.stage.name // "-"),
(.target.ref_name // "n/a"),
(.target.selector.pattern // .trigger.name // "-"),
(.created_on | split("T") | .[0]),
(.duration_in_seconds // 0 | tostring)
] | join("\t")
' | while IFS=$'\t' read -r num state result ref trigger date duration; do
local display_state
if [[ -n "$result" ]]; then
display_state=$(format_state "$result")
else
display_state=$(format_state "$state")
fi
local dur_str="-"
if [[ "$duration" != "0" && "$duration" != "null" ]]; then
dur_str=$(format_duration "$duration")
fi
printf " #%-6s %-6s %-22s %-18s %-12s %s\n" \
"$num" "$display_state" "$ref" "$trigger" "$date" "$dur_str"
done
}
cmd_pipeline() {
local repo
resolve_repo "${1:-}"
local build_number="${2:-}"
if [[ -z "$build_number" ]]; then
echo "Usage: bb pipeline [repo] <build-number>" >&2
exit 1
fi
_require_build_number "$build_number"
# Parity fix: bumped pagelen 50→100 (Bitbucket's max). Older
# pipelines still unfindable beyond 100; full pagination is
# the Python-side improvement.
local response
response=$(bb_get "$(repo_path "$repo")/pipelines/?sort=-created_on&pagelen=100")
local pipeline_uuid
pipeline_uuid=$(echo "$response" | jq -r ".values[] | select(.build_number == ${build_number}) | .uuid" | tr -d '{}')
if [[ -z "$pipeline_uuid" ]]; then
echo "Pipeline #${build_number} not found." >&2
exit 1
fi
local pipeline
pipeline=$(bb_get "$(repo_path "$repo")/pipelines/%7B${pipeline_uuid}%7D")
echo "Pipeline #${build_number} - ${BB_WORKSPACE}/${repo}"
echo ""
echo "$pipeline" | jq -r '
" Branch: " + (.target.ref_name // "n/a"),
" Trigger: " + (.target.selector.pattern // .trigger_name // "n/a"),
" State: " + .state.name + (if .state.result then " / " + .state.result.name else "" end),
" Created: " + .created_on,
" Duration: " + (if .duration_in_seconds then (.duration_in_seconds | tostring) + "s" else "in progress" end)
'
echo ""
echo " Steps:"
local steps
steps=$(bb_get "$(repo_path "$repo")/pipelines/%7B${pipeline_uuid}%7D/steps/?pagelen=50")
echo "$steps" | jq -r '
.values[] |
" " +
(if .state.result then .state.result.name else .state.name end) +
" " + .name +
(if .duration_in_seconds then " (" + (.duration_in_seconds | tostring) + "s)" else "" end)
'
}
cmd_watch() {
local repo
resolve_repo "${1:-}"
local build_number="${2:-}"
local poll_interval="${3:-15}"
if [[ -z "$build_number" ]]; then
local latest
latest=$(bb_get "$(repo_path "$repo")/pipelines/?sort=-created_on&pagelen=1")
build_number=$(echo "$latest" | jq -r '.values[0].build_number')
echo "Watching most recent pipeline: #${build_number}"
fi
_require_build_number "$build_number"
echo "Watching pipeline #${build_number} on ${BB_WORKSPACE}/${repo} (every ${poll_interval}s)..."
echo ""
while true; do
# Parity fix: bumped pagelen 50 → 100, symmetric with
# cmd_pipeline / cmd_pipeline_stop / cmd_logs. Without this
# bump, a pipeline at positions 51-100 in the recent list
# would never match here and the watch loop would spin
# forever printing blanks.
local response
response=$(bb_get "$(repo_path "$repo")/pipelines/?sort=-created_on&pagelen=100")
local state result duration ref
IFS=$'\t' read -r state result duration ref < <(echo "$response" | jq -r "
.values[] | select(.build_number == ${build_number}) |
[.state.name, (.state.result.name // \"-\"), (.duration_in_seconds // 0 | tostring), (.target.ref_name // \"n/a\")] | join(\"\t\")
")
local display_state
if [[ -n "$result" && "$result" != "-" ]]; then
display_state=$(format_state "$result")
else
display_state=$(format_state "$state")
fi
local dur_str=""
if [[ "$duration" =~ ^[0-9]+$ && "$duration" != "0" ]]; then
dur_str=" ($(format_duration "$duration"))"
fi
printf "\r #%-6s %-6s %-22s%s " "$build_number" "$display_state" "$ref" "$dur_str"
if [[ "$state" == "COMPLETED" ]]; then
echo ""
echo ""
echo "Pipeline finished: ${result}"
cmd_pipeline "$repo" "$build_number" 2>/dev/null | grep -A 100 "Steps:" || true
return 0
fi
sleep "$poll_interval"
done
}
cmd_logs() {
local repo
resolve_repo "${1:-}"
local build_number="${2:-}"
local step_index="${3:-}"
if [[ -z "$build_number" ]]; then
echo "Usage: bb logs [repo] <build-number> [step-index]" >&2
exit 1
fi
_require_build_number "$build_number"
# Validate step_index at the boundary — before any network call and
# before the jq interpolation below (jq injection / token-exfil
# boundary; see _require_step_index). Empty step_index takes the
# list-steps path, so only guard the non-empty case here. This keeps
# "zero network IO on bad input" parity with the Python side.
[[ -n "$step_index" ]] && _require_step_index "$step_index"
# Parity fix: bumped pagelen 50→100 (Bitbucket's max). Symmetric
# with cmd_pipeline_stop / cmd_pipeline.
local response
response=$(bb_get "$(repo_path "$repo")/pipelines/?sort=-created_on&pagelen=100")
local pipeline_uuid
pipeline_uuid=$(echo "$response" | jq -r ".values[] | select(.build_number == ${build_number}) | .uuid" | tr -d '{}')
if [[ -z "$pipeline_uuid" ]]; then
echo "Pipeline #${build_number} not found." >&2
exit 1
fi
local steps
steps=$(bb_get "$(repo_path "$repo")/pipelines/%7B${pipeline_uuid}%7D/steps/?pagelen=50")
if [[ -z "$step_index" ]]; then
echo "Steps for pipeline #${build_number}:"
echo ""
echo "$steps" | jq -r '
.values | to_entries[] |
" [" + (.key | tostring) + "] " + .value.name +
" (" + (if .value.state.result then .value.state.result.name else .value.state.name end) + ")"
'
echo ""
echo "Usage: bb logs ${repo} ${build_number} <step-index>"
return
fi
# step_index is validated at the top (before any network call), so
# the interpolation below is safe.
local step_uuid
step_uuid=$(echo "$steps" | jq -r ".values[${step_index}].uuid" | tr -d '{}')
if [[ "$step_uuid" == "null" || -z "$step_uuid" ]]; then
echo "Step index ${step_index} not found." >&2
exit 1
fi
local step_name
step_name=$(echo "$steps" | jq -r ".values[${step_index}].name")
echo "Logs for step [${step_index}] ${step_name}:"
echo ""
# -L because a log can be served as a 307 to a signed object-store URL.
# `curl -L -u` does not resend credentials to a different host, so the
# Bitbucket Basic header never reaches the redirect target.
#
# A failure here used to be swallowed (`2>/dev/null` plus a fallback
# line), which reported "no log output" for a missing scope, an
# expired token, and a genuinely empty log alike. bb_get now prints
# the API's own reason first, and the fallback line still marks the
# request as having produced nothing. It fires only on failure: a
# successful but empty log exits 0 and prints nothing at all.
bb_get "$(repo_path "$repo")/pipelines/%7B${pipeline_uuid}%7D/steps/%7B${step_uuid}%7D/log" -L \
|| echo "(no log output available)"
}
cmd_pipeline_trigger() {
# bb trigger [repo] [branch] [pattern] [--var KEY=VALUE ...] [KEY=VALUE ...]
#
# Per-run pipeline variables arrive two ways, combined in order:
# --var/-v KEY=VALUE repeatable, position-independent (preferred)
# trailing KEY=VALUE positional pairs after [pattern] (legacy form)
# The variables need not be declared in bitbucket-pipelines.yml; the
# API accepts arbitrary per-run keys alongside the target selector.
local -a var_pairs=() positionals=()
while [[ $# -gt 0 ]]; do
case "$1" in
--var|-v) _require_flag_value "$@"; var_pairs+=("$2"); shift 2 ;;
--var=*) var_pairs+=("${1#*=}"); shift ;;
-*)
echo "Error: unknown flag for trigger: $1" >&2
echo "Usage: bb trigger [repo] [branch] [pattern] [--var KEY=VALUE ...]" >&2
exit 1 ;;
*) positionals+=("$1"); shift ;;
esac
done
local repo
resolve_repo "${positionals[0]:-}"
local branch="${positionals[1]:-}"
local pattern="${positionals[2]:-}"
# Positionals past [pattern] are legacy KEY=VALUE pairs (the
# pre-flag form, still supported so documented invocations keep
# working). They merge after the --var pairs.
local _i
for (( _i=3; _i < ${#positionals[@]}; _i++ )); do
var_pairs+=("${positionals[$_i]}")
done
# Validate every pair BEFORE any API call. A pair with no '=' or a
# malformed key must fail loudly here; otherwise it is silently
# sent as {"key": <arg>, "value": ""} and Bitbucket runs the build
# with a garbage variable. The key charset is Bitbucket's documented
# rule for variable names.
local _pair _key
for _pair in ${var_pairs[@]+"${var_pairs[@]}"}; do
if [[ "$_pair" != *=* ]]; then
echo "Error: pipeline variable must be KEY=VALUE, got '$_pair'." >&2
exit 1
fi
_key="${_pair%%=*}"
if ! [[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
echo "Error: invalid pipeline variable name '$_key'." >&2
echo " Names use letters, digits, and underscores, and must not start with a digit." >&2
exit 1
fi
done
# Build the variables array via `jq` so values containing `"`, `\`,
# newlines, or tabs are correctly JSON-escaped. Each pair rides in as
# its OWN jq argument ($ARGS.positional, jq >= 1.6), never as a
# delimited stream. A delimiter cannot work here: bash arguments are
# C strings, so a NUL delimiter embedded in the jq program is dropped
# by the shell (jq then sees split("") and shreds the stream into
# per-character ghost variables), and any printable delimiter could
# collide with a variable's value.
local variables="[]"
if [[ ${#var_pairs[@]} -gt 0 ]]; then
# Per-pair shape: split on the FIRST `=` only so values
# containing `=` survive intact.
variables=$(jq -n '
[$ARGS.positional[] | split("=") | {
key: .[0],
value: (.[1:] | join("="))
}]' --args "${var_pairs[@]}")
fi
if [[ -z "$branch" ]]; then
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
# git rev-parse exits 0 with literal "HEAD" on detached HEAD —
# Bitbucket would 400 on ref_name=HEAD. Surface a clean error.
if [[ "$branch" == "HEAD" ]]; then
echo "Error: detached HEAD detected. Pass an explicit branch." >&2
exit 1
fi
fi
# Parity fix: previously the no-pattern branch built `{target: {...}}`
# WITHOUT the variables field, silently dropping any VAR=value args
# the user passed. Build the payload incrementally so variables always
# land in the request when provided, regardless of pattern.
#
# The target MUST carry `type: "pipeline_ref_target"`. Without it
# Bitbucket 400s with "Unsupported reference target provided
# 'pipeline_unknown_target'" — fatal on the custom-pattern path
# (verified live), and the field is correct for the default-branch path
# too, so it's sent unconditionally for parity with bb_ops.
local payload
if [[ "$variables" != "[]" ]]; then
if [[ -n "$pattern" ]]; then
payload=$(jq -n \
--arg ref "$branch" \
--arg pat "$pattern" \
--argjson vars "$variables" \
'{target: {type: "pipeline_ref_target", ref_type: "branch", ref_name: $ref, selector: {type: "custom", pattern: $pat}}, variables: $vars}')
else
payload=$(jq -n \
--arg ref "$branch" \
--argjson vars "$variables" \
'{target: {type: "pipeline_ref_target", ref_type: "branch", ref_name: $ref}, variables: $vars}')
fi
else
# No variables → omit the key entirely (matches Python's
# omit-when-empty contract).
if [[ -n "$pattern" ]]; then
payload=$(jq -n \
--arg ref "$branch" \
--arg pat "$pattern" \
'{target: {type: "pipeline_ref_target", ref_type: "branch", ref_name: $ref, selector: {type: "custom", pattern: $pat}}}')
else
payload=$(jq -n --arg ref "$branch" '{target: {type: "pipeline_ref_target", ref_type: "branch", ref_name: $ref}}')
fi
fi
echo "Triggering pipeline on ${BB_WORKSPACE}/${repo} branch ${branch}..."
if [[ -n "$pattern" ]]; then
echo " Custom pipeline: ${pattern}"
fi
if [[ "$variables" != "[]" ]]; then
# Echo variable KEYS only — values may be secrets (API tokens,
# deploy creds) that the user passed as --var KEY=value. Mask
# per ELEMENT, not per line: a line-oriented sed would print any
# text after an embedded newline in a value unmasked (KEY=$'a\nb'
# masks the KEY=a line but leaks the b line).
local masked="" _mp
for _mp in "${var_pairs[@]}"; do
masked+="${_mp%%=*}=*** "
done
echo " Variables: ${masked%% }"
fi
# rc-capture pattern: capture the exit code so a 4xx (protected
# branch, custom pipeline name not found, invalid variable shape)
# surfaces as a labelled error instead of `set -e` silently
# aborting after the "Triggering pipeline..." banner.
# Capture rc via `|| rc=$?`, not `if ! cmd; then rc=$?` — the
# latter sets $? to the negation (always 0), so the real exit code
# was being lost and `exit $rc` exited 0 on failure. Verified on