pygitcode 0.1.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.
- gitcode_cli/__init__.py +3 -0
- gitcode_cli/cli.py +44 -0
- gitcode_cli/cli_compat.py +116 -0
- gitcode_cli/client.py +71 -0
- gitcode_cli/commands/__init__.py +0 -0
- gitcode_cli/commands/auth.py +23 -0
- gitcode_cli/commands/issue.py +338 -0
- gitcode_cli/commands/pr.py +497 -0
- gitcode_cli/config.py +39 -0
- gitcode_cli/context.py +14 -0
- gitcode_cli/errors.py +23 -0
- gitcode_cli/formatters.py +163 -0
- gitcode_cli/repo.py +43 -0
- gitcode_cli/services/__init__.py +6 -0
- gitcode_cli/services/issues.py +33 -0
- gitcode_cli/services/pulls.py +51 -0
- gitcode_cli/utils.py +118 -0
- pygitcode-0.1.0.dist-info/METADATA +249 -0
- pygitcode-0.1.0.dist-info/RECORD +23 -0
- pygitcode-0.1.0.dist-info/WHEEL +5 -0
- pygitcode-0.1.0.dist-info/entry_points.txt +2 -0
- pygitcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- pygitcode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..cli_compat import (
|
|
9
|
+
get_body_from_options,
|
|
10
|
+
get_default_base_branch,
|
|
11
|
+
get_fill_info,
|
|
12
|
+
normalize_multi_values,
|
|
13
|
+
resolve_pr_identifier_or_current_branch,
|
|
14
|
+
)
|
|
15
|
+
from ..formatters import format_pr_detail, format_pr_list, output_result
|
|
16
|
+
from ..repo import resolve_repo
|
|
17
|
+
from ..services import PullRequestService
|
|
18
|
+
from ..utils import get_current_git_branch, open_in_browser, prompt_if_missing, resolve_pr_arg
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group("pr")
|
|
22
|
+
def pr_group() -> None:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pr_group.command("list")
|
|
27
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
28
|
+
@click.option("-s", "--state")
|
|
29
|
+
@click.option("-A", "--author")
|
|
30
|
+
@click.option("-B", "--base")
|
|
31
|
+
@click.option("--assignee")
|
|
32
|
+
@click.option("--draft", is_flag=True, default=None)
|
|
33
|
+
@click.option("--head")
|
|
34
|
+
@click.option("-l", "--label", "labels", multiple=True)
|
|
35
|
+
@click.option("-S", "--search")
|
|
36
|
+
@click.option("-L", "--limit", type=int, default=30, show_default=True, help="Maximum number of items to fetch.")
|
|
37
|
+
@click.option("-w", "--web", is_flag=True, help="Open the pull requests list in the web browser.")
|
|
38
|
+
@click.option("--json", "json_fields", help="Output JSON. Optionally specify comma-separated fields.")
|
|
39
|
+
@click.option("-q", "--jq", "jq_query", help="Filter JSON output using a jq expression.")
|
|
40
|
+
@click.option("-t", "--template", help="Format output using a Go template string.")
|
|
41
|
+
@click.pass_context
|
|
42
|
+
def pr_list(
|
|
43
|
+
ctx: click.Context,
|
|
44
|
+
repo_name: str | None,
|
|
45
|
+
state: str | None,
|
|
46
|
+
author: str | None,
|
|
47
|
+
base: str | None,
|
|
48
|
+
assignee: str | None,
|
|
49
|
+
draft: bool | None,
|
|
50
|
+
head: str | None,
|
|
51
|
+
labels: tuple[str, ...] | None,
|
|
52
|
+
search: str | None,
|
|
53
|
+
limit: int | None,
|
|
54
|
+
web: bool,
|
|
55
|
+
json_fields: str | None,
|
|
56
|
+
jq_query: str | None,
|
|
57
|
+
template: str | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
app = ctx.obj["app"]
|
|
60
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
61
|
+
if web:
|
|
62
|
+
open_in_browser(f"https://gitcode.com/{owner}/{repo}/pulls")
|
|
63
|
+
return
|
|
64
|
+
service = PullRequestService(app.client())
|
|
65
|
+
labels_str = normalize_multi_values(labels)
|
|
66
|
+
items = service.list(
|
|
67
|
+
owner,
|
|
68
|
+
repo,
|
|
69
|
+
state=state,
|
|
70
|
+
author=author,
|
|
71
|
+
base=base,
|
|
72
|
+
assignee=assignee,
|
|
73
|
+
draft=draft,
|
|
74
|
+
head=head,
|
|
75
|
+
labels=labels_str,
|
|
76
|
+
search=search,
|
|
77
|
+
)
|
|
78
|
+
if limit is not None:
|
|
79
|
+
items = items[:limit]
|
|
80
|
+
|
|
81
|
+
def default_formatter(data):
|
|
82
|
+
output = format_pr_list(data)
|
|
83
|
+
if output:
|
|
84
|
+
click.echo(output)
|
|
85
|
+
|
|
86
|
+
output_result(
|
|
87
|
+
items,
|
|
88
|
+
json_fields,
|
|
89
|
+
jq_query,
|
|
90
|
+
template,
|
|
91
|
+
default_formatter=default_formatter,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pr_group.command("view")
|
|
96
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
97
|
+
@click.argument("identifier", required=False)
|
|
98
|
+
@click.option("-w", "--web", is_flag=True, help="Open the pull request in the web browser.")
|
|
99
|
+
@click.option("--json", "json_fields", help="Output JSON. Optionally specify comma-separated fields.")
|
|
100
|
+
@click.option("-q", "--jq", "jq_query", help="Filter JSON output using a jq expression.")
|
|
101
|
+
@click.option("-t", "--template", help="Format output using a Go template string.")
|
|
102
|
+
@click.pass_context
|
|
103
|
+
def pr_view(
|
|
104
|
+
ctx: click.Context,
|
|
105
|
+
repo_name: str | None,
|
|
106
|
+
identifier: str | None,
|
|
107
|
+
web: bool,
|
|
108
|
+
json_fields: str | None,
|
|
109
|
+
jq_query: str | None,
|
|
110
|
+
template: str | None,
|
|
111
|
+
) -> None:
|
|
112
|
+
app = ctx.obj["app"]
|
|
113
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
114
|
+
service = PullRequestService(app.client())
|
|
115
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
116
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
117
|
+
number = int(number)
|
|
118
|
+
item = service.get(owner, repo, number)
|
|
119
|
+
if web:
|
|
120
|
+
open_in_browser(item["html_url"])
|
|
121
|
+
return
|
|
122
|
+
output_result(
|
|
123
|
+
item,
|
|
124
|
+
json_fields,
|
|
125
|
+
jq_query,
|
|
126
|
+
template,
|
|
127
|
+
default_formatter=lambda data: click.echo(format_pr_detail(data)),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pr_group.command("create")
|
|
132
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
133
|
+
@click.option("-t", "--title")
|
|
134
|
+
@click.option("-b", "--body")
|
|
135
|
+
@click.option("-F", "--body-file")
|
|
136
|
+
@click.option("--editor", is_flag=True)
|
|
137
|
+
@click.option("--fill", is_flag=True, help="Use commit info for title and body.")
|
|
138
|
+
@click.option("--fill-first", is_flag=True, help="Use first commit info for title and body.")
|
|
139
|
+
@click.option("--fill-verbose", is_flag=True, help="Use all commits for body description.")
|
|
140
|
+
@click.option("--dry-run", is_flag=True)
|
|
141
|
+
@click.option("-B", "--base")
|
|
142
|
+
@click.option("-H", "--head")
|
|
143
|
+
@click.option("-d", "--draft", is_flag=True)
|
|
144
|
+
@click.option("--milestone")
|
|
145
|
+
@click.option("-l", "--label", "labels", multiple=True)
|
|
146
|
+
@click.option("-r", "--reviewer", "reviewers", multiple=True)
|
|
147
|
+
@click.option("-a", "--assignee", "assignees", multiple=True)
|
|
148
|
+
@click.option("-w", "--web", is_flag=True, help="Open the pull request in the web browser.")
|
|
149
|
+
@click.option("--json", "json_fields", help="Output JSON. Optionally specify comma-separated fields.")
|
|
150
|
+
@click.option("-q", "--jq", "jq_query", help="Filter JSON output using a jq expression.")
|
|
151
|
+
@click.option("--template", help="Format output using a Go template string.")
|
|
152
|
+
@click.pass_context
|
|
153
|
+
def pr_create(
|
|
154
|
+
ctx: click.Context,
|
|
155
|
+
repo_name: str | None,
|
|
156
|
+
title: str | None,
|
|
157
|
+
body: str | None,
|
|
158
|
+
body_file: str | None,
|
|
159
|
+
editor: bool,
|
|
160
|
+
fill: bool,
|
|
161
|
+
fill_first: bool,
|
|
162
|
+
fill_verbose: bool,
|
|
163
|
+
dry_run: bool,
|
|
164
|
+
base: str | None,
|
|
165
|
+
head: str | None,
|
|
166
|
+
draft: bool,
|
|
167
|
+
milestone: str | None,
|
|
168
|
+
labels: tuple[str, ...] | None,
|
|
169
|
+
reviewers: tuple[str, ...] | None,
|
|
170
|
+
assignees: tuple[str, ...] | None,
|
|
171
|
+
web: bool,
|
|
172
|
+
json_fields: str | None,
|
|
173
|
+
jq_query: str | None,
|
|
174
|
+
template: str | None,
|
|
175
|
+
) -> None:
|
|
176
|
+
app = ctx.obj["app"]
|
|
177
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
178
|
+
if web:
|
|
179
|
+
open_in_browser(f"https://gitcode.com/{owner}/{repo}/pulls/new")
|
|
180
|
+
return
|
|
181
|
+
if not head:
|
|
182
|
+
head = get_current_git_branch()
|
|
183
|
+
if not head:
|
|
184
|
+
raise click.ClickException("Unable to detect current branch. Use --head.")
|
|
185
|
+
if not base:
|
|
186
|
+
base = get_default_base_branch()
|
|
187
|
+
|
|
188
|
+
fill_mode = None
|
|
189
|
+
if fill_verbose:
|
|
190
|
+
fill_mode = "verbose"
|
|
191
|
+
elif fill_first:
|
|
192
|
+
fill_mode = "first"
|
|
193
|
+
elif fill:
|
|
194
|
+
fill_mode = "last"
|
|
195
|
+
|
|
196
|
+
if fill_mode:
|
|
197
|
+
fill_title, fill_body = get_fill_info(fill_mode)
|
|
198
|
+
if title is None:
|
|
199
|
+
title = fill_title
|
|
200
|
+
if body is None:
|
|
201
|
+
body = fill_body
|
|
202
|
+
|
|
203
|
+
title = prompt_if_missing(title, "Title")
|
|
204
|
+
body = get_body_from_options(body=body, body_file=body_file, editor=editor)
|
|
205
|
+
payload = {
|
|
206
|
+
"title": title,
|
|
207
|
+
"body": body,
|
|
208
|
+
"base": base,
|
|
209
|
+
"head": head,
|
|
210
|
+
"draft": draft,
|
|
211
|
+
"labels": normalize_multi_values(labels),
|
|
212
|
+
"assignees": normalize_multi_values(assignees),
|
|
213
|
+
"reviewers": normalize_multi_values(reviewers),
|
|
214
|
+
"milestone": milestone,
|
|
215
|
+
}
|
|
216
|
+
if dry_run:
|
|
217
|
+
click.echo(json.dumps({k: v for k, v in payload.items() if v is not None}, indent=2, sort_keys=True))
|
|
218
|
+
return
|
|
219
|
+
service = PullRequestService(app.client())
|
|
220
|
+
item = service.create(owner, repo, **payload)
|
|
221
|
+
output_result(
|
|
222
|
+
item,
|
|
223
|
+
json_fields,
|
|
224
|
+
jq_query,
|
|
225
|
+
template,
|
|
226
|
+
default_formatter=lambda data: click.echo(data["html_url"]),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@pr_group.command("close")
|
|
231
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
232
|
+
@click.argument("identifier", required=False)
|
|
233
|
+
@click.option("-c", "--comment")
|
|
234
|
+
@click.option("-d", "--delete-branch", is_flag=True, help="Delete the remote branch after closing.")
|
|
235
|
+
@click.pass_context
|
|
236
|
+
def pr_close(
|
|
237
|
+
ctx: click.Context, repo_name: str | None, identifier: str | None, comment: str | None, delete_branch: bool
|
|
238
|
+
) -> None:
|
|
239
|
+
app = ctx.obj["app"]
|
|
240
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
241
|
+
service = PullRequestService(app.client())
|
|
242
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
243
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
244
|
+
number = int(number)
|
|
245
|
+
if comment:
|
|
246
|
+
service.comment(owner, repo, number, body=comment)
|
|
247
|
+
item = service.update(owner, repo, number, state="closed")
|
|
248
|
+
if delete_branch:
|
|
249
|
+
try:
|
|
250
|
+
branch = item.get("head", {}).get("ref")
|
|
251
|
+
if branch:
|
|
252
|
+
subprocess.run(["git", "push", "origin", "--delete", branch], check=True)
|
|
253
|
+
click.echo(f"Deleted remote branch {branch}")
|
|
254
|
+
else:
|
|
255
|
+
click.echo("Warning: could not determine branch to delete.", err=True)
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
click.echo(f"Warning: could not delete remote branch: {exc}", err=True)
|
|
258
|
+
click.echo(f"Closed pull request #{item['number']}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@pr_group.command("merge")
|
|
262
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
263
|
+
@click.argument("identifier", required=False)
|
|
264
|
+
@click.option("-m", "--merge", "merge_mode", flag_value="merge")
|
|
265
|
+
@click.option("-s", "--squash", "merge_mode", flag_value="squash")
|
|
266
|
+
@click.option("-r", "--rebase", "merge_mode", flag_value="rebase")
|
|
267
|
+
@click.pass_context
|
|
268
|
+
def pr_merge(ctx: click.Context, repo_name: str | None, identifier: str | None, merge_mode: str | None) -> None:
|
|
269
|
+
app = ctx.obj["app"]
|
|
270
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
271
|
+
service = PullRequestService(app.client())
|
|
272
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
273
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
274
|
+
number = int(number)
|
|
275
|
+
item = service.merge(owner, repo, number, merge_method=merge_mode or "merge")
|
|
276
|
+
click.echo(item["message"])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@pr_group.command("comment")
|
|
280
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
281
|
+
@click.argument("identifier", required=False)
|
|
282
|
+
@click.option("-b", "--body")
|
|
283
|
+
@click.option("--path")
|
|
284
|
+
@click.option("--position", type=int)
|
|
285
|
+
@click.pass_context
|
|
286
|
+
def pr_comment(
|
|
287
|
+
ctx: click.Context,
|
|
288
|
+
repo_name: str | None,
|
|
289
|
+
identifier: str | None,
|
|
290
|
+
body: str | None,
|
|
291
|
+
path: str | None,
|
|
292
|
+
position: int | None,
|
|
293
|
+
) -> None:
|
|
294
|
+
app = ctx.obj["app"]
|
|
295
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
296
|
+
service = PullRequestService(app.client())
|
|
297
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
298
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
299
|
+
number = int(number)
|
|
300
|
+
body = prompt_if_missing(body, "Body")
|
|
301
|
+
item = service.comment(owner, repo, number, body=body, path=path, position=position)
|
|
302
|
+
click.echo(str(item["id"]))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@pr_group.command("review")
|
|
306
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
307
|
+
@click.argument("identifier", required=False)
|
|
308
|
+
@click.option("-a", "--approve", is_flag=True, help="Approve the pull request. GitCode maps this to its review API.")
|
|
309
|
+
@click.option("--body")
|
|
310
|
+
@click.option("--comment", is_flag=True, help="Leave a review comment. Downgrades to a PR comment on GitCode.")
|
|
311
|
+
@click.option("--request-changes", is_flag=True, help="Request changes. Downgrades to a PR comment on GitCode.")
|
|
312
|
+
@click.option("--force", is_flag=True, help="Force review handling when supported by GitCode.")
|
|
313
|
+
@click.pass_context
|
|
314
|
+
def pr_review(
|
|
315
|
+
ctx: click.Context,
|
|
316
|
+
repo_name: str | None,
|
|
317
|
+
identifier: str | None,
|
|
318
|
+
approve: bool,
|
|
319
|
+
body: str | None,
|
|
320
|
+
comment: bool,
|
|
321
|
+
request_changes: bool,
|
|
322
|
+
force: bool,
|
|
323
|
+
) -> None:
|
|
324
|
+
app = ctx.obj["app"]
|
|
325
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
326
|
+
service = PullRequestService(app.client())
|
|
327
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
328
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
329
|
+
number = int(number)
|
|
330
|
+
|
|
331
|
+
selected_modes = [approve, comment, request_changes]
|
|
332
|
+
if sum(1 for selected in selected_modes if selected) != 1:
|
|
333
|
+
raise click.ClickException("Specify exactly one of --approve, --comment, or --request-changes.")
|
|
334
|
+
|
|
335
|
+
if comment or request_changes:
|
|
336
|
+
if not body:
|
|
337
|
+
raise click.ClickException("Body is required when using --comment or --request-changes.")
|
|
338
|
+
item = service.comment(owner, repo, number, body=body)
|
|
339
|
+
if comment:
|
|
340
|
+
click.echo("GitCode review API does not support comment reviews; posted a pull request comment instead.")
|
|
341
|
+
else:
|
|
342
|
+
click.echo(
|
|
343
|
+
"GitCode review API does not support request-changes reviews; posted a pull request comment instead."
|
|
344
|
+
)
|
|
345
|
+
click.echo(f"Posted pull request comment {item['id']}")
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
item = service.review(owner, repo, number, body=body, force=force)
|
|
349
|
+
click.echo(f"Reviewed pull request #{number}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@pr_group.command("reopen")
|
|
353
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
354
|
+
@click.argument("identifier", required=False)
|
|
355
|
+
@click.pass_context
|
|
356
|
+
def pr_reopen(ctx: click.Context, repo_name: str | None, identifier: str | None) -> None:
|
|
357
|
+
app = ctx.obj["app"]
|
|
358
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
359
|
+
service = PullRequestService(app.client())
|
|
360
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
361
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
362
|
+
number = int(number)
|
|
363
|
+
item = service.update(owner, repo, number, state="open")
|
|
364
|
+
click.echo(f"Reopened pull request #{item['number']}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@pr_group.command("edit")
|
|
368
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
369
|
+
@click.argument("identifier", required=False)
|
|
370
|
+
@click.option("-t", "--title")
|
|
371
|
+
@click.option("-b", "--body")
|
|
372
|
+
@click.option("-B", "--base")
|
|
373
|
+
@click.option("-a", "--add-assignee")
|
|
374
|
+
@click.option("-l", "--add-label")
|
|
375
|
+
@click.option("-r", "--add-reviewer")
|
|
376
|
+
@click.option("--remove-assignee")
|
|
377
|
+
@click.option("--remove-label")
|
|
378
|
+
@click.option("--remove-reviewer")
|
|
379
|
+
@click.pass_context
|
|
380
|
+
def pr_edit(
|
|
381
|
+
ctx: click.Context,
|
|
382
|
+
repo_name: str | None,
|
|
383
|
+
identifier: str | None,
|
|
384
|
+
title: str | None,
|
|
385
|
+
body: str | None,
|
|
386
|
+
base: str | None,
|
|
387
|
+
add_assignee: str | None,
|
|
388
|
+
add_label: str | None,
|
|
389
|
+
add_reviewer: str | None,
|
|
390
|
+
remove_assignee: str | None,
|
|
391
|
+
remove_label: str | None,
|
|
392
|
+
remove_reviewer: str | None,
|
|
393
|
+
) -> None:
|
|
394
|
+
app = ctx.obj["app"]
|
|
395
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
396
|
+
service = PullRequestService(app.client())
|
|
397
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
398
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
399
|
+
number = int(number)
|
|
400
|
+
data = {
|
|
401
|
+
k: v
|
|
402
|
+
for k, v in {
|
|
403
|
+
"title": title,
|
|
404
|
+
"body": body,
|
|
405
|
+
"base": base,
|
|
406
|
+
"assignee": add_assignee,
|
|
407
|
+
"labels": add_label,
|
|
408
|
+
"reviewer": add_reviewer,
|
|
409
|
+
"unassignee": remove_assignee,
|
|
410
|
+
"unset_labels": remove_label,
|
|
411
|
+
"unset_reviewer": remove_reviewer,
|
|
412
|
+
}.items()
|
|
413
|
+
if v is not None
|
|
414
|
+
}
|
|
415
|
+
if not data:
|
|
416
|
+
raise click.UsageError("must specify at least one field to edit")
|
|
417
|
+
item = service.update(owner, repo, number, **data)
|
|
418
|
+
click.echo(f"Edited pull request #{item['number']}")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@pr_group.command("diff")
|
|
422
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
423
|
+
@click.argument("identifier", required=False)
|
|
424
|
+
@click.pass_context
|
|
425
|
+
def pr_diff(ctx: click.Context, repo_name: str | None, identifier: str | None) -> None:
|
|
426
|
+
app = ctx.obj["app"]
|
|
427
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
428
|
+
service = PullRequestService(app.client())
|
|
429
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
430
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
431
|
+
diff_text = service.diff(owner, repo, int(number))
|
|
432
|
+
click.echo(diff_text)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@pr_group.command("checkout")
|
|
436
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
437
|
+
@click.argument("identifier", required=False)
|
|
438
|
+
@click.option("-b", "--branch", help="Local branch name to checkout into.")
|
|
439
|
+
@click.pass_context
|
|
440
|
+
def pr_checkout(ctx: click.Context, repo_name: str | None, identifier: str | None, branch: str | None) -> None:
|
|
441
|
+
app = ctx.obj["app"]
|
|
442
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
443
|
+
service = PullRequestService(app.client())
|
|
444
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
445
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
446
|
+
item = service.get(owner, repo, int(number))
|
|
447
|
+
head_ref = item.get("head", {}).get("ref")
|
|
448
|
+
if not head_ref:
|
|
449
|
+
raise click.ClickException("Unable to determine PR branch.")
|
|
450
|
+
local_branch = branch or head_ref
|
|
451
|
+
try:
|
|
452
|
+
subprocess.run(["git", "fetch", "origin", head_ref], check=True)
|
|
453
|
+
subprocess.run(["git", "checkout", "-b", local_branch, f"origin/{head_ref}"], check=True)
|
|
454
|
+
click.echo(f"Checked out branch {local_branch}")
|
|
455
|
+
except subprocess.CalledProcessError as exc:
|
|
456
|
+
raise click.ClickException(f"Git checkout failed: {exc}") from exc
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@pr_group.command("ready")
|
|
460
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
461
|
+
@click.argument("identifier", required=False)
|
|
462
|
+
@click.option("--undo", is_flag=True, help="Convert a pull request to draft.")
|
|
463
|
+
@click.pass_context
|
|
464
|
+
def pr_ready(ctx: click.Context, repo_name: str | None, identifier: str | None, undo: bool) -> None:
|
|
465
|
+
app = ctx.obj["app"]
|
|
466
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
467
|
+
service = PullRequestService(app.client())
|
|
468
|
+
resolved_identifier = resolve_pr_identifier_or_current_branch(identifier)
|
|
469
|
+
owner, repo, number = resolve_pr_arg(resolved_identifier, owner, repo, service)
|
|
470
|
+
item = service.update(owner, repo, int(number), draft=undo)
|
|
471
|
+
if undo:
|
|
472
|
+
click.echo(f"Converted pull request #{item['number']} to draft")
|
|
473
|
+
else:
|
|
474
|
+
click.echo(f"Marked pull request #{item['number']} as ready for review")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@pr_group.command("status")
|
|
478
|
+
@click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
|
|
479
|
+
@click.pass_context
|
|
480
|
+
def pr_status(ctx: click.Context, repo_name: str | None) -> None:
|
|
481
|
+
app = ctx.obj["app"]
|
|
482
|
+
owner, repo = resolve_repo(repo_name or app.repo)
|
|
483
|
+
service = PullRequestService(app.client())
|
|
484
|
+
items = service.list(owner, repo, state="open")
|
|
485
|
+
click.echo(
|
|
486
|
+
f"Open pull requests in {owner}/{repo} "
|
|
487
|
+
"(GitCode API approximation -- user-specific filtering is not available)"
|
|
488
|
+
)
|
|
489
|
+
if items:
|
|
490
|
+
for item in items:
|
|
491
|
+
click.echo(f" #{item['number']}\t{item['state']}\t{item['title']}")
|
|
492
|
+
else:
|
|
493
|
+
click.echo(" No open pull requests")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
pr_group.add_command(pr_list, name="ls")
|
|
497
|
+
pr_group.add_command(pr_create, name="new")
|
gitcode_cli/config.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import ConfigError
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR = Path.home() / ".config" / "gc"
|
|
10
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
11
|
+
TOKEN_ENV_VARS = ("GC_TOKEN",)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config() -> dict:
|
|
15
|
+
if not CONFIG_PATH.exists():
|
|
16
|
+
return {}
|
|
17
|
+
try:
|
|
18
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
19
|
+
except json.JSONDecodeError as exc:
|
|
20
|
+
raise ConfigError(f"Invalid config file: {CONFIG_PATH}") from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def save_config(data: dict) -> None:
|
|
24
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
CONFIG_PATH.write_text(json.dumps(data, indent=2) + "\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_token(explicit_token: str | None = None) -> str:
|
|
29
|
+
if explicit_token:
|
|
30
|
+
return explicit_token
|
|
31
|
+
for env_name in TOKEN_ENV_VARS:
|
|
32
|
+
value = os.getenv(env_name)
|
|
33
|
+
if value:
|
|
34
|
+
return value
|
|
35
|
+
config = load_config()
|
|
36
|
+
token = config.get("token")
|
|
37
|
+
if token:
|
|
38
|
+
return token
|
|
39
|
+
raise ConfigError("No token found. Set GC_TOKEN or run `gc auth login`.")
|
gitcode_cli/context.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .client import GitCodeClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AppContext:
|
|
10
|
+
token: str
|
|
11
|
+
repo: str | None
|
|
12
|
+
|
|
13
|
+
def client(self) -> GitCodeClient:
|
|
14
|
+
return GitCodeClient(token=self.token)
|
gitcode_cli/errors.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GCError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(GCError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(GCError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RepoResolutionError(GCError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIError(GCError):
|
|
21
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|