bitbucket-jira-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """bitbucket-jira-cli: a gh-style CLI for Bitbucket and Jira."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version
5
+
6
+ try:
7
+ __version__ = version("bitbucket-jira-cli")
8
+ except PackageNotFoundError: # pragma: no cover - not installed (e.g. running from source)
9
+ __version__ = "0.0.0"
@@ -0,0 +1,17 @@
1
+ """Bridge between Typer's sync commands and the async httpx clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TYPE_CHECKING
7
+ from typing import TypeVar
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Coroutine
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ def run(coro: Coroutine[object, object, T]) -> T:
16
+ """Run an async command body to completion from a sync Typer callback."""
17
+ return asyncio.run(coro)
@@ -0,0 +1 @@
1
+ """Async HTTP clients for the Bitbucket Cloud and Jira Cloud REST APIs."""
@@ -0,0 +1,45 @@
1
+ """Minimal Atlassian Document Format (ADF) helpers for Jira v3 bodies.
2
+
3
+ Jira Cloud v3 requires ADF JSON (not plain text/wiki markup) for issue
4
+ descriptions and comments. We accept plain text / light Markdown from the user
5
+ and emit a valid ADF document: blank-line-separated paragraphs, preserving
6
+ hard line breaks within a paragraph.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ def text_to_adf(text: str) -> dict[str, Any]:
15
+ paragraphs = text.replace("\r\n", "\n").split("\n\n")
16
+ content: list[dict[str, Any]] = []
17
+ for para in paragraphs:
18
+ lines = para.split("\n")
19
+ nodes: list[dict[str, Any]] = []
20
+ for i, line in enumerate(lines):
21
+ if line:
22
+ nodes.append({"type": "text", "text": line})
23
+ if i < len(lines) - 1:
24
+ nodes.append({"type": "hardBreak"})
25
+ content.append({"type": "paragraph", "content": nodes or [{"type": "text", "text": ""}]})
26
+ if not content:
27
+ content = [{"type": "paragraph", "content": []}]
28
+ return {"type": "doc", "version": 1, "content": content}
29
+
30
+
31
+ def adf_to_text(node: Any) -> str:
32
+ """Best-effort flatten of an ADF document back to plain text for display."""
33
+ if isinstance(node, str):
34
+ return node
35
+ if isinstance(node, list):
36
+ return "".join(adf_to_text(c) for c in node)
37
+ if not isinstance(node, dict):
38
+ return ""
39
+ node_type = node.get("type")
40
+ if node_type == "text":
41
+ return str(node.get("text", ""))
42
+ if node_type == "hardBreak":
43
+ return "\n"
44
+ children = "".join(adf_to_text(c) for c in node.get("content", []))
45
+ return children + "\n" if node_type == "paragraph" else children
@@ -0,0 +1,88 @@
1
+ """Shared async client plumbing: request, error mapping, JSON helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from bitbucket_jira_cli.errors import ApiError
11
+
12
+ if TYPE_CHECKING:
13
+ from types import TracebackType
14
+
15
+ DEFAULT_TIMEOUT = 30.0
16
+
17
+
18
+ class BaseAsyncClient:
19
+ """Thin wrapper over ``httpx.AsyncClient`` with error mapping.
20
+
21
+ Subclasses set ``backend`` (for error messages) and pass a base URL and
22
+ default headers (including ``Authorization``).
23
+ """
24
+
25
+ backend = "api"
26
+
27
+ def __init__(self, base_url: str, headers: dict[str, str]) -> None:
28
+ self._client = httpx.AsyncClient(
29
+ base_url=base_url,
30
+ headers={"Accept": "application/json", **headers},
31
+ timeout=DEFAULT_TIMEOUT,
32
+ follow_redirects=True,
33
+ )
34
+
35
+ async def __aenter__(self) -> BaseAsyncClient:
36
+ return self
37
+
38
+ async def __aexit__(
39
+ self,
40
+ exc_type: type[BaseException] | None,
41
+ exc: BaseException | None,
42
+ tb: TracebackType | None,
43
+ ) -> None:
44
+ await self.aclose()
45
+
46
+ async def aclose(self) -> None:
47
+ await self._client.aclose()
48
+
49
+ def _raise(self, response: httpx.Response) -> None:
50
+ detail = self._error_detail(response)
51
+ raise ApiError(self.backend, response.status_code, detail)
52
+
53
+ @staticmethod
54
+ def _error_detail(response: httpx.Response) -> str:
55
+ try:
56
+ body = response.json()
57
+ except ValueError:
58
+ return response.text[:300] or response.reason_phrase
59
+ # Bitbucket: {"error": {"message": ...}}; Jira: {"errorMessages": [...]}
60
+ if isinstance(body, dict):
61
+ if isinstance(body.get("error"), dict) and body["error"].get("message"):
62
+ return str(body["error"]["message"])
63
+ if body.get("errorMessages"):
64
+ return "; ".join(body["errorMessages"])
65
+ if body.get("errors"):
66
+ return "; ".join(f"{k}: {v}" for k, v in body["errors"].items())
67
+ return response.text[:300] or response.reason_phrase
68
+
69
+ async def request(
70
+ self,
71
+ method: str,
72
+ url: str,
73
+ *,
74
+ params: dict[str, Any] | None = None,
75
+ json: Any = None,
76
+ content: bytes | str | None = None,
77
+ headers: dict[str, str] | None = None,
78
+ ) -> httpx.Response:
79
+ response = await self._client.request(
80
+ method, url, params=params, json=json, content=content, headers=headers
81
+ )
82
+ if response.status_code >= httpx.codes.BAD_REQUEST:
83
+ self._raise(response)
84
+ return response
85
+
86
+ async def get_json(self, url: str, *, params: dict[str, Any] | None = None) -> Any:
87
+ response = await self.request("GET", url, params=params)
88
+ return response.json()
@@ -0,0 +1,304 @@
1
+ """Bitbucket Cloud REST API v2.0 async client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ from bitbucket_jira_cli.api.base import BaseAsyncClient
9
+
10
+ BASE_URL = "https://api.bitbucket.org/2.0"
11
+
12
+
13
+ class BitbucketClient(BaseAsyncClient):
14
+ backend = "Bitbucket"
15
+
16
+ def __init__(self, authorization: str) -> None:
17
+ super().__init__(BASE_URL, {"Authorization": authorization})
18
+
19
+ # -- identity -----------------------------------------------------------
20
+ async def current_user(self) -> dict[str, Any]:
21
+ return await self.get_json("/user")
22
+
23
+ # -- repositories -------------------------------------------------------
24
+ async def get_repo(self, workspace: str, repo_slug: str) -> dict[str, Any]:
25
+ return await self.get_json(f"/repositories/{workspace}/{repo_slug}")
26
+
27
+ async def list_repos(
28
+ self, workspace: str, *, query: str | None = None, role: str | None = None, limit: int = 30
29
+ ) -> list[dict[str, Any]]:
30
+ params: dict[str, Any] = {"pagelen": min(limit, 100)}
31
+ if query:
32
+ params["q"] = query
33
+ if role:
34
+ params["role"] = role
35
+ return await self._paginate(f"/repositories/{workspace}", params=params, limit=limit)
36
+
37
+ async def list_workspace_members(
38
+ self, workspace: str, *, limit: int = 100
39
+ ) -> list[dict[str, Any]]:
40
+ return await self._paginate(f"/workspaces/{workspace}/members", limit=limit)
41
+
42
+ # -- pull requests ------------------------------------------------------
43
+ def _pr_base(self, workspace: str, repo_slug: str) -> str:
44
+ return f"/repositories/{workspace}/{repo_slug}/pullrequests"
45
+
46
+ async def create_pr(
47
+ self, workspace: str, repo_slug: str, body: dict[str, Any]
48
+ ) -> dict[str, Any]:
49
+ response = await self.request("POST", self._pr_base(workspace, repo_slug), json=body)
50
+ return response.json()
51
+
52
+ async def list_prs(
53
+ self,
54
+ workspace: str,
55
+ repo_slug: str,
56
+ *,
57
+ state: str | None = None,
58
+ source_branch: str | None = None,
59
+ query: str | None = None,
60
+ limit: int = 30,
61
+ ) -> list[dict[str, Any]]:
62
+ clauses = []
63
+ if state:
64
+ clauses.append(f'state="{state}"')
65
+ if source_branch:
66
+ clauses.append(f'source.branch.name="{source_branch}"')
67
+ if query:
68
+ clauses.append(query)
69
+ params: dict[str, Any] = {"pagelen": min(limit, 50)}
70
+ if clauses:
71
+ params["q"] = " AND ".join(clauses)
72
+ return await self._paginate(self._pr_base(workspace, repo_slug), params=params, limit=limit)
73
+
74
+ async def get_pr(self, workspace: str, repo_slug: str, pr_id: int) -> dict[str, Any]:
75
+ return await self.get_json(f"{self._pr_base(workspace, repo_slug)}/{pr_id}")
76
+
77
+ async def update_pr(
78
+ self, workspace: str, repo_slug: str, pr_id: int, body: dict[str, Any]
79
+ ) -> dict[str, Any]:
80
+ response = await self.request(
81
+ "PUT", f"{self._pr_base(workspace, repo_slug)}/{pr_id}", json=body
82
+ )
83
+ return response.json()
84
+
85
+ async def merge_pr(
86
+ self, workspace: str, repo_slug: str, pr_id: int, body: dict[str, Any]
87
+ ) -> dict[str, Any]:
88
+ response = await self.request(
89
+ "POST", f"{self._pr_base(workspace, repo_slug)}/{pr_id}/merge", json=body
90
+ )
91
+ return response.json()
92
+
93
+ async def decline_pr(self, workspace: str, repo_slug: str, pr_id: int) -> dict[str, Any]:
94
+ response = await self.request(
95
+ "POST", f"{self._pr_base(workspace, repo_slug)}/{pr_id}/decline"
96
+ )
97
+ return response.json()
98
+
99
+ async def pr_diff(self, workspace: str, repo_slug: str, pr_id: int) -> str:
100
+ response = await self.request("GET", f"{self._pr_base(workspace, repo_slug)}/{pr_id}/diff")
101
+ return response.text
102
+
103
+ async def pr_diffstat(self, workspace: str, repo_slug: str, pr_id: int) -> list[dict[str, Any]]:
104
+ return await self._paginate(
105
+ f"{self._pr_base(workspace, repo_slug)}/{pr_id}/diffstat", params={"pagelen": 100}
106
+ )
107
+
108
+ async def approve_pr(self, workspace: str, repo_slug: str, pr_id: int) -> dict[str, Any]:
109
+ response = await self.request(
110
+ "POST", f"{self._pr_base(workspace, repo_slug)}/{pr_id}/approve"
111
+ )
112
+ return response.json()
113
+
114
+ async def unapprove_pr(self, workspace: str, repo_slug: str, pr_id: int) -> None:
115
+ await self.request("DELETE", f"{self._pr_base(workspace, repo_slug)}/{pr_id}/approve")
116
+
117
+ async def request_changes_pr(
118
+ self, workspace: str, repo_slug: str, pr_id: int
119
+ ) -> dict[str, Any]:
120
+ response = await self.request(
121
+ "POST", f"{self._pr_base(workspace, repo_slug)}/{pr_id}/request-changes"
122
+ )
123
+ return response.json()
124
+
125
+ async def list_pr_comments(
126
+ self, workspace: str, repo_slug: str, pr_id: int, *, limit: int = 100
127
+ ) -> list[dict[str, Any]]:
128
+ return await self._paginate(
129
+ f"{self._pr_base(workspace, repo_slug)}/{pr_id}/comments",
130
+ params={"pagelen": 100},
131
+ limit=limit,
132
+ )
133
+
134
+ async def add_pr_comment(
135
+ self,
136
+ workspace: str,
137
+ repo_slug: str,
138
+ pr_id: int,
139
+ text: str,
140
+ *,
141
+ inline: dict[str, Any] | None = None,
142
+ parent_id: int | None = None,
143
+ ) -> dict[str, Any]:
144
+ body: dict[str, Any] = {"content": {"raw": text}}
145
+ if inline:
146
+ body["inline"] = inline
147
+ if parent_id is not None:
148
+ body["parent"] = {"id": parent_id}
149
+ response = await self.request(
150
+ "POST", f"{self._pr_base(workspace, repo_slug)}/{pr_id}/comments", json=body
151
+ )
152
+ return response.json()
153
+
154
+ def _comment_base(self, workspace: str, repo_slug: str, pr_id: int) -> str:
155
+ return f"{self._pr_base(workspace, repo_slug)}/{pr_id}/comments"
156
+
157
+ async def update_pr_comment(
158
+ self, workspace: str, repo_slug: str, pr_id: int, comment_id: int, text: str
159
+ ) -> dict[str, Any]:
160
+ response = await self.request(
161
+ "PUT",
162
+ f"{self._comment_base(workspace, repo_slug, pr_id)}/{comment_id}",
163
+ json={"content": {"raw": text}},
164
+ )
165
+ return response.json()
166
+
167
+ async def delete_pr_comment(
168
+ self, workspace: str, repo_slug: str, pr_id: int, comment_id: int
169
+ ) -> None:
170
+ await self.request(
171
+ "DELETE", f"{self._comment_base(workspace, repo_slug, pr_id)}/{comment_id}"
172
+ )
173
+
174
+ async def set_pr_comment_resolved(
175
+ self, workspace: str, repo_slug: str, pr_id: int, comment_id: int, *, resolved: bool
176
+ ) -> None:
177
+ method = "POST" if resolved else "DELETE"
178
+ await self.request(
179
+ method, f"{self._comment_base(workspace, repo_slug, pr_id)}/{comment_id}/resolve"
180
+ )
181
+
182
+ # -- pull request tasks -------------------------------------------------
183
+ def _task_base(self, workspace: str, repo_slug: str, pr_id: int) -> str:
184
+ return f"{self._pr_base(workspace, repo_slug)}/{pr_id}/tasks"
185
+
186
+ async def list_pr_tasks(
187
+ self, workspace: str, repo_slug: str, pr_id: int, *, limit: int = 100
188
+ ) -> list[dict[str, Any]]:
189
+ return await self._paginate(
190
+ self._task_base(workspace, repo_slug, pr_id), params={"pagelen": 100}, limit=limit
191
+ )
192
+
193
+ async def add_pr_task(
194
+ self,
195
+ workspace: str,
196
+ repo_slug: str,
197
+ pr_id: int,
198
+ text: str,
199
+ *,
200
+ comment_id: int | None = None,
201
+ ) -> dict[str, Any]:
202
+ body: dict[str, Any] = {"content": {"raw": text}}
203
+ if comment_id is not None:
204
+ body["comment"] = {"id": comment_id}
205
+ response = await self.request(
206
+ "POST", self._task_base(workspace, repo_slug, pr_id), json=body
207
+ )
208
+ return response.json()
209
+
210
+ async def update_pr_task(
211
+ self,
212
+ workspace: str,
213
+ repo_slug: str,
214
+ pr_id: int,
215
+ task_id: int,
216
+ *,
217
+ state: str | None = None,
218
+ text: str | None = None,
219
+ ) -> dict[str, Any]:
220
+ body: dict[str, Any] = {}
221
+ if state is not None:
222
+ body["state"] = state
223
+ if text is not None:
224
+ body["content"] = {"raw": text}
225
+ response = await self.request(
226
+ "PUT", f"{self._task_base(workspace, repo_slug, pr_id)}/{task_id}", json=body
227
+ )
228
+ return response.json()
229
+
230
+ async def delete_pr_task(
231
+ self, workspace: str, repo_slug: str, pr_id: int, task_id: int
232
+ ) -> None:
233
+ await self.request(
234
+ "DELETE", f"{self._task_base(workspace, repo_slug, pr_id)}/{task_id}"
235
+ )
236
+
237
+ # -- pipelines ----------------------------------------------------------
238
+ def _pipe_base(self, workspace: str, repo_slug: str) -> str:
239
+ return f"/repositories/{workspace}/{repo_slug}/pipelines"
240
+
241
+ async def list_pipelines(
242
+ self, workspace: str, repo_slug: str, *, limit: int = 30
243
+ ) -> list[dict[str, Any]]:
244
+ return await self._paginate(
245
+ self._pipe_base(workspace, repo_slug),
246
+ params={"pagelen": min(limit, 50), "sort": "-created_on"},
247
+ limit=limit,
248
+ )
249
+
250
+ async def run_pipeline(
251
+ self, workspace: str, repo_slug: str, body: dict[str, Any]
252
+ ) -> dict[str, Any]:
253
+ response = await self.request("POST", self._pipe_base(workspace, repo_slug), json=body)
254
+ return response.json()
255
+
256
+ async def get_pipeline(self, workspace: str, repo_slug: str, uuid: str) -> dict[str, Any]:
257
+ return await self.get_json(f"{self._pipe_base(workspace, repo_slug)}/{quote(uuid)}")
258
+
259
+ async def stop_pipeline(self, workspace: str, repo_slug: str, uuid: str) -> None:
260
+ await self.request(
261
+ "POST", f"{self._pipe_base(workspace, repo_slug)}/{quote(uuid)}/stopPipeline"
262
+ )
263
+
264
+ async def list_pipeline_steps(
265
+ self, workspace: str, repo_slug: str, uuid: str
266
+ ) -> list[dict[str, Any]]:
267
+ return await self._paginate(
268
+ f"{self._pipe_base(workspace, repo_slug)}/{quote(uuid)}/steps", params={"pagelen": 100}
269
+ )
270
+
271
+ async def pipeline_step_log(
272
+ self, workspace: str, repo_slug: str, uuid: str, step_uuid: str
273
+ ) -> str:
274
+ response = await self.request(
275
+ "GET",
276
+ f"{self._pipe_base(workspace, repo_slug)}/{quote(uuid)}/steps/{quote(step_uuid)}/log",
277
+ )
278
+ return response.text
279
+
280
+ # -- generic passthrough (bj api) --------------------------------------
281
+ async def raw(
282
+ self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any = None
283
+ ) -> Any:
284
+ response = await self.request(method, path, params=params, json=json)
285
+ try:
286
+ return response.json()
287
+ except ValueError:
288
+ return response.text
289
+
290
+ # -- pagination ---------------------------------------------------------
291
+ async def _paginate(
292
+ self, url: str, *, params: dict[str, Any] | None = None, limit: int | None = None
293
+ ) -> list[dict[str, Any]]:
294
+ results: list[dict[str, Any]] = []
295
+ next_url: str | None = url
296
+ next_params = params
297
+ while next_url:
298
+ payload = await self.get_json(next_url, params=next_params)
299
+ results.extend(payload.get("values", []))
300
+ if limit is not None and len(results) >= limit:
301
+ return results[:limit]
302
+ next_url = payload.get("next")
303
+ next_params = None # `next` is an absolute URL with its own query
304
+ return results
@@ -0,0 +1,147 @@
1
+ """Jira Cloud REST API v3 async client.
2
+
3
+ Uses the current ``/search/jql`` endpoint (the old ``/search`` was removed).
4
+ Bodies (comments, descriptions) are ADF documents — see ``api.adf``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from bitbucket_jira_cli.api.base import DEFAULT_TIMEOUT
14
+ from bitbucket_jira_cli.api.base import BaseAsyncClient
15
+ from bitbucket_jira_cli.errors import ApiError
16
+
17
+
18
+ async def fetch_cloud_id(site: str) -> str:
19
+ """Resolve a site's cloudId from its public tenant_info endpoint.
20
+
21
+ Needed to address a site through the api.atlassian.com/ex/jira/{cloudId}
22
+ gateway (scoped-token mode). No authentication required.
23
+ """
24
+ backend = "Jira"
25
+ url = site.rstrip("/") + "/_edge/tenant_info"
26
+ async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, follow_redirects=True) as client:
27
+ response = await client.get(url)
28
+ if response.status_code >= httpx.codes.BAD_REQUEST:
29
+ msg = f"could not resolve cloudId from {url}"
30
+ raise ApiError(backend, response.status_code, msg)
31
+ cloud_id = response.json().get("cloudId")
32
+ if not cloud_id:
33
+ msg = "tenant_info returned no cloudId"
34
+ raise ApiError(backend, response.status_code, msg)
35
+ return str(cloud_id)
36
+
37
+
38
+ class JiraClient(BaseAsyncClient):
39
+ backend = "Jira"
40
+
41
+ def __init__(self, base_url: str, authorization: str) -> None:
42
+ super().__init__(base_url, {"Authorization": authorization})
43
+
44
+ # -- identity -----------------------------------------------------------
45
+ async def myself(self) -> dict[str, Any]:
46
+ return await self.get_json("/myself")
47
+
48
+ # -- issues -------------------------------------------------------------
49
+ async def get_issue(
50
+ self, key: str, *, fields: list[str] | None = None, expand: list[str] | None = None
51
+ ) -> dict[str, Any]:
52
+ params: dict[str, Any] = {}
53
+ if fields:
54
+ params["fields"] = ",".join(fields)
55
+ if expand:
56
+ params["expand"] = ",".join(expand)
57
+ return await self.get_json(f"/issue/{key}", params=params or None)
58
+
59
+ async def get_editmeta(self, key: str) -> dict[str, Any]:
60
+ data = await self.get_json(f"/issue/{key}/editmeta")
61
+ return data.get("fields", {})
62
+
63
+ async def search_assignable_users(
64
+ self, query: str, *, issue_key: str, limit: int = 10
65
+ ) -> list[dict[str, Any]]:
66
+ return await self.get_json(
67
+ "/user/assignable/search",
68
+ params={"query": query, "issueKey": issue_key, "maxResults": limit},
69
+ )
70
+
71
+ async def create_issue(self, body: dict[str, Any]) -> dict[str, Any]:
72
+ response = await self.request("POST", "/issue", json=body)
73
+ return response.json()
74
+
75
+ async def edit_issue(self, key: str, body: dict[str, Any]) -> None:
76
+ await self.request("PUT", f"/issue/{key}", json=body)
77
+
78
+ async def search(
79
+ self, jql: str, *, fields: list[str] | None = None, limit: int = 50
80
+ ) -> list[dict[str, Any]]:
81
+ issues: list[dict[str, Any]] = []
82
+ token: str | None = None
83
+ payload_fields = fields or [
84
+ "summary",
85
+ "status",
86
+ "assignee",
87
+ "issuetype",
88
+ "priority",
89
+ "updated",
90
+ ]
91
+ while True:
92
+ body: dict[str, Any] = {
93
+ "jql": jql,
94
+ "maxResults": min(limit - len(issues), 100),
95
+ "fields": payload_fields,
96
+ }
97
+ if token:
98
+ body["nextPageToken"] = token
99
+ response = await self.request("POST", "/search/jql", json=body)
100
+ data = response.json()
101
+ issues.extend(data.get("issues", []))
102
+ token = data.get("nextPageToken")
103
+ if not token or len(issues) >= limit:
104
+ break
105
+ return issues[:limit]
106
+
107
+ # -- comments -----------------------------------------------------------
108
+ async def add_comment(self, key: str, adf_body: dict[str, Any]) -> dict[str, Any]:
109
+ response = await self.request("POST", f"/issue/{key}/comment", json={"body": adf_body})
110
+ return response.json()
111
+
112
+ async def list_comments(self, key: str) -> list[dict[str, Any]]:
113
+ data = await self.get_json(f"/issue/{key}/comment")
114
+ return data.get("comments", [])
115
+
116
+ # -- transitions --------------------------------------------------------
117
+ async def get_transitions(self, key: str) -> list[dict[str, Any]]:
118
+ data = await self.get_json(f"/issue/{key}/transitions")
119
+ return data.get("transitions", [])
120
+
121
+ async def transition_issue(self, key: str, transition_id: str) -> None:
122
+ await self.request(
123
+ "POST", f"/issue/{key}/transitions", json={"transition": {"id": transition_id}}
124
+ )
125
+
126
+ # -- remote links (link a PR URL to an issue) ---------------------------
127
+ async def create_remote_link(
128
+ self, key: str, url: str, title: str, *, global_id: str | None = None
129
+ ) -> dict[str, Any]:
130
+ body: dict[str, Any] = {"object": {"url": url, "title": title}}
131
+ if global_id:
132
+ body["globalId"] = global_id
133
+ response = await self.request("POST", f"/issue/{key}/remotelink", json=body)
134
+ return response.json()
135
+
136
+ async def get_remote_links(self, key: str) -> list[dict[str, Any]]:
137
+ return await self.get_json(f"/issue/{key}/remotelink")
138
+
139
+ # -- generic passthrough (bj api) --------------------------------------
140
+ async def raw(
141
+ self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any = None
142
+ ) -> Any:
143
+ response = await self.request(method, path, params=params, json=json)
144
+ try:
145
+ return response.json()
146
+ except ValueError:
147
+ return response.text
@@ -0,0 +1 @@
1
+ """Authentication: credential storage and the `bj auth` command group."""