-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbb_ops.py
More file actions
1938 lines (1667 loc) · 76 KB
/
Copy pathbb_ops.py
File metadata and controls
1938 lines (1667 loc) · 76 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
"""
bb_ops — Bitbucket REST operations grouped by resource.
The MCP server (mcp_server.py, future PR) wires each function here to a
tool. Each function takes a BBClient as its first positional argument and
returns native Python data (dicts, lists, strings) — no terminal-style
formatting, no colour codes, no parsing of bash output.
`bb` (bash) and bb_ops (Python) are parallel implementations of the same
Bitbucket REST contract. See CONTRIBUTING.md for the parity rule: when a
defect surfaces in either side, the fix lands in both code paths.
Current scope: pipelines, pull requests, repos/branches/vars/downloads/
commits. The companion git_ops module provides the git-context wrappers
the MCP server uses to resolve "current branch" / "remote workspace"
before invoking these ops.
"""
from __future__ import annotations
import re
from typing import Any, Iterable
from urllib.parse import quote
from bb_api import BBApiError, BBClient, repo_path
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
# Bitbucket Cloud caps pagelen at 100 server-side. Asking for more is
# silently truncated, which would create a confusing partial result. Clamp
# explicitly so the caller's intent (cap N items, paginate if needed) is
# preserved.
_BITBUCKET_MAX_PAGELEN = 100
def _is_positive_int(value: Any) -> bool:
"""True iff `value` is an int (NOT a bool) >= 1.
`bool` is a subclass of `int` in Python, so `isinstance(True, int)` is
True and `True < 1` is False — meaning a bare `isinstance(x, int) and
x >= 1` check happily accepts `True` as `1`. That then propagates
through f-string interpolation into URLs as the literal `"True"`, and
through `urlencode({"pagelen": True})` as `"pagelen=True"` — both
failure modes the boundary validator exists to prevent.
"""
return isinstance(value, int) and not isinstance(value, bool) and value >= 1
# When resolving a build_number -> uuid, we walk the pipelines list sorted
# by most-recent-first. This cap bounds how far back we look before giving
# up. 2000 = 20 pages of 100 = "any pipeline triggered in the last few
# months" for an active repo. The bash script's inline lookup is a single
# 100-pipeline page; this MCP-side scan trades a few extra API calls for
# the ability to address older builds by number.
_PIPELINE_SCAN_LIMIT = 2000
class BBOpNotFound(LookupError):
"""A requested resource (pipeline build_number, step index, etc.) was
not present in the responses we walked. Distinct from BBApiError so
callers can render "no such pipeline" vs "API failure" differently."""
def _wrap_uuid(uuid: str) -> str:
"""Bitbucket's URL contract uses `{uuid}` with the literal braces,
URL-encoded as `%7B...%7D`. The bash script does this by interpolating
`%7B${uuid}%7D` directly into curl URLs; mirror that. The UUID itself
is alphanumeric+hyphens so `quote` would no-op on it, but we route
through it to defend against a UUID that ever contains characters
that would otherwise need encoding."""
inner = uuid.strip()
if inner.startswith("{") and inner.endswith("}"):
inner = inner[1:-1]
return f"%7B{quote(inner)}%7D"
def _pipelines_root(workspace: str, repo: str) -> str:
"""Common URL prefix for the pipelines API. Centralising this means a
future API-version bump touches one place."""
return f"{repo_path(workspace, repo)}/pipelines/"
def _strip_uuid_braces(uuid: str | None) -> str:
"""Bitbucket returns pipeline UUIDs in two shapes depending on endpoint:
bare ('a1b2-...') and brace-wrapped ('{a1b2-...}'). Normalise to bare
so callers don't have to care."""
if not uuid:
raise BBApiError(0, "", "response missing uuid")
s = uuid.strip()
if s.startswith("{") and s.endswith("}"):
return s[1:-1]
return s
# ---------------------------------------------------------------------------
# Resolution helpers (build_number -> uuid, step_index -> uuid)
# ---------------------------------------------------------------------------
def _resolve_pipeline_uuid(
client: BBClient,
workspace: str,
repo: str,
build_number: int,
*,
scan_limit: int = _PIPELINE_SCAN_LIMIT,
) -> str:
"""Resolve a pipeline's UUID by walking pipelines/ sorted by most-recent.
Bitbucket Cloud's API does not expose a direct `GET /pipelines/{build_number}`
endpoint, only `GET /pipelines/{uuid}`. The CLI passes a build_number
because that's what's user-visible (and what the API echoes in payloads).
This helper paginates the listing until it finds the matching build or
`scan_limit` items have been examined.
Raises BBOpNotFound if the build_number isn't found within the scan
window. Distinct from a network/API failure so callers can render
"no such pipeline #N" naturally.
"""
if not _is_positive_int(build_number):
raise ValueError(f"build_number must be a positive int, got {build_number!r}")
seen = 0
path = _pipelines_root(workspace, repo)
query = {"sort": "-created_on", "pagelen": _BITBUCKET_MAX_PAGELEN}
for pipeline in client.paginate(path, query=query):
seen += 1
if pipeline.get("build_number") == build_number:
return _strip_uuid_braces(pipeline.get("uuid"))
if seen >= scan_limit:
break
raise BBOpNotFound(
f"pipeline #{build_number} not found within the {scan_limit} most-recent "
f"pipelines of {workspace}/{repo}"
)
def _resolve_step_uuid(
client: BBClient,
workspace: str,
repo: str,
pipeline_uuid: str,
step_index: int,
) -> str:
"""Return the step UUID for the step at the given 0-based index.
The bash script's `bb logs` uses the same 0-based indexing into the
steps list; mirror that contract so the user-facing index numbers
match across both surfaces.
Returns just the uuid (not the name) — callers that need the name
should fetch the steps list themselves via `pipeline_steps()`. The
MCP step-logs tool wraps the log payload in its own response shape
and can surface the name there.
"""
if (
not isinstance(step_index, int)
or isinstance(step_index, bool)
or step_index < 0
):
raise ValueError(f"step_index must be a non-negative int, got {step_index!r}")
steps = _pipeline_steps_by_uuid(client, workspace, repo, pipeline_uuid)
if step_index >= len(steps):
raise BBOpNotFound(
f"step index {step_index} out of range "
f"(pipeline has {len(steps)} step{'s' if len(steps) != 1 else ''})"
)
return _strip_uuid_braces(steps[step_index].get("uuid"))
# ---------------------------------------------------------------------------
# Public operations
# ---------------------------------------------------------------------------
def pipelines_list(
client: BBClient,
workspace: str,
repo: str,
*,
count: int = 10,
sort: str = "-created_on",
branch: str | None = None,
) -> list[dict[str, Any]]:
"""List recent pipelines, most-recent first by default.
`count` is the upper bound on returned items. We always honour it
even if it exceeds Bitbucket's per-page cap (100): the function
paginates as needed.
`branch` filters to pipelines triggered against a specific branch via
Bitbucket's `target.ref_name` query (the API supports this without a
`?q=` filter shape).
"""
if not _is_positive_int(count):
raise ValueError(f"count must be a positive int, got {count!r}")
pagelen = min(count, _BITBUCKET_MAX_PAGELEN)
query: dict[str, Any] = {"sort": sort, "pagelen": pagelen}
if branch is not None:
query["target.ref_name"] = branch
out: list[dict[str, Any]] = []
for pipeline in client.paginate(_pipelines_root(workspace, repo), query=query):
out.append(pipeline)
if len(out) >= count:
break
return out
def pipeline_show(
client: BBClient, workspace: str, repo: str, build_number: int
) -> dict[str, Any]:
"""Fetch full pipeline detail for the given build_number."""
uuid = _resolve_pipeline_uuid(client, workspace, repo, build_number)
return client.get(f"{_pipelines_root(workspace, repo)}{_wrap_uuid(uuid)}")
def _pipeline_steps_by_uuid(
client: BBClient, workspace: str, repo: str, pipeline_uuid: str
) -> list[dict[str, Any]]:
"""Internal: list steps when you already have a pipeline UUID. Used by
`_resolve_step_uuid` (which already paid the build_number→uuid lookup)
to avoid a second list-pipelines walk."""
uuid = _strip_uuid_braces(pipeline_uuid)
path = f"{_pipelines_root(workspace, repo)}{_wrap_uuid(uuid)}/steps/"
return list(client.paginate(path, query={"pagelen": _BITBUCKET_MAX_PAGELEN}))
def pipeline_steps(
client: BBClient, workspace: str, repo: str, build_number: int
) -> list[dict[str, Any]]:
"""List the steps of a pipeline by build_number."""
uuid = _resolve_pipeline_uuid(client, workspace, repo, build_number)
return _pipeline_steps_by_uuid(client, workspace, repo, uuid)
def pipeline_trigger(
client: BBClient,
workspace: str,
repo: str,
*,
branch: str,
pattern: str | None = None,
variables: dict[str, str]
| Iterable[tuple[str, str] | dict[str, Any]]
| None = None,
) -> dict[str, Any]:
"""Trigger a new pipeline run.
Without `pattern`, runs the branch's default pipeline.
With `pattern`, runs the named custom pipeline (must be defined in
bitbucket-pipelines.yml under `custom:`).
`variables` is the set of per-run pipeline variables to pass: a dict
{name: value}, an iterable of (name, value) tuples, or an iterable of
Bitbucket wire-shape dicts `{"key": ..., "value": ..., "secured"?: bool}`.
Values must be strings; Bitbucket does not accept other JSON types for
variables. Variables need not be declared in bitbucket-pipelines.yml;
the API accepts arbitrary per-run keys.
Returns the new pipeline's record (includes build_number, uuid, etc.).
"""
if not branch or not isinstance(branch, str):
raise ValueError(f"branch is required and must be a string, got {branch!r}")
# `type: "pipeline_ref_target"` is REQUIRED on the target. Without it
# Bitbucket can't classify the reference and 400s with "Unsupported
# reference target provided 'pipeline_unknown_target'". This bites the
# custom-pattern path hardest (verified live); the default-branch path
# happened to be accepted without it, but the field is correct for
# both, so send it unconditionally for one consistent shape.
target: dict[str, Any] = {
"type": "pipeline_ref_target",
"ref_type": "branch",
"ref_name": branch,
}
if pattern is not None:
if not isinstance(pattern, str) or not pattern:
raise ValueError(f"pattern must be a non-empty string, got {pattern!r}")
target["selector"] = {"type": "custom", "pattern": pattern}
payload: dict[str, Any] = {"target": target}
if variables is not None:
# Normalise to a list of {"key": k, "value": v} dicts — Bitbucket's
# contract. Accept a dict, an iterable of pairs, or wire-shape
# dicts at the Python boundary so MCP tool args can use any form.
if isinstance(variables, dict):
items: list[Any] = list(variables.items())
else:
items = list(variables)
normalised: list[dict[str, Any]] = []
for item in items:
secured: bool | None = None
if isinstance(item, dict):
# Bitbucket's wire shape. This MUST be an explicit case:
# tuple-unpacking a dict iterates its KEY NAMES, so a
# wire-shape item would silently become the literal
# variable key="key", value="value", corrupting the run
# instead of erroring.
extra = set(item) - {"key", "value", "secured"}
if extra or "key" not in item or "value" not in item:
raise ValueError(
"variable item dicts must have keys 'key' and 'value' "
f"(optionally 'secured'), got {sorted(item)!r}"
)
k, v = item["key"], item["value"]
if "secured" in item:
if not isinstance(item["secured"], bool):
raise ValueError(
f"variable 'secured' for {k!r} must be a bool, "
f"got {type(item['secured']).__name__}"
)
secured = item["secured"]
elif isinstance(item, str):
# A bare string would tuple-unpack character-wise (a
# 2-char string "AB" unpacks to ("A", "B") without error).
raise ValueError(
f"variable item must be a (key, value) pair or a "
f"{{'key', 'value'}} dict, got the string {item!r}"
)
else:
try:
k, v = item
except (TypeError, ValueError) as exc:
raise ValueError(
f"variable item must be a (key, value) pair or a "
f"{{'key', 'value'}} dict, got {item!r}"
) from exc
if not isinstance(k, str) or not k:
raise ValueError(f"variable key must be a non-empty string, got {k!r}")
if not isinstance(v, str):
raise ValueError(
f"variable value for {k!r} must be a string, got {type(v).__name__}"
)
entry: dict[str, Any] = {"key": k, "value": v}
if secured is not None:
entry["secured"] = secured
normalised.append(entry)
if normalised:
payload["variables"] = normalised
return client.post(_pipelines_root(workspace, repo), json_body=payload)
def pipeline_stop(
client: BBClient, workspace: str, repo: str, build_number: int
) -> Any:
"""Stop a running pipeline. Returns the raw API response (typically
None on success — Bitbucket returns 204). The bash script discards
this response with `> /dev/null`; we return it so the MCP tool can
surface a structured outcome (parity follow-up for 4.7)."""
uuid = _resolve_pipeline_uuid(client, workspace, repo, build_number)
path = f"{_pipelines_root(workspace, repo)}{_wrap_uuid(uuid)}/stopPipeline"
return client.post(path)
def pipeline_logs(
client: BBClient,
workspace: str,
repo: str,
build_number: int,
step_index: int,
*,
timeout: float = 120.0,
) -> str:
"""Fetch raw log text for a pipeline step (0-based step index).
Bitbucket returns either the log body inline (200) or a 307 redirect
to an S3 signed URL. The fetch helper follows redirects while
stripping the Authorization header on cross-host hops so the
Bitbucket credential is never sent to S3. Default timeout is 120s
because log payloads can be large and the bash equivalent uses
no timeout cap.
"""
pipeline_uuid = _resolve_pipeline_uuid(client, workspace, repo, build_number)
step_uuid = _resolve_step_uuid(
client, workspace, repo, pipeline_uuid, step_index
)
path = (
f"{_pipelines_root(workspace, repo)}"
f"{_wrap_uuid(pipeline_uuid)}/steps/{_wrap_uuid(step_uuid)}/log"
)
return client.fetch_redirected_text(path, timeout=timeout)
# --- Pipelines configuration (enable / disable / status) ---
#
# A repo's Pipelines feature is toggled via the pipelines_config resource:
# GET /repositories/{ws}/{slug}/pipelines_config → {"enabled": bool, ...}
# PUT ... {"enabled": true|false} → updated config
# Pipelines must be ENABLED before repo pipeline variables, custom
# pipelines, or builds work at all — this is the "CI won't run / vars
# won't take" gap. Note (verified live): the GET 404s when Pipelines has
# never been configured on the repo (the pre-enable state), rather than
# returning {"enabled": false}. pipelines_config_show translates that 404
# into a clean {"enabled": false, "configured": false} so callers get a
# definite answer instead of an exception for the common "never enabled"
# case.
def pipelines_config_show(
client: BBClient, workspace: str, repo: str
) -> dict[str, Any]:
"""Return the repo's Pipelines configuration: `{"enabled": bool, ...}`.
GET /repositories/{ws}/{slug}/pipelines_config. When Pipelines has
never been configured the API 404s; this is the normal "never enabled"
state, so it's translated to `{"enabled": False, "configured": False}`
rather than propagating BBApiError. When the config exists, the raw
record is returned with a `"configured": True` marker added.
"""
path = f"{repo_path(workspace, repo)}/pipelines_config"
try:
result = client.get(path)
except BBApiError as e:
if e.status == 404:
return {"enabled": False, "configured": False}
raise
if isinstance(result, dict):
result.setdefault("configured", True)
return result
def pipelines_config_set(
client: BBClient, workspace: str, repo: str, *, enabled: bool
) -> dict[str, Any]:
"""Enable or disable Pipelines on a repo.
PUT /repositories/{ws}/{slug}/pipelines_config with
`{"enabled": true|false}`. Returns the updated configuration record.
Requires `admin:pipeline:bitbucket` scope on the token (toggling the
Pipelines feature is a pipeline-admin operation, same scope family as
`vars_set`). `write:pipeline:bitbucket` alone is insufficient; a 403
names the missing scope under `error.detail.required`.
"""
if not isinstance(enabled, bool):
raise ValueError(f"enabled must be a bool, got {type(enabled).__name__}")
path = f"{repo_path(workspace, repo)}/pipelines_config"
return client.put(path, json_body={"enabled": enabled})
# ===========================================================================
# PULL REQUEST OPERATIONS
# ===========================================================================
# Bitbucket's documented merge strategies. Validating at the boundary
# means the MCP tool fails fast on a typo rather than waiting for the
# server's 400.
_VALID_MERGE_STRATEGIES = frozenset({"merge_commit", "squash", "fast_forward"})
# PR `state` filter values the Bitbucket API accepts on the simple
# `?state=` query parameter. For multi-state filtering, Bitbucket requires
# the BBQL `q` parameter (e.g. `?q=state="OPEN" OR state="MERGED"`); the
# `?state=OPEN,MERGED` shape returns 400 / empty results. We validate the
# scalar form against this set when prs_list is called with `state=`;
# callers needing compound filtering should construct a `q=` query and
# call `client.paginate` directly.
_KNOWN_PR_STATES = frozenset({"OPEN", "MERGED", "DECLINED", "SUPERSEDED"})
def _prs_root(workspace: str, repo: str) -> str:
"""Common URL prefix for the pull-requests API."""
return f"{repo_path(workspace, repo)}/pullrequests"
def _validate_pr_id(pr_id: int) -> None:
"""PR IDs are positive integers. The bash script passes them as bare
strings and lets Bitbucket reject malformed values; we fail at the
boundary so the MCP tool surfaces a clear error before any network
call burns API budget.
Rejects bool explicitly (`True`/`False` are subclass-of-int in Python
but stringify to `"True"`/`"False"` in URLs, not `"1"`/`"0"`).
"""
if not _is_positive_int(pr_id):
raise ValueError(f"pr_id must be a positive int, got {pr_id!r}")
# Fields stripped from each PR object in the LIST view (prs_list) by
# default. Bitbucket PR objects carry the full rendered description +
# summary (raw / html / markup variants) and the participants array,
# which together push even a 3-PR list past the MCP 25k-token response
# cap on repos with rich PR bodies (observed: johnny-server, 3 open PRs
# = ~70 KB). The list/triage workflow (prs_list -> pick one -> pr_show)
# only needs identity + state + branches + author + links; the full
# body is one pr_show away. pr_show is intentionally NOT slimmed — it's
# the drill-down where you WANT the whole object.
_PR_LIST_BULKY_FIELDS = ("description", "summary", "rendered", "participants")
def _slim_pr_list_item(pr: dict[str, Any]) -> dict[str, Any]:
"""Drop the bulky fields from one PR list object. Shallow copy so
the caller's source dict is untouched. `reviewers`, when present,
is projected down to uuid + display_name per reviewer (the full
account blobs are the other big contributor) while preserving the
count and identities a triage view needs."""
slim = {k: v for k, v in pr.items() if k not in _PR_LIST_BULKY_FIELDS}
reviewers = pr.get("reviewers")
if isinstance(reviewers, list):
slim["reviewers"] = [
{
"uuid": r.get("uuid"),
"display_name": r.get("display_name"),
}
for r in reviewers
if isinstance(r, dict)
]
return slim
def prs_list(
client: BBClient,
workspace: str,
repo: str,
*,
state: str = "OPEN",
count: int = 25,
verbose: bool = False,
) -> list[dict[str, Any]]:
"""List pull requests filtered by state. Defaults match bash:
state=OPEN, count=25. Walks pages as needed to honour `count`.
By default each PR is slimmed (see _slim_pr_list_item) so the list
fits the MCP response cap on rich-PR repos. Pass verbose=True to get
the full Bitbucket PR objects (description, summary, rendered,
participants intact) — useful when a caller genuinely needs the
bodies and isn't going through the MCP transport."""
if not _is_positive_int(count):
raise ValueError(f"count must be a positive int, got {count!r}")
if not isinstance(state, str) or not state:
raise ValueError(f"state must be a non-empty string, got {state!r}")
# _KNOWN_PR_STATES is the boundary check. Without it, typos like
# state="OPENED", case bugs like state="open", and unsupported
# compound forms like state="OPEN,MERGED" would burn an API call
# before failing (Bitbucket returns 400 or empty results). For
# compound filtering use a `?q=` query via client.paginate directly.
if state not in _KNOWN_PR_STATES:
raise ValueError(
f"state must be one of {sorted(_KNOWN_PR_STATES)}, got {state!r}"
)
pagelen = min(count, _BITBUCKET_MAX_PAGELEN)
query: dict[str, Any] = {"state": state, "pagelen": pagelen}
out: list[dict[str, Any]] = []
for pr in client.paginate(_prs_root(workspace, repo), query=query):
out.append(pr if verbose else _slim_pr_list_item(pr))
if len(out) >= count:
break
return out
def pr_show(
client: BBClient, workspace: str, repo: str, pr_id: int
) -> dict[str, Any]:
"""Fetch a pull request by its numeric ID."""
_validate_pr_id(pr_id)
return client.get(f"{_prs_root(workspace, repo)}/{pr_id}")
def pr_activity(
client: BBClient,
workspace: str,
repo: str,
pr_id: int,
*,
count: int = 50,
) -> list[dict[str, Any]]:
"""List the activity stream on a PR (approvals, comment events, state
transitions). Used by the bash `bb pr` to surface approver names;
surfaced separately as an op so the MCP agent can render its own view
of the activity timeline."""
_validate_pr_id(pr_id)
if not _is_positive_int(count):
raise ValueError(f"count must be a positive int, got {count!r}")
pagelen = min(count, _BITBUCKET_MAX_PAGELEN)
out: list[dict[str, Any]] = []
for entry in client.paginate(
f"{_prs_root(workspace, repo)}/{pr_id}/activity",
query={"pagelen": pagelen},
):
out.append(entry)
if len(out) >= count:
break
return out
def pr_create(
client: BBClient,
workspace: str,
repo: str,
*,
title: str,
source_branch: str,
destination_branch: str = "main",
description: str = "",
close_source_branch: bool = False,
reviewers: Iterable[str] | None = None,
) -> dict[str, Any]:
"""Create a pull request.
`reviewers` is an iterable of Bitbucket account UUIDs (the API expects
`[{"uuid": "..."}, ...]`). Discover them with `members_list`, whose
`.user.uuid` is exactly this value, braces included. Parity with bash
`bb pr-create --reviewer <uuid>` (repeatable).
`close_source_branch` defaults to False: deleting the source branch on
merge is a destructive action, so it is opt-in, never automatic (the
same stance `gh pr merge` takes with `--delete-branch`). Pass
`close_source_branch=True` to have the branch deleted when the PR
merges. Parity with bash `bb pr-create --close-source-branch`.
"""
for label, value in (
("title", title),
("source_branch", source_branch),
("destination_branch", destination_branch),
):
# Strip-check rather than truthiness so " " / "\n\t" don't slip
# through. A whitespace-only PR title is technically accepted by
# Bitbucket but visually meaningless in any PR list view.
if not isinstance(value, str) or not value.strip():
raise ValueError(
f"{label} must be a non-empty, non-whitespace string, got {value!r}"
)
if not isinstance(description, str):
raise ValueError(
f"description must be a string, got {type(description).__name__}"
)
if not isinstance(close_source_branch, bool):
raise ValueError(
f"close_source_branch must be a bool, "
f"got {type(close_source_branch).__name__}"
)
payload: dict[str, Any] = {
"title": title,
"source": {"branch": {"name": source_branch}},
"destination": {"branch": {"name": destination_branch}},
"close_source_branch": close_source_branch,
}
# Bash includes an empty description string ALWAYS; Python omits
# when the description is empty or whitespace-only so the API payload
# stays meaningful. Parity item: bash should align on omission.
if description.strip():
payload["description"] = description
if reviewers is not None:
# A bare string is technically an Iterable[str] (yields characters),
# which would silently produce `[{"uuid":"a"}, {"uuid":"l"}, ...]`
# from `reviewers="alice-uuid"`. Reject explicitly so the typo
# fails locally rather than as a 400 from Bitbucket.
# Same helper pr_update uses, so both paths validate identically
# AND canonicalise a bare uuid to the braced form the API returns.
uuids = _normalise_reviewer_uuids("reviewers", reviewers)
if uuids:
payload["reviewers"] = [{"uuid": u} for u in uuids]
return client.post(_prs_root(workspace, repo), json_body=payload)
# Bitbucket account UUIDs, as returned by members_list and on a PR's
# reviewers/participants, are BRACED: {8-4-4-4-12}. Callers routinely strip
# the braces when copying one by hand.
# Braces must be BALANCED. `\{?...\}?` would also match a half-braced
# `{aaaa…` (leading brace, no closer), which then fails the matched-pair
# strip below and gets re-wrapped into `{{aaaa…}` — mangling input the
# docstring promises to return untouched.
_UUID_CORE = (
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
_UUID_RE = re.compile(r"^(?:\{" + _UUID_CORE + r"\}|" + _UUID_CORE + r")$")
def _canonical_reviewer_uuid(value: str) -> str:
"""Return a uuid in the braced form the API uses.
pr_update's reviewer arithmetic compares caller-supplied uuids against
`.reviewers[].uuid` and `.participants[].user.uuid` from a live
response, all of which are braced. A bare uuid would therefore match
nothing: the removal would silently no-op and an add would append a
duplicate. Converging both forms here is what makes accepting either
one safe.
Anything that is not uuid-shaped is returned untouched — this layer
does not validate format (the bash `_require_*` sibling does), and
wrapping an unrecognised string in braces would corrupt it.
"""
stripped = value.strip()
if not _UUID_RE.match(stripped):
return value
core = stripped[1:-1] if stripped.startswith("{") and stripped.endswith("}") else stripped
return "{" + core + "}"
def _normalise_reviewer_uuids(label: str, values: Iterable[str]) -> list[str]:
"""Validate a reviewer-uuid iterable and return it as a list.
Rejects a bare string for the same reason pr_create does: a str is an
Iterable[str] that yields CHARACTERS, so `add_reviewers="{abc}"` would
silently become one reviewer per character.
"""
if isinstance(values, str):
raise ValueError(
f"{label} must be a list/tuple of uuids, not a bare string. "
f"Got {values!r}; did you mean [{values!r}]?"
)
out: list[str] = []
for uuid in values:
if not isinstance(uuid, str) or not uuid.strip():
raise ValueError(
f"{label} uuids must be non-empty strings, got {uuid!r}"
)
out.append(_canonical_reviewer_uuid(uuid))
return out
def pr_update(
client: BBClient,
workspace: str,
repo: str,
pr_id: int,
*,
title: str | None = None,
description: str | None = None,
add_reviewers: Iterable[str] | None = None,
remove_reviewers: Iterable[str] | None = None,
drop_approvals: bool = False,
) -> dict[str, Any]:
"""Update an OPEN pull request's title and/or description.
Bitbucket's PR mutation endpoint is a PUT to the SAME path pr_show GETs:
PUT /repositories/{ws}/{slug}/pullrequests/{id}. Only open pull requests
can be mutated. Only the fields present in the body change — the PUT
merges them into the existing PR, preserving the source/destination
branches and reviewers (Bitbucket keeps omitted PR fields on this
endpoint, so a title-only or description-only update is safe).
Note the HTTP method is PUT, not PATCH: Bitbucket Cloud has no PATCH for
the pullrequests resource. This mirrors repo_update, which PUTs the same
path repo_show GETs for the analogous repo mutation.
`title`: None (omit) = leave unchanged; any non-empty, non-whitespace
string = set it. An empty/whitespace title is INVALID (a PR must have a
title — Bitbucket rejects a blank one and it's meaningless in any PR
list view), so it's rejected at the boundary rather than sent.
`description`: None (omit) = leave unchanged; "" = intentionally CLEAR
the body; any other string = set it. This three-way distinction matches
repo_update's description contract so a deliberate clear isn't collapsed
into a no-op.
`add_reviewers` / `remove_reviewers` change who is asked to review an
EXISTING PR. Bitbucket has no add-a-reviewer endpoint: the PUT REPLACES
the whole `reviewers` array, so sending just the person being added
would silently unassign everyone else. Both options therefore read the
PR first and send the full resulting list.
That read-modify-write has a race: a reviewer added by someone else
between the GET and the PUT is lost. Bitbucket exposes no ETag or
if-match on this endpoint, so the window cannot be closed here; it is
narrow and the operation is trivially repeatable.
Deliberately absent: a wholesale "set the reviewers to exactly this"
option. Replace is the operation that silently discards other people's
work, and add/remove composes to the same result with each change
stated explicitly. Adding someone already on the PR is a no-op rather
than a duplicate, so both are idempotent.
`drop_approvals` guards the destructive half. Removing a reviewer who
has already APPROVED discards that approval, and re-adding them does
not bring it back — Bitbucket resets their participant state. That is
not recoverable from the CLI, so it is refused unless the caller opts
in explicitly, matching the repo-wide rule that a destructive action is
never a default. Removing a reviewer who has NOT approved needs no
opt-in.
At least one of `title` / `description` / `add_reviewers` /
`remove_reviewers` must be supplied; a PUT with an empty body would be
a no-op round-trip, so it's rejected at the boundary before burning an
API call.
Returns the updated PR record.
"""
_validate_pr_id(pr_id)
payload: dict[str, Any] = {}
if title is not None:
if not isinstance(title, str) or not title.strip():
raise ValueError(
f"title must be a non-empty, non-whitespace string when "
f"provided, got {title!r}"
)
payload["title"] = title
if description is not None:
if not isinstance(description, str):
raise ValueError(
f"description must be a string when provided, got "
f"{type(description).__name__}"
)
payload["description"] = description
to_add = (
_normalise_reviewer_uuids("add_reviewers", add_reviewers)
if add_reviewers is not None
else None
)
to_remove = (
_normalise_reviewer_uuids("remove_reviewers", remove_reviewers)
if remove_reviewers is not None
else None
)
# An empty list is a caller mistake rather than a meaningful request:
# "add nobody" / "remove nobody" would fall through to the
# at-least-one-field error below and report something confusing.
for label, values in (("add_reviewers", to_add), ("remove_reviewers", to_remove)):
if values is not None and not values:
raise ValueError(f"{label} was empty; nothing to change")
if to_add is not None or to_remove is not None:
pr_path = f"{_prs_root(workspace, repo)}/{pr_id}"
current = client.get(pr_path)
current_uuids = [
r["uuid"]
for r in (current.get("reviewers") or [])
if isinstance(r, dict) and r.get("uuid")
]
removing = set(to_remove or [])
if removing:
# An approval is only visible on `participants`, not on
# `reviewers`, so the check reads the participant record for
# each person being removed.
approved_uuids = {
p["user"]["uuid"]
for p in (current.get("participants") or [])
if isinstance(p, dict)
and p.get("approved")
and isinstance(p.get("user"), dict)
and p["user"].get("uuid")
}
dropping_approvals = sorted(removing & approved_uuids)
if dropping_approvals and not drop_approvals:
raise ValueError(
"refusing to remove reviewer(s) who have already approved: "
f"{', '.join(dropping_approvals)}. Removing them discards "
"the approval and re-adding them does not restore it. Pass "
"drop_approvals=True to do it anyway."
)
# Order is deliberate: survivors keep their existing position and
# additions append, so a repeated call produces a stable list.
new_uuids = [u for u in current_uuids if u not in removing]
for uuid in to_add or []:
if uuid not in new_uuids:
new_uuids.append(uuid)
payload["reviewers"] = [{"uuid": u} for u in new_uuids]
if not payload:
raise ValueError(
"pr_update requires at least one field to change "
"(title, description, add_reviewers and/or remove_reviewers)"
)
return client.put(f"{_prs_root(workspace, repo)}/{pr_id}", json_body=payload)
def pr_approve(
client: BBClient, workspace: str, repo: str, pr_id: int
) -> Any:
"""Approve a pull request as the authenticated user. Returns the
approval record; the bash equivalent discards it with `> /dev/null`."""
_validate_pr_id(pr_id)
return client.post(f"{_prs_root(workspace, repo)}/{pr_id}/approve")
def pr_unapprove(
client: BBClient, workspace: str, repo: str, pr_id: int
) -> Any:
"""Remove the authenticated user's approval from a PR.
Not exposed by the bash CLI today — this is one of the parity gaps
that 4.7 will fill. The Bitbucket REST contract is a DELETE against
the same /approve subpath that POST uses for approval.
"""
_validate_pr_id(pr_id)
return client.delete(f"{_prs_root(workspace, repo)}/{pr_id}/approve")
def pr_merge(
client: BBClient,
workspace: str,
repo: str,
pr_id: int,
*,
strategy: str = "merge_commit",
close_source_branch: bool = False,
message: str | None = None,
) -> dict[str, Any]:
"""Merge a pull request.
Bitbucket Cloud's documented strategies: `merge_commit` (default),
`squash`, `fast_forward`. We validate at the boundary so a typo
fails locally rather than burning an API call to get a 400.
`message` overrides the default merge-commit message.
`close_source_branch` defaults to False and is always sent
explicitly in the merge payload. Deleting the source branch on merge
is a destructive action, so it is opt-in, never automatic (the same
stance `gh pr merge` takes with `--delete-branch`). Sending False
explicitly also OVERRIDES whatever `close_source_branch` the PR was
stored with at creation — the merge API's value wins over the PR's —
so a PR created by an older `bb`, or by the Bitbucket UI, with the
box checked will still keep its source branch here unless the caller
opts in. Pass `close_source_branch=True` to delete on merge. Parity
with bash `bb pr-merge --close-source-branch`.
"""
_validate_pr_id(pr_id)
# isinstance gate before the membership test: a non-hashable strategy
# (list, dict, set) would otherwise raise TypeError from the frozenset
# `in` check rather than the documented ValueError, breaking the
# "every boundary failure is ValueError" convention this file follows.
if not isinstance(strategy, str) or strategy not in _VALID_MERGE_STRATEGIES:
raise ValueError(
f"strategy must be one of {sorted(_VALID_MERGE_STRATEGIES)}, "
f"got {strategy!r}"
)
if not isinstance(close_source_branch, bool):
raise ValueError(
f"close_source_branch must be a bool, "
f"got {type(close_source_branch).__name__}"
)
# Symmetric with pr_comment_add's body validation: empty (or
# whitespace-only) message would produce a blank merge-commit
# subject line, visually empty in any `git log --oneline` view.
# Reject at the boundary so every invalid input costs zero network IO.
if message is not None and (not isinstance(message, str) or not message.strip()):
raise ValueError(
f"message must be a non-empty, non-whitespace string "
f"when provided, got {message!r}"
)
payload: dict[str, Any] = {
"type": "pullrequest",
"merge_strategy": strategy,
"close_source_branch": close_source_branch,
}
if message is not None:
payload["message"] = message
# Bitbucket's PR merge endpoint is POST per the REST docs. An earlier
# version of this op (and bash cmd_pr_merge) used PUT on the
# historical assumption that Bitbucket accepted either; that's no
# longer true. PUT now returns HTTP 403 + "This endpoint does not
# support token-based authentication" (an unhelpful error that
# actually means "wrong method here"). Confirmed against
# dreamfacesbir/ryan-os PR#2 (2026-06-28) where direct POST with the
# same API token merged cleanly. Keep POST; do not "improve" back.
return client.post(
f"{_prs_root(workspace, repo)}/{pr_id}/merge",
json_body=payload,
)
def pr_decline(
client: BBClient, workspace: str, repo: str, pr_id: int
) -> Any:
"""Decline (close without merging) a pull request."""
_validate_pr_id(pr_id)
return client.post(f"{_prs_root(workspace, repo)}/{pr_id}/decline")
def pr_diff(
client: BBClient,
workspace: str,
repo: str,
pr_id: int,
*,
timeout: float = 120.0,
) -> str:
"""Fetch the unified diff text for a pull request.
Bitbucket returns plain text (not JSON), so we route through
`fetch_redirected_text`. Today the diff endpoint does NOT redirect,
so this is functionally equivalent to a direct GET. If Bitbucket
ever introduces a redirect, the cross-host-auth-strip protection
kicks in — but the returned body would then be whatever the redirect
target serves (a behavioural divergence from bash, which uses
`curl -sf` without `-L` and would fail visibly on any 3xx). Until
that happens, the two surfaces produce identical text.
"""
_validate_pr_id(pr_id)
return client.fetch_redirected_text(
f"{_prs_root(workspace, repo)}/{pr_id}/diff",