pygitcode 0.1.0__py3-none-any.whl → 0.1.2__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.
- gitcode_cli/__init__.py +1 -1
- gitcode_cli/adapters/__init__.py +7 -0
- gitcode_cli/adapters/base.py +14 -0
- gitcode_cli/adapters/capabilities.py +29 -0
- gitcode_cli/adapters/issues.py +239 -0
- gitcode_cli/adapters/pulls.py +170 -0
- gitcode_cli/cli.py +72 -7
- gitcode_cli/cli_compat.py +2 -0
- gitcode_cli/client.py +22 -8
- gitcode_cli/commands/auth.py +36 -4
- gitcode_cli/commands/issue.py +327 -70
- gitcode_cli/commands/pr.py +439 -211
- gitcode_cli/errors.py +4 -0
- gitcode_cli/formatters.py +15 -7
- gitcode_cli/helptext.py +220 -0
- gitcode_cli/services/__init__.py +2 -1
- gitcode_cli/services/issues.py +5 -2
- gitcode_cli/services/pulls.py +3 -0
- gitcode_cli/services/users.py +13 -0
- gitcode_cli/utils.py +34 -5
- {pygitcode-0.1.0.dist-info → pygitcode-0.1.2.dist-info}/METADATA +49 -12
- pygitcode-0.1.2.dist-info/RECORD +30 -0
- {pygitcode-0.1.0.dist-info → pygitcode-0.1.2.dist-info}/entry_points.txt +1 -0
- pygitcode-0.1.0.dist-info/RECORD +0 -23
- {pygitcode-0.1.0.dist-info → pygitcode-0.1.2.dist-info}/WHEEL +0 -0
- {pygitcode-0.1.0.dist-info → pygitcode-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {pygitcode-0.1.0.dist-info → pygitcode-0.1.2.dist-info}/top_level.txt +0 -0
gitcode_cli/__init__.py
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AdapterActionResult:
|
|
9
|
+
item: dict[str, Any] | None = None
|
|
10
|
+
items: list[dict[str, Any]] | None = None
|
|
11
|
+
message: str | None = None
|
|
12
|
+
warning: str | None = None
|
|
13
|
+
degraded: bool = False
|
|
14
|
+
approximated: bool = False
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
CAPABILITY_MESSAGES = {
|
|
6
|
+
"ISSUE_COMMENT_LAST_NOT_FOUND": "No editable issue comment from the current user was found.",
|
|
7
|
+
"ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE": (
|
|
8
|
+
"Unable to verify the current user's issue comments on GitCode; refusing to edit or delete comments safely."
|
|
9
|
+
),
|
|
10
|
+
"ISSUE_CREATE_IF_NONE_REQUIRES_EDIT_LAST": "--create-if-none can only be used together with --edit-last.",
|
|
11
|
+
"ISSUE_DELETE": "GitCode API does not support deleting issues.",
|
|
12
|
+
"ISSUE_DEVELOP_BASE": "--base and --name are not supported by 'gc issue develop'",
|
|
13
|
+
"ISSUE_DEVELOP_NAME": "--base and --name are not supported by 'gc issue develop'",
|
|
14
|
+
"ISSUE_STATUS_GH_SEMANTICS": "GitCode-limited approximation of gh issue status",
|
|
15
|
+
"PR_MERGE_AUTHOR_EMAIL": "GitCode merge API does not support --author-email.",
|
|
16
|
+
"PR_MERGE_AUTO": "GitCode merge API does not support --auto.",
|
|
17
|
+
"PR_REVIEW_REQUEST_CHANGES": (
|
|
18
|
+
"GitCode review API does not support request-changes reviews; the pull request comment was posted instead."
|
|
19
|
+
),
|
|
20
|
+
"PR_STATUS_GH_SEMANTICS": ("GitCode API approximation -- user-specific filtering is not available"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def capability_message(feature: str) -> str:
|
|
25
|
+
return CAPABILITY_MESSAGES[feature]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def unsupported(feature: str) -> click.ClickException:
|
|
29
|
+
return click.ClickException(capability_message(feature))
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..errors import APIError
|
|
8
|
+
from ..services import IssueService, UserService
|
|
9
|
+
from .base import AdapterActionResult
|
|
10
|
+
from .capabilities import capability_message, unsupported
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalize_multi_values(values: tuple[str, ...] | None) -> str | None:
|
|
14
|
+
if not values:
|
|
15
|
+
return None
|
|
16
|
+
return ",".join(values)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_label_names(issue: dict[str, Any] | None) -> list[str]:
|
|
20
|
+
labels = issue.get("labels") if isinstance(issue, dict) else None
|
|
21
|
+
if not labels:
|
|
22
|
+
return []
|
|
23
|
+
if isinstance(labels, str):
|
|
24
|
+
return [part.strip() for part in labels.split(",") if part.strip()]
|
|
25
|
+
|
|
26
|
+
names: list[str] = []
|
|
27
|
+
if isinstance(labels, list):
|
|
28
|
+
for label in labels:
|
|
29
|
+
if isinstance(label, dict):
|
|
30
|
+
name = label.get("name") or label.get("title")
|
|
31
|
+
if name:
|
|
32
|
+
names.append(str(name))
|
|
33
|
+
elif label:
|
|
34
|
+
names.append(str(label))
|
|
35
|
+
return names
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _extract_login(data: dict[str, Any] | None) -> str | None:
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
return None
|
|
41
|
+
for key in ("login", "username", "name"):
|
|
42
|
+
value = data.get(key)
|
|
43
|
+
if isinstance(value, str) and value.strip():
|
|
44
|
+
return value.strip()
|
|
45
|
+
user = data.get("user")
|
|
46
|
+
if isinstance(user, dict):
|
|
47
|
+
return _extract_login(user)
|
|
48
|
+
author = data.get("author")
|
|
49
|
+
if isinstance(author, dict):
|
|
50
|
+
return _extract_login(author)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IssueAdapter:
|
|
55
|
+
def __init__(self, service: IssueService, user_service: UserService | None = None):
|
|
56
|
+
self.service = service
|
|
57
|
+
self.user_service = user_service
|
|
58
|
+
|
|
59
|
+
def list_issues(
|
|
60
|
+
self,
|
|
61
|
+
owner: str,
|
|
62
|
+
repo: str,
|
|
63
|
+
*,
|
|
64
|
+
state: str | None,
|
|
65
|
+
labels: tuple[str, ...] | None,
|
|
66
|
+
author: str | None,
|
|
67
|
+
assignee: str | None,
|
|
68
|
+
milestone: str | None,
|
|
69
|
+
mention: str | None,
|
|
70
|
+
search: str | None,
|
|
71
|
+
limit: int | None,
|
|
72
|
+
) -> Any:
|
|
73
|
+
items = self.service.list(
|
|
74
|
+
owner,
|
|
75
|
+
repo,
|
|
76
|
+
state=state,
|
|
77
|
+
labels=_normalize_multi_values(labels),
|
|
78
|
+
creator=author,
|
|
79
|
+
assignee=assignee,
|
|
80
|
+
milestone=milestone,
|
|
81
|
+
mention=mention,
|
|
82
|
+
search=search,
|
|
83
|
+
)
|
|
84
|
+
if limit is not None:
|
|
85
|
+
return items[:limit]
|
|
86
|
+
return items
|
|
87
|
+
|
|
88
|
+
def create_issue(
|
|
89
|
+
self,
|
|
90
|
+
owner: str,
|
|
91
|
+
repo: str,
|
|
92
|
+
*,
|
|
93
|
+
title: str,
|
|
94
|
+
body: str | None,
|
|
95
|
+
assignee: str | None,
|
|
96
|
+
labels: tuple[str, ...] | None,
|
|
97
|
+
milestone: str | None,
|
|
98
|
+
) -> dict[str, Any] | None:
|
|
99
|
+
return self.service.create(
|
|
100
|
+
owner,
|
|
101
|
+
repo,
|
|
102
|
+
title=title,
|
|
103
|
+
body=body,
|
|
104
|
+
assignee=assignee,
|
|
105
|
+
labels=_normalize_multi_values(labels),
|
|
106
|
+
milestone=milestone,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def close_issue(
|
|
110
|
+
self,
|
|
111
|
+
owner: str,
|
|
112
|
+
repo: str,
|
|
113
|
+
number: str,
|
|
114
|
+
*,
|
|
115
|
+
comment: str | None,
|
|
116
|
+
reason: str | None,
|
|
117
|
+
) -> AdapterActionResult:
|
|
118
|
+
current = self.service.get(owner, repo, number)
|
|
119
|
+
if current and current.get("state") == "closed":
|
|
120
|
+
if comment:
|
|
121
|
+
self.service.comment(owner, repo, number, comment)
|
|
122
|
+
return AdapterActionResult(item=current, message="already_closed_commented")
|
|
123
|
+
return AdapterActionResult(item=current, message="already_closed")
|
|
124
|
+
if comment:
|
|
125
|
+
self.service.comment(owner, repo, number, comment)
|
|
126
|
+
payload: dict[str, Any] = {"state": "close"}
|
|
127
|
+
if reason:
|
|
128
|
+
payload["state_reason"] = reason
|
|
129
|
+
item = self.service.update(owner, repo, number, **payload)
|
|
130
|
+
return AdapterActionResult(item=item, message="closed")
|
|
131
|
+
|
|
132
|
+
def comment_issue(self, owner: str, repo: str, number: str, *, body: str) -> dict[str, Any] | None:
|
|
133
|
+
return self.service.comment(owner, repo, number, body)
|
|
134
|
+
|
|
135
|
+
def manage_comment_history(
|
|
136
|
+
self,
|
|
137
|
+
owner: str,
|
|
138
|
+
repo: str,
|
|
139
|
+
number: str,
|
|
140
|
+
*,
|
|
141
|
+
body: str | None,
|
|
142
|
+
delete_last: bool,
|
|
143
|
+
create_if_none: bool,
|
|
144
|
+
) -> AdapterActionResult:
|
|
145
|
+
comment = self._find_last_owned_comment(owner, repo, number)
|
|
146
|
+
if delete_last:
|
|
147
|
+
if comment is None:
|
|
148
|
+
raise click.ClickException(capability_message("ISSUE_COMMENT_LAST_NOT_FOUND"))
|
|
149
|
+
self.service.delete_comment(owner, repo, comment["id"])
|
|
150
|
+
return AdapterActionResult(item=comment, message="deleted")
|
|
151
|
+
if comment is None:
|
|
152
|
+
if create_if_none:
|
|
153
|
+
item = self.service.comment(owner, repo, number, body or "")
|
|
154
|
+
return AdapterActionResult(item=item, message="created")
|
|
155
|
+
raise click.ClickException(capability_message("ISSUE_COMMENT_LAST_NOT_FOUND"))
|
|
156
|
+
item = self.service.update_comment(owner, repo, comment["id"], body or "")
|
|
157
|
+
return AdapterActionResult(item=item, message="edited")
|
|
158
|
+
|
|
159
|
+
def _find_last_owned_comment(self, owner: str, repo: str, number: str) -> dict[str, Any] | None:
|
|
160
|
+
if self.user_service is None:
|
|
161
|
+
raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE"))
|
|
162
|
+
try:
|
|
163
|
+
current_user = self.user_service.current()
|
|
164
|
+
except APIError as exc:
|
|
165
|
+
raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE")) from exc
|
|
166
|
+
current_login = _extract_login(current_user)
|
|
167
|
+
if not current_login:
|
|
168
|
+
raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE"))
|
|
169
|
+
comments = self.service.list_comments(owner, repo, number) or []
|
|
170
|
+
for comment in reversed(comments):
|
|
171
|
+
if not isinstance(comment, dict):
|
|
172
|
+
continue
|
|
173
|
+
if _extract_login(comment) == current_login:
|
|
174
|
+
return comment
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def reopen_issue(self, owner: str, repo: str, number: str) -> AdapterActionResult:
|
|
178
|
+
current = self.service.get(owner, repo, number)
|
|
179
|
+
if current and current.get("state") == "open":
|
|
180
|
+
return AdapterActionResult(item=current, message="already_open")
|
|
181
|
+
item = self.service.update(owner, repo, number, state="reopen")
|
|
182
|
+
return AdapterActionResult(item=item, message="reopened")
|
|
183
|
+
|
|
184
|
+
def delete_issue(self, owner: str, repo: str, number: str) -> AdapterActionResult: # noqa: ARG002
|
|
185
|
+
raise unsupported("ISSUE_DELETE")
|
|
186
|
+
|
|
187
|
+
def edit_issue(
|
|
188
|
+
self,
|
|
189
|
+
owner: str,
|
|
190
|
+
repo: str,
|
|
191
|
+
number: str,
|
|
192
|
+
*,
|
|
193
|
+
title: str | None,
|
|
194
|
+
body: str | None,
|
|
195
|
+
add_assignee: str | None,
|
|
196
|
+
add_labels: tuple[str, ...] | None,
|
|
197
|
+
milestone: str | None,
|
|
198
|
+
remove_milestone: bool,
|
|
199
|
+
) -> dict[str, Any] | None:
|
|
200
|
+
data = {k: v for k, v in {"title": title, "body": body}.items() if v is not None}
|
|
201
|
+
|
|
202
|
+
if add_assignee is not None:
|
|
203
|
+
data["assignee"] = add_assignee
|
|
204
|
+
|
|
205
|
+
if add_labels:
|
|
206
|
+
current = self.service.get(owner, repo, number)
|
|
207
|
+
existing = _extract_label_names(current)
|
|
208
|
+
merged: list[str] = []
|
|
209
|
+
seen: set[str] = set()
|
|
210
|
+
for label in [*existing, *list(add_labels)]:
|
|
211
|
+
normalized = label.strip()
|
|
212
|
+
if normalized and normalized not in seen:
|
|
213
|
+
seen.add(normalized)
|
|
214
|
+
merged.append(normalized)
|
|
215
|
+
data["labels"] = ",".join(merged)
|
|
216
|
+
|
|
217
|
+
if milestone is not None:
|
|
218
|
+
data["milestone"] = milestone
|
|
219
|
+
if remove_milestone:
|
|
220
|
+
data["milestone"] = ""
|
|
221
|
+
return self.service.update(owner, repo, number, **data)
|
|
222
|
+
|
|
223
|
+
def status(self, owner: str, repo: str) -> AdapterActionResult:
|
|
224
|
+
items = self.service.list(owner, repo, state="open")
|
|
225
|
+
return AdapterActionResult(
|
|
226
|
+
items=items,
|
|
227
|
+
message=capability_message("ISSUE_STATUS_GH_SEMANTICS"),
|
|
228
|
+
approximated=True,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def develop(self, owner: str, repo: str, number: str, *, base: str | None, name: str | None) -> AdapterActionResult: # noqa: ARG002
|
|
232
|
+
if base is not None:
|
|
233
|
+
raise unsupported("ISSUE_DEVELOP_BASE")
|
|
234
|
+
if name is not None:
|
|
235
|
+
raise unsupported("ISSUE_DEVELOP_NAME")
|
|
236
|
+
return AdapterActionResult(
|
|
237
|
+
message=f"Opening issue #{number} in the browser instead.",
|
|
238
|
+
warning="Note: 'issue develop' does not create a local branch on GitCode.",
|
|
239
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..services import PullRequestService
|
|
6
|
+
from .base import AdapterActionResult
|
|
7
|
+
from .capabilities import capability_message
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _normalize_multi_values(values: tuple[str, ...] | None) -> str | None:
|
|
11
|
+
if not values:
|
|
12
|
+
return None
|
|
13
|
+
return ",".join(values)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PullRequestAdapter:
|
|
17
|
+
def __init__(self, service: PullRequestService):
|
|
18
|
+
self.service = service
|
|
19
|
+
|
|
20
|
+
def list_prs(
|
|
21
|
+
self,
|
|
22
|
+
owner: str,
|
|
23
|
+
repo: str,
|
|
24
|
+
*,
|
|
25
|
+
state: str | None,
|
|
26
|
+
author: str | None,
|
|
27
|
+
base: str | None,
|
|
28
|
+
assignee: str | None,
|
|
29
|
+
draft: bool | None,
|
|
30
|
+
head: str | None,
|
|
31
|
+
labels: tuple[str, ...] | None,
|
|
32
|
+
search: str | None,
|
|
33
|
+
limit: int | None,
|
|
34
|
+
) -> Any:
|
|
35
|
+
items = self.service.list(
|
|
36
|
+
owner,
|
|
37
|
+
repo,
|
|
38
|
+
state=state,
|
|
39
|
+
author=author,
|
|
40
|
+
base=base,
|
|
41
|
+
assignee=assignee,
|
|
42
|
+
draft=draft,
|
|
43
|
+
head=head,
|
|
44
|
+
labels=_normalize_multi_values(labels),
|
|
45
|
+
search=search,
|
|
46
|
+
)
|
|
47
|
+
if limit is not None:
|
|
48
|
+
return items[:limit]
|
|
49
|
+
return items
|
|
50
|
+
|
|
51
|
+
def create_pr(
|
|
52
|
+
self,
|
|
53
|
+
owner: str,
|
|
54
|
+
repo: str,
|
|
55
|
+
*,
|
|
56
|
+
title: str,
|
|
57
|
+
body: str | None,
|
|
58
|
+
base: str,
|
|
59
|
+
head: str,
|
|
60
|
+
draft: bool,
|
|
61
|
+
milestone: str | None,
|
|
62
|
+
labels: tuple[str, ...] | None,
|
|
63
|
+
reviewers: tuple[str, ...] | None,
|
|
64
|
+
assignees: tuple[str, ...] | None,
|
|
65
|
+
dry_run: bool,
|
|
66
|
+
) -> AdapterActionResult:
|
|
67
|
+
payload = {
|
|
68
|
+
"title": title,
|
|
69
|
+
"body": body,
|
|
70
|
+
"base": base,
|
|
71
|
+
"head": head,
|
|
72
|
+
"draft": draft,
|
|
73
|
+
"labels": _normalize_multi_values(labels),
|
|
74
|
+
"assignees": _normalize_multi_values(assignees),
|
|
75
|
+
"reviewers": _normalize_multi_values(reviewers),
|
|
76
|
+
"milestone": milestone,
|
|
77
|
+
}
|
|
78
|
+
if dry_run:
|
|
79
|
+
return AdapterActionResult(item={k: v for k, v in payload.items() if v is not None})
|
|
80
|
+
return AdapterActionResult(item=self.service.create(owner, repo, **payload))
|
|
81
|
+
|
|
82
|
+
def merge_pr(
|
|
83
|
+
self,
|
|
84
|
+
owner: str,
|
|
85
|
+
repo: str,
|
|
86
|
+
number: int,
|
|
87
|
+
*,
|
|
88
|
+
merge_method: str,
|
|
89
|
+
body: str | None,
|
|
90
|
+
subject: str | None,
|
|
91
|
+
admin: bool,
|
|
92
|
+
) -> dict[str, Any] | None:
|
|
93
|
+
payload = {
|
|
94
|
+
"merge_method": merge_method,
|
|
95
|
+
"description": body,
|
|
96
|
+
"title": subject,
|
|
97
|
+
"force_merge": True if admin else None,
|
|
98
|
+
}
|
|
99
|
+
return self.service.merge(owner, repo, number, **payload)
|
|
100
|
+
|
|
101
|
+
def review_pr(
|
|
102
|
+
self,
|
|
103
|
+
owner: str,
|
|
104
|
+
repo: str,
|
|
105
|
+
number: int,
|
|
106
|
+
*,
|
|
107
|
+
approve: bool, # noqa: ARG002
|
|
108
|
+
body: str | None,
|
|
109
|
+
comment: bool,
|
|
110
|
+
request_changes: bool,
|
|
111
|
+
force: bool,
|
|
112
|
+
) -> AdapterActionResult:
|
|
113
|
+
if comment:
|
|
114
|
+
item = self.service.comment(owner, repo, number, body=body or "")
|
|
115
|
+
return AdapterActionResult(item=item)
|
|
116
|
+
if request_changes:
|
|
117
|
+
item = self.service.comment(owner, repo, number, body=body or "")
|
|
118
|
+
return AdapterActionResult(
|
|
119
|
+
item=item,
|
|
120
|
+
message=capability_message("PR_REVIEW_REQUEST_CHANGES"),
|
|
121
|
+
degraded=True,
|
|
122
|
+
)
|
|
123
|
+
item = self.service.review(owner, repo, number, force=force)
|
|
124
|
+
return AdapterActionResult(item=item)
|
|
125
|
+
|
|
126
|
+
def edit_pr(
|
|
127
|
+
self,
|
|
128
|
+
owner: str,
|
|
129
|
+
repo: str,
|
|
130
|
+
number: int,
|
|
131
|
+
*,
|
|
132
|
+
title: str | None,
|
|
133
|
+
body: str | None,
|
|
134
|
+
base: str | None,
|
|
135
|
+
add_assignee: str | None,
|
|
136
|
+
add_label: str | None,
|
|
137
|
+
add_reviewer: str | None,
|
|
138
|
+
remove_assignee: str | None,
|
|
139
|
+
remove_label: str | None,
|
|
140
|
+
remove_reviewer: str | None,
|
|
141
|
+
milestone: str | None,
|
|
142
|
+
remove_milestone: bool,
|
|
143
|
+
) -> dict[str, Any] | None:
|
|
144
|
+
data = {
|
|
145
|
+
k: v
|
|
146
|
+
for k, v in {
|
|
147
|
+
"title": title,
|
|
148
|
+
"body": body,
|
|
149
|
+
"base": base,
|
|
150
|
+
"assignee": add_assignee,
|
|
151
|
+
"labels": add_label,
|
|
152
|
+
"reviewer": add_reviewer,
|
|
153
|
+
"unassignee": remove_assignee,
|
|
154
|
+
"unset_labels": remove_label,
|
|
155
|
+
"unset_reviewer": remove_reviewer,
|
|
156
|
+
"milestone": milestone,
|
|
157
|
+
}.items()
|
|
158
|
+
if v is not None
|
|
159
|
+
}
|
|
160
|
+
if remove_milestone:
|
|
161
|
+
data["milestone"] = ""
|
|
162
|
+
return self.service.update(owner, repo, number, **data)
|
|
163
|
+
|
|
164
|
+
def status(self, owner: str, repo: str) -> AdapterActionResult:
|
|
165
|
+
items = self.service.list(owner, repo, state="open")
|
|
166
|
+
return AdapterActionResult(
|
|
167
|
+
items=items,
|
|
168
|
+
message=capability_message("PR_STATUS_GH_SEMANTICS"),
|
|
169
|
+
approximated=True,
|
|
170
|
+
)
|
gitcode_cli/cli.py
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import sys
|
|
6
|
+
|
|
3
7
|
import click
|
|
8
|
+
from click.shell_completion import get_completion_class
|
|
4
9
|
|
|
5
10
|
from . import __version__
|
|
6
11
|
from .commands.auth import auth_group
|
|
@@ -9,14 +14,46 @@ from .commands.pr import pr_group
|
|
|
9
14
|
from .config import get_token
|
|
10
15
|
from .context import AppContext
|
|
11
16
|
from .errors import GCError
|
|
17
|
+
from .helptext import GCSectionGroup, set_gc_help
|
|
18
|
+
from .utils import safe_echo
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _configure_stdout_encoding() -> None:
|
|
22
|
+
if sys.platform != "win32":
|
|
23
|
+
return
|
|
24
|
+
for stream in (sys.stdout, sys.stderr):
|
|
25
|
+
if hasattr(stream, "reconfigure") and stream.isatty():
|
|
26
|
+
with contextlib.suppress(Exception):
|
|
27
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_version() -> str:
|
|
31
|
+
try:
|
|
32
|
+
return importlib.metadata.version("pygitcode")
|
|
33
|
+
except importlib.metadata.PackageNotFoundError:
|
|
34
|
+
return __version__
|
|
12
35
|
|
|
13
36
|
|
|
14
|
-
|
|
15
|
-
|
|
37
|
+
class _GCMainGroup(GCSectionGroup):
|
|
38
|
+
def invoke(self, ctx: click.Context):
|
|
39
|
+
try:
|
|
40
|
+
return super().invoke(ctx)
|
|
41
|
+
except GCError as exc:
|
|
42
|
+
safe_echo(f"error: {exc}", err=True)
|
|
43
|
+
ctx.exit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.group(
|
|
47
|
+
cls=_GCMainGroup,
|
|
48
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
49
|
+
help="Work seamlessly with GitCode from the command line.",
|
|
50
|
+
)
|
|
51
|
+
@click.version_option(version=_get_version(), prog_name="gitcode")
|
|
16
52
|
@click.option("--repo", "repo_name", "-R", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
17
53
|
@click.option("--token", hidden=True, help="Override authentication token.")
|
|
18
54
|
@click.pass_context
|
|
19
55
|
def main(ctx: click.Context, repo_name: str | None, token: str | None) -> None:
|
|
56
|
+
_configure_stdout_encoding()
|
|
20
57
|
ctx.ensure_object(dict)
|
|
21
58
|
try:
|
|
22
59
|
resolved_token = token or get_token()
|
|
@@ -25,14 +62,42 @@ def main(ctx: click.Context, repo_name: str | None, token: str | None) -> None:
|
|
|
25
62
|
ctx.obj["app"] = AppContext(token=resolved_token or "", repo=repo_name)
|
|
26
63
|
|
|
27
64
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
65
|
+
set_gc_help(
|
|
66
|
+
main,
|
|
67
|
+
gc_usage="gc <command> <subcommand> [flags]",
|
|
68
|
+
gc_command_sections=[
|
|
69
|
+
("CORE COMMANDS", ["auth", "issue", "pr"]),
|
|
70
|
+
("ADDITIONAL COMMANDS", ["completion", "version"]),
|
|
71
|
+
],
|
|
72
|
+
gc_examples=[
|
|
73
|
+
"gc issue create",
|
|
74
|
+
"gc pr list -R owner/repo",
|
|
75
|
+
"gc auth login",
|
|
76
|
+
],
|
|
77
|
+
gc_learn_more=[
|
|
78
|
+
"Use `gc <command> <subcommand> --help` for more information about a command.",
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
|
|
31
82
|
|
|
83
|
+
auth_group.short_help = "Authenticate gc with GitCode"
|
|
84
|
+
issue_group.short_help = "Manage issues"
|
|
85
|
+
pr_group.short_help = "Manage pull requests"
|
|
32
86
|
|
|
33
|
-
|
|
87
|
+
|
|
88
|
+
@main.command("version", short_help="Show gc version", help="Show gc version.")
|
|
34
89
|
def version_command() -> None:
|
|
35
|
-
|
|
90
|
+
safe_echo(f"gitcode version {_get_version()}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@main.command("completion", short_help="Generate shell completion scripts", help="Generate shell completion scripts.")
|
|
94
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
|
|
95
|
+
def completion_command(shell: str) -> None:
|
|
96
|
+
comp_class = get_completion_class(shell)
|
|
97
|
+
if comp_class is None:
|
|
98
|
+
raise click.ClickException(f"Shell completion not supported for: {shell}")
|
|
99
|
+
comp = comp_class(main, {}, "gc", "_GC_COMPLETE")
|
|
100
|
+
safe_echo(comp.source())
|
|
36
101
|
|
|
37
102
|
|
|
38
103
|
main.add_command(auth_group)
|
gitcode_cli/cli_compat.py
CHANGED
|
@@ -10,6 +10,8 @@ from .utils import get_current_git_branch, get_default_git_branch, read_body_fil
|
|
|
10
10
|
|
|
11
11
|
def get_body_from_options(body: str | None, body_file: str | None, editor: bool) -> str | None:
|
|
12
12
|
"""Resolve body text from inline text, file/stdin, or editor."""
|
|
13
|
+
if body is not None and body_file:
|
|
14
|
+
raise click.UsageError("--body and --body-file are mutually exclusive")
|
|
13
15
|
if body is not None:
|
|
14
16
|
return body
|
|
15
17
|
if body_file:
|
gitcode_cli/client.py
CHANGED
|
@@ -5,7 +5,7 @@ from urllib.parse import urljoin
|
|
|
5
5
|
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
|
-
from .errors import APIError
|
|
8
|
+
from .errors import APIError, NetworkError
|
|
9
9
|
|
|
10
10
|
BASE_URL = "https://api.gitcode.com/api/v5/"
|
|
11
11
|
|
|
@@ -29,13 +29,27 @@ class GitCodeClient:
|
|
|
29
29
|
merged_params: dict[str, Any] = {"access_token": self.token}
|
|
30
30
|
if params:
|
|
31
31
|
merged_params.update({k: v for k, v in params.items() if v is not None})
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
try:
|
|
33
|
+
response = self._client.request(
|
|
34
|
+
method,
|
|
35
|
+
urljoin(self.base_url, path.lstrip("/")),
|
|
36
|
+
params=merged_params,
|
|
37
|
+
json=json,
|
|
38
|
+
headers={"Accept": accept},
|
|
39
|
+
)
|
|
40
|
+
except httpx.TimeoutException as exc:
|
|
41
|
+
raise NetworkError(f"Request timed out: {exc}") from exc
|
|
42
|
+
except httpx.ConnectError as exc:
|
|
43
|
+
raise NetworkError(f"Connection failed: {exc}") from exc
|
|
44
|
+
except httpx.HTTPError as exc:
|
|
45
|
+
raise NetworkError(f"Network error: {exc}") from exc
|
|
46
|
+
if response.status_code == 401:
|
|
47
|
+
try:
|
|
48
|
+
data = response.json()
|
|
49
|
+
original = data.get("message") or str(data)
|
|
50
|
+
except Exception:
|
|
51
|
+
original = response.text
|
|
52
|
+
raise APIError(f"Authentication failed: {original}. Run 'gc auth login' to authenticate.", 401)
|
|
39
53
|
if response.status_code >= 400:
|
|
40
54
|
try:
|
|
41
55
|
data = response.json()
|
gitcode_cli/commands/auth.py
CHANGED
|
@@ -2,15 +2,17 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import click
|
|
4
4
|
|
|
5
|
-
from ..config import save_config
|
|
5
|
+
from ..config import get_token, load_config, save_config
|
|
6
|
+
from ..helptext import GCSectionGroup
|
|
7
|
+
from ..utils import safe_echo
|
|
6
8
|
|
|
7
9
|
|
|
8
|
-
@click.group("auth")
|
|
10
|
+
@click.group("auth", cls=GCSectionGroup, help="Authenticate gc with GitCode.")
|
|
9
11
|
def auth_group() -> None:
|
|
10
12
|
pass
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
@auth_group.command("login")
|
|
15
|
+
@auth_group.command("login", short_help="Authenticate with a GitCode token", help="Authenticate with a GitCode token.")
|
|
14
16
|
@click.option("--with-token", is_flag=True, help="Read token from stdin.")
|
|
15
17
|
def auth_login(with_token: bool) -> None:
|
|
16
18
|
if with_token:
|
|
@@ -20,4 +22,34 @@ def auth_login(with_token: bool) -> None:
|
|
|
20
22
|
else:
|
|
21
23
|
token = click.prompt("GitCode token", hide_input=True)
|
|
22
24
|
save_config({"token": token})
|
|
23
|
-
|
|
25
|
+
safe_echo("Authentication saved.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@auth_group.command("logout", short_help="Remove saved authentication", help="Remove saved authentication.")
|
|
29
|
+
def auth_logout() -> None:
|
|
30
|
+
config = load_config()
|
|
31
|
+
if "token" not in config:
|
|
32
|
+
raise click.ClickException("Not logged in.")
|
|
33
|
+
del config["token"]
|
|
34
|
+
save_config(config)
|
|
35
|
+
safe_echo("Logged out.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@auth_group.command("status", short_help="Show authentication status", help="Show authentication status.")
|
|
39
|
+
def auth_status() -> None:
|
|
40
|
+
try:
|
|
41
|
+
token = get_token()
|
|
42
|
+
except Exception:
|
|
43
|
+
safe_echo("Not logged in. Run `gc auth login` to authenticate.")
|
|
44
|
+
return
|
|
45
|
+
masked = token[:4] + "****" if len(token) > 4 else "****"
|
|
46
|
+
safe_echo(f"Logged in to GitCode (token: {masked})")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@auth_group.command("token", short_help="Print the active token", help="Print the active token.")
|
|
50
|
+
def auth_token() -> None:
|
|
51
|
+
try:
|
|
52
|
+
token = get_token()
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
raise click.ClickException(str(exc)) from exc
|
|
55
|
+
safe_echo(token)
|