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.
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
gitcode_cli/cli.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from . import __version__
6
+ from .commands.auth import auth_group
7
+ from .commands.issue import issue_group
8
+ from .commands.pr import pr_group
9
+ from .config import get_token
10
+ from .context import AppContext
11
+ from .errors import GCError
12
+
13
+
14
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
15
+ @click.version_option(version=__version__, prog_name="gc")
16
+ @click.option("--repo", "repo_name", "-R", help="Select another repository using the [HOST/]OWNER/REPO format.")
17
+ @click.option("--token", hidden=True, help="Override authentication token.")
18
+ @click.pass_context
19
+ def main(ctx: click.Context, repo_name: str | None, token: str | None) -> None:
20
+ ctx.ensure_object(dict)
21
+ try:
22
+ resolved_token = token or get_token()
23
+ except GCError:
24
+ resolved_token = token
25
+ ctx.obj["app"] = AppContext(token=resolved_token or "", repo=repo_name)
26
+
27
+
28
+ @main.result_callback()
29
+ def process_result(*_args: object, **_kwargs: object) -> None:
30
+ return None
31
+
32
+
33
+ @main.command("version")
34
+ def version_command() -> None:
35
+ click.echo(f"gc version {__version__}")
36
+
37
+
38
+ main.add_command(auth_group)
39
+ main.add_command(issue_group)
40
+ main.add_command(pr_group)
41
+
42
+
43
+ if __name__ == "__main__": # pragma: no cover
44
+ main()
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+
6
+ import click
7
+
8
+ from .utils import get_current_git_branch, get_default_git_branch, read_body_file
9
+
10
+
11
+ def get_body_from_options(body: str | None, body_file: str | None, editor: bool) -> str | None:
12
+ """Resolve body text from inline text, file/stdin, or editor."""
13
+ if body is not None:
14
+ return body
15
+ if body_file:
16
+ if body_file == "-":
17
+ return sys.stdin.read()
18
+ return read_body_file(body_file)
19
+ if editor:
20
+ return click.edit()
21
+ return None
22
+
23
+
24
+ def get_default_base_branch() -> str:
25
+ """Return the detected default git branch for CLI base-branch inference."""
26
+ branch = get_default_git_branch()
27
+ if not branch:
28
+ raise click.ClickException("Unable to determine the default base branch. Use --base.")
29
+ return branch
30
+
31
+
32
+ def normalize_multi_values(values: tuple[str, ...] | None) -> str | None:
33
+ """Normalize repeatable click option values for API calls."""
34
+ if not values:
35
+ return None
36
+ return ",".join(values)
37
+
38
+
39
+ def resolve_pr_identifier_or_current_branch(identifier: str | None) -> str:
40
+ """Return the given PR identifier or fall back to the current branch name."""
41
+ if identifier is not None:
42
+ return identifier
43
+ branch = get_current_git_branch()
44
+ if not branch:
45
+ raise click.ClickException("Unable to detect current branch. Specify a PR or branch explicitly.")
46
+ return branch
47
+
48
+
49
+ def get_fill_info(mode: str = "last") -> tuple[str, str]:
50
+ """Get title and body from git commits on current branch.
51
+
52
+ Args:
53
+ mode: "last" (latest commit), "first" (first commit), or "verbose" (all commits)
54
+
55
+ Returns:
56
+ (title, body) tuple
57
+ """
58
+ base_branch = get_default_git_branch() or "main"
59
+ current_branch = get_current_git_branch()
60
+ if not current_branch:
61
+ raise click.ClickException("Unable to detect current branch for --fill.")
62
+
63
+ try:
64
+ if mode == "last":
65
+ result = subprocess.run(
66
+ ["git", "log", "-1", "--format=%s%n%n%b", "--no-merges"],
67
+ capture_output=True,
68
+ text=True,
69
+ check=True,
70
+ )
71
+ elif mode == "first":
72
+ result = subprocess.run(
73
+ ["git", "log", f"{base_branch}..{current_branch}", "--format=%s%n%n%b", "--no-merges", "--reverse"],
74
+ capture_output=True,
75
+ text=True,
76
+ check=True,
77
+ )
78
+ lines = result.stdout.strip().split("\n\n", 1)
79
+ result = subprocess.run(
80
+ ["echo", lines[0] if lines else ""],
81
+ capture_output=True,
82
+ text=True,
83
+ check=True,
84
+ )
85
+ else:
86
+ result = subprocess.run(
87
+ ["git", "log", f"{base_branch}..{current_branch}", "--format=%s%n%n%b", "--no-merges"],
88
+ capture_output=True,
89
+ text=True,
90
+ check=True,
91
+ )
92
+ except subprocess.CalledProcessError as exc:
93
+ raise click.ClickException(f"Failed to get commit info: {exc}") from exc
94
+
95
+ output = result.stdout.strip()
96
+ if not output:
97
+ return "", ""
98
+
99
+ parts = output.split("\n\n", 1)
100
+ title = parts[0].strip()
101
+ body = parts[1].strip() if len(parts) > 1 else ""
102
+
103
+ if mode == "verbose" and body:
104
+ lines = []
105
+ commits = subprocess.run(
106
+ ["git", "log", f"{base_branch}..{current_branch}", "--format=%s", "--no-merges"],
107
+ capture_output=True,
108
+ text=True,
109
+ check=True,
110
+ )
111
+ for line in commits.stdout.strip().split("\n"):
112
+ if line:
113
+ lines.append(f"- {line}")
114
+ body = "\n".join(lines)
115
+
116
+ return title, body
gitcode_cli/client.py ADDED
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+ from urllib.parse import urljoin
5
+
6
+ import httpx
7
+
8
+ from .errors import APIError
9
+
10
+ BASE_URL = "https://api.gitcode.com/api/v5/"
11
+
12
+
13
+ class GitCodeClient:
14
+ def __init__(self, token: str, base_url: str = BASE_URL):
15
+ self.token = token
16
+ self.base_url = base_url
17
+ self._client = httpx.Client(timeout=30.0)
18
+
19
+ def request(
20
+ self,
21
+ method: str,
22
+ path: str,
23
+ *,
24
+ params: dict[str, Any] | None = None,
25
+ json: dict[str, Any] | None = None,
26
+ accept: str = "application/json",
27
+ response_format: Literal["json", "text"] = "json",
28
+ ) -> Any | None:
29
+ merged_params: dict[str, Any] = {"access_token": self.token}
30
+ if params:
31
+ merged_params.update({k: v for k, v in params.items() if v is not None})
32
+ response = self._client.request(
33
+ method,
34
+ urljoin(self.base_url, path.lstrip("/")),
35
+ params=merged_params,
36
+ json=json,
37
+ headers={"Accept": accept},
38
+ )
39
+ if response.status_code >= 400:
40
+ try:
41
+ data = response.json()
42
+ message = data.get("message") or str(data)
43
+ except Exception:
44
+ message = response.text
45
+ raise APIError(message or "GitCode API request failed", response.status_code)
46
+ if not response.content:
47
+ return None
48
+ if response_format == "text":
49
+ return response.text
50
+ return response.json()
51
+
52
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any | None:
53
+ return self.request("GET", path, params=params)
54
+
55
+ def post(
56
+ self, path: str, *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None
57
+ ) -> Any | None:
58
+ return self.request("POST", path, params=params, json=json)
59
+
60
+ def patch(
61
+ self, path: str, *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None
62
+ ) -> Any | None:
63
+ return self.request("PATCH", path, params=params, json=json)
64
+
65
+ def put(self, path: str, *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None) -> Any | None:
66
+ return self.request("PUT", path, params=params, json=json)
67
+
68
+ def delete(
69
+ self, path: str, *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None
70
+ ) -> Any | None:
71
+ return self.request("DELETE", path, params=params, json=json)
File without changes
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from ..config import save_config
6
+
7
+
8
+ @click.group("auth")
9
+ def auth_group() -> None:
10
+ pass
11
+
12
+
13
+ @auth_group.command("login")
14
+ @click.option("--with-token", is_flag=True, help="Read token from stdin.")
15
+ def auth_login(with_token: bool) -> None:
16
+ if with_token:
17
+ token = click.get_text_stream("stdin").read().strip()
18
+ if not token:
19
+ raise click.ClickException("No token provided on stdin.")
20
+ else:
21
+ token = click.prompt("GitCode token", hide_input=True)
22
+ save_config({"token": token})
23
+ click.echo("Authentication saved.")
@@ -0,0 +1,338 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from ..cli_compat import get_body_from_options, normalize_multi_values
6
+ from ..formatters import format_issue_detail, format_issue_list, output_result
7
+ from ..repo import resolve_repo
8
+ from ..services import IssueService
9
+ from ..utils import open_in_browser, prompt_if_missing, read_body_file, resolve_issue_arg
10
+
11
+
12
+ def _echo_issue_summary(items: list[dict]) -> None:
13
+ output = format_issue_list(items)
14
+ if output:
15
+ click.echo(output)
16
+
17
+
18
+ @click.group("issue")
19
+ def issue_group() -> None:
20
+ pass
21
+
22
+
23
+ @issue_group.command("list")
24
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
25
+ @click.option("-s", "--state")
26
+ @click.option("-l", "--label", "labels", multiple=True)
27
+ @click.option("-A", "--author")
28
+ @click.option("-a", "--assignee")
29
+ @click.option("--milestone")
30
+ @click.option("--mention")
31
+ @click.option("-S", "--search")
32
+ @click.option("-L", "--limit", type=int, default=30, show_default=True, help="Maximum number of items to fetch.")
33
+ @click.option("-w", "--web", is_flag=True, help="Open the issue list in the web browser.")
34
+ @click.option("--json", "json_fields", help="Output JSON. Optionally specify comma-separated fields.")
35
+ @click.option("-q", "--jq", "jq_query", help="Filter JSON output using a jq expression.")
36
+ @click.option("-t", "--template", help="Format output using a Go template string.")
37
+ @click.pass_context
38
+ def issue_list(
39
+ ctx: click.Context,
40
+ repo_name: str | None,
41
+ state: str | None,
42
+ labels: tuple[str, ...] | None,
43
+ author: str | None,
44
+ assignee: str | None,
45
+ milestone: str | None,
46
+ mention: str | None,
47
+ search: str | None,
48
+ limit: int | None,
49
+ web: bool,
50
+ json_fields: str | None,
51
+ jq_query: str | None,
52
+ template: str | None,
53
+ ) -> None:
54
+ app = ctx.obj["app"]
55
+ owner, repo = resolve_repo(repo_name or app.repo)
56
+ if web:
57
+ open_in_browser(f"https://gitcode.com/{owner}/{repo}/issues")
58
+ return
59
+ service = IssueService(app.client())
60
+ labels_str = normalize_multi_values(labels)
61
+ items = service.list(
62
+ owner,
63
+ repo,
64
+ state=state,
65
+ labels=labels_str,
66
+ creator=author,
67
+ assignee=assignee,
68
+ milestone=milestone,
69
+ mention=mention,
70
+ search=search,
71
+ )
72
+ if limit is not None:
73
+ items = items[:limit]
74
+ output_result(
75
+ items,
76
+ json_fields,
77
+ jq_query,
78
+ template,
79
+ default_formatter=_echo_issue_summary,
80
+ )
81
+
82
+
83
+ @issue_group.command("view")
84
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
85
+ @click.argument("identifier")
86
+ @click.option("-w", "--web", is_flag=True, help="Open the issue in the web browser.")
87
+ @click.option("-c", "--comments", is_flag=True, help="View issue comments.")
88
+ @click.option("--json", "json_fields", help="Output JSON. Optionally specify comma-separated fields.")
89
+ @click.option("-q", "--jq", "jq_query", help="Filter JSON output using a jq expression.")
90
+ @click.option("-t", "--template", help="Format output using a Go template string.")
91
+ @click.pass_context
92
+ def issue_view(
93
+ ctx: click.Context,
94
+ repo_name: str | None,
95
+ identifier: str,
96
+ web: bool,
97
+ comments: bool,
98
+ json_fields: str | None,
99
+ jq_query: str | None,
100
+ template: str | None,
101
+ ) -> None:
102
+ app = ctx.obj["app"]
103
+ url_owner, url_repo, number = resolve_issue_arg(identifier)
104
+ if url_owner:
105
+ assert url_repo is not None
106
+ owner, repo = url_owner, url_repo
107
+ else:
108
+ owner, repo = resolve_repo(repo_name or app.repo)
109
+ if web:
110
+ target_url = identifier if url_owner else f"https://gitcode.com/{owner}/{repo}/issues/{number}"
111
+ open_in_browser(target_url)
112
+ return
113
+ service = IssueService(app.client())
114
+ item = service.get(owner, repo, number)
115
+ if comments:
116
+ comment_items = service.list_comments(owner, repo, number)
117
+ data = dict(item) if item else {}
118
+ data["comments"] = comment_items
119
+
120
+ def default_formatter(data: dict) -> None:
121
+ click.echo(format_issue_detail(data))
122
+ if comment_items:
123
+ click.echo("\nComments:")
124
+ for comment in comment_items:
125
+ click.echo(f"- {comment.get('body') or ''}")
126
+
127
+ output_result(
128
+ data,
129
+ json_fields,
130
+ jq_query,
131
+ template,
132
+ default_formatter=default_formatter,
133
+ )
134
+ return
135
+ output_result(
136
+ item,
137
+ json_fields,
138
+ jq_query,
139
+ template,
140
+ default_formatter=lambda data: click.echo(format_issue_detail(data)),
141
+ )
142
+
143
+
144
+ @issue_group.command("create")
145
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
146
+ @click.option("-t", "--title")
147
+ @click.option("-b", "--body")
148
+ @click.option("-a", "--assignee")
149
+ @click.option("-l", "--label", "labels")
150
+ @click.option("-m", "--milestone")
151
+ @click.option("-F", "--body-file")
152
+ @click.option("-w", "--web", is_flag=True, help="Open the issue in the web browser.")
153
+ @click.option("--json", "json_fields", help="Output JSON. Optionally specify comma-separated fields.")
154
+ @click.option("-q", "--jq", "jq_query", help="Filter JSON output using a jq expression.")
155
+ @click.option("--template", help="Format output using a Go template string.")
156
+ @click.pass_context
157
+ def issue_create(
158
+ ctx: click.Context,
159
+ repo_name: str | None,
160
+ title: str | None,
161
+ body: str | None,
162
+ assignee: str | None,
163
+ labels: str | None,
164
+ milestone: str | None,
165
+ body_file: str | None,
166
+ web: bool,
167
+ json_fields: str | None,
168
+ jq_query: str | None,
169
+ template: str | None,
170
+ ) -> None:
171
+ app = ctx.obj["app"]
172
+ owner, repo = resolve_repo(repo_name or app.repo)
173
+ if web:
174
+ open_in_browser(f"https://gitcode.com/{owner}/{repo}/issues/new")
175
+ return
176
+ title = prompt_if_missing(title, "Title")
177
+ if body_file:
178
+ body = read_body_file(body_file)
179
+ service = IssueService(app.client())
180
+ item = service.create(owner, repo, title=title, body=body, assignee=assignee, labels=labels, milestone=milestone)
181
+ output_result(
182
+ item,
183
+ json_fields,
184
+ jq_query,
185
+ template,
186
+ default_formatter=lambda data: click.echo(data["html_url"]),
187
+ )
188
+
189
+
190
+ @issue_group.command("close")
191
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
192
+ @click.argument("identifier")
193
+ @click.pass_context
194
+ def issue_close(ctx: click.Context, repo_name: str | None, identifier: str) -> None:
195
+ app = ctx.obj["app"]
196
+ url_owner, url_repo, number = resolve_issue_arg(identifier)
197
+ if url_owner:
198
+ assert url_repo is not None
199
+ owner, repo = url_owner, url_repo
200
+ else:
201
+ owner, repo = resolve_repo(repo_name or app.repo)
202
+ service = IssueService(app.client())
203
+ item = service.update(owner, repo, number, state="closed")
204
+ click.echo(f"Closed issue #{item['number']}")
205
+
206
+
207
+ @issue_group.command("comment")
208
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
209
+ @click.argument("identifier")
210
+ @click.option("-b", "--body")
211
+ @click.option("-F", "--body-file")
212
+ @click.option("-e", "--editor", is_flag=True)
213
+ @click.option("-w", "--web", is_flag=True, help="Open the issue in the web browser.")
214
+ @click.pass_context
215
+ def issue_comment(
216
+ ctx: click.Context,
217
+ repo_name: str | None,
218
+ identifier: str,
219
+ body: str | None,
220
+ body_file: str | None,
221
+ editor: bool,
222
+ web: bool,
223
+ ) -> None:
224
+ app = ctx.obj["app"]
225
+ url_owner, url_repo, number = resolve_issue_arg(identifier)
226
+ if url_owner:
227
+ assert url_repo is not None
228
+ owner, repo = url_owner, url_repo
229
+ else:
230
+ owner, repo = resolve_repo(repo_name or app.repo)
231
+ if web:
232
+ open_in_browser(f"https://gitcode.com/{owner}/{repo}/issues/{number}")
233
+ return
234
+ body = get_body_from_options(body=body, body_file=body_file, editor=editor)
235
+ body = prompt_if_missing(body, "Body")
236
+ service = IssueService(app.client())
237
+ item = service.comment(owner, repo, number, body)
238
+ click.echo(str(item["id"]))
239
+
240
+
241
+ @issue_group.command("reopen")
242
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
243
+ @click.argument("identifier")
244
+ @click.pass_context
245
+ def issue_reopen(ctx: click.Context, repo_name: str | None, identifier: str) -> None:
246
+ app = ctx.obj["app"]
247
+ url_owner, url_repo, number = resolve_issue_arg(identifier)
248
+ if url_owner:
249
+ assert url_repo is not None
250
+ owner, repo = url_owner, url_repo
251
+ else:
252
+ owner, repo = resolve_repo(repo_name or app.repo)
253
+ service = IssueService(app.client())
254
+ item = service.update(owner, repo, number, state="open")
255
+ click.echo(f"Reopened issue #{item['number']}")
256
+
257
+
258
+ @issue_group.command("edit")
259
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
260
+ @click.argument("identifier")
261
+ @click.option("-t", "--title")
262
+ @click.option("-b", "--body")
263
+ @click.option("-a", "--add-assignee")
264
+ @click.option("-l", "--add-label")
265
+ @click.option("--remove-assignee")
266
+ @click.option("--remove-label")
267
+ @click.pass_context
268
+ def issue_edit(
269
+ ctx: click.Context,
270
+ repo_name: str | None,
271
+ identifier: str,
272
+ title: str | None,
273
+ body: str | None,
274
+ add_assignee: str | None,
275
+ add_label: str | None,
276
+ remove_assignee: str | None,
277
+ remove_label: str | None,
278
+ ) -> None:
279
+ app = ctx.obj["app"]
280
+ url_owner, url_repo, number = resolve_issue_arg(identifier)
281
+ if url_owner:
282
+ assert url_repo is not None
283
+ owner, repo = url_owner, url_repo
284
+ else:
285
+ owner, repo = resolve_repo(repo_name or app.repo)
286
+ service = IssueService(app.client())
287
+ data = {
288
+ k: v
289
+ for k, v in {
290
+ "title": title,
291
+ "body": body,
292
+ "assignee": add_assignee,
293
+ "labels": add_label,
294
+ "unassignee": remove_assignee,
295
+ "unset_labels": remove_label,
296
+ }.items()
297
+ if v is not None
298
+ }
299
+ if not data:
300
+ raise click.UsageError("must specify at least one field to edit")
301
+ item = service.update(owner, repo, number, **data)
302
+ click.echo(f"Edited issue #{item['number']}")
303
+
304
+
305
+ @issue_group.command("delete")
306
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
307
+ @click.argument("identifier")
308
+ @click.confirmation_option(prompt="Are you sure you want to delete this issue?")
309
+ @click.pass_context
310
+ def issue_delete(ctx: click.Context, repo_name: str | None, identifier: str) -> None:
311
+ app = ctx.obj["app"]
312
+ url_owner, url_repo, number = resolve_issue_arg(identifier)
313
+ if url_owner:
314
+ assert url_repo is not None
315
+ owner, repo = url_owner, url_repo
316
+ else:
317
+ owner, repo = resolve_repo(repo_name or app.repo)
318
+ service = IssueService(app.client())
319
+ service.delete(owner, repo, number)
320
+ click.echo(f"Deleted issue #{number}")
321
+
322
+
323
+ @issue_group.command("status")
324
+ @click.option("-R", "--repo", "repo_name", help="Select another repository using the [HOST/]OWNER/REPO format.")
325
+ @click.pass_context
326
+ def issue_status(ctx: click.Context, repo_name: str | None) -> None:
327
+ app = ctx.obj["app"]
328
+ owner, repo = resolve_repo(repo_name or app.repo)
329
+ service = IssueService(app.client())
330
+ items = service.list(owner, repo, state="open")
331
+ click.echo("GitCode-limited approximation of gh issue status")
332
+ click.echo(f"Repository open issues for {owner}/{repo}:")
333
+ for item in items:
334
+ click.echo(f" #{item['number']}\t{item['state']}\t{item['title']}")
335
+
336
+
337
+ issue_group.add_command(issue_list, name="ls")
338
+ issue_group.add_command(issue_create, name="new")