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.
- bitbucket_jira_cli/__init__.py +9 -0
- bitbucket_jira_cli/_async.py +17 -0
- bitbucket_jira_cli/api/__init__.py +1 -0
- bitbucket_jira_cli/api/adf.py +45 -0
- bitbucket_jira_cli/api/base.py +88 -0
- bitbucket_jira_cli/api/bitbucket.py +304 -0
- bitbucket_jira_cli/api/jira.py +147 -0
- bitbucket_jira_cli/auth/__init__.py +1 -0
- bitbucket_jira_cli/auth/commands.py +366 -0
- bitbucket_jira_cli/auth/store.py +97 -0
- bitbucket_jira_cli/commands/__init__.py +1 -0
- bitbucket_jira_cli/commands/_common.py +65 -0
- bitbucket_jira_cli/commands/issue.py +410 -0
- bitbucket_jira_cli/commands/misc.py +111 -0
- bitbucket_jira_cli/commands/pipeline.py +180 -0
- bitbucket_jira_cli/commands/pr.py +817 -0
- bitbucket_jira_cli/commands/repo.py +144 -0
- bitbucket_jira_cli/config.py +95 -0
- bitbucket_jira_cli/context.py +70 -0
- bitbucket_jira_cli/errors.py +33 -0
- bitbucket_jira_cli/git.py +126 -0
- bitbucket_jira_cli/gitauth.py +63 -0
- bitbucket_jira_cli/interaction.py +121 -0
- bitbucket_jira_cli/jira_fields.py +86 -0
- bitbucket_jira_cli/jira_ops.py +37 -0
- bitbucket_jira_cli/main.py +67 -0
- bitbucket_jira_cli/render.py +273 -0
- bitbucket_jira_cli/timefmt.py +42 -0
- bitbucket_jira_cli/ui.py +26 -0
- bitbucket_jira_cli-1.0.0.dist-info/METADATA +106 -0
- bitbucket_jira_cli-1.0.0.dist-info/RECORD +34 -0
- bitbucket_jira_cli-1.0.0.dist-info/WHEEL +4 -0
- bitbucket_jira_cli-1.0.0.dist-info/entry_points.txt +3 -0
- bitbucket_jira_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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."""
|