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,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def dump_json(data, fields: list[str] | None = None) -> str:
|
|
12
|
+
if fields:
|
|
13
|
+
if isinstance(data, list):
|
|
14
|
+
data = [_filter_fields(item, fields) for item in data]
|
|
15
|
+
else:
|
|
16
|
+
data = _filter_fields(data, fields)
|
|
17
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _filter_fields(item: dict, fields: list[str]) -> dict:
|
|
21
|
+
result = {}
|
|
22
|
+
for field in fields:
|
|
23
|
+
if "." in field:
|
|
24
|
+
parts = field.split(".")
|
|
25
|
+
value = item
|
|
26
|
+
for part in parts:
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
value = value.get(part)
|
|
29
|
+
else:
|
|
30
|
+
value = None
|
|
31
|
+
break
|
|
32
|
+
result[field] = value
|
|
33
|
+
else:
|
|
34
|
+
result[field] = item.get(field)
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _login_from(item: dict, *keys: str) -> str:
|
|
39
|
+
for key in keys:
|
|
40
|
+
value = item.get(key)
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
login = value.get("login") or value.get("username") or value.get("name")
|
|
43
|
+
if login:
|
|
44
|
+
return str(login)
|
|
45
|
+
elif value:
|
|
46
|
+
return str(value)
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _list_row(item: dict, *, author_keys: tuple[str, ...]) -> str:
|
|
51
|
+
return "\t".join(
|
|
52
|
+
[
|
|
53
|
+
f"#{item.get('number', '')}",
|
|
54
|
+
str(item.get("state") or ""),
|
|
55
|
+
str(item.get("title") or ""),
|
|
56
|
+
_login_from(item, *author_keys),
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_issue_list(items: list[dict]) -> str:
|
|
62
|
+
return "\n".join(_list_row(item, author_keys=("author", "user", "creator")) for item in items)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_pr_list(items: list[dict]) -> str:
|
|
66
|
+
return "\n".join(_list_row(item, author_keys=("user", "author", "creator")) for item in items)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_detail(item: dict, *, author_keys: tuple[str, ...], branch: str | None = None) -> str:
|
|
70
|
+
number = item.get("number", "")
|
|
71
|
+
title = item.get("title") or ""
|
|
72
|
+
lines = [f"#{number} {title}", "", f"Title:\t{title}"]
|
|
73
|
+
|
|
74
|
+
state = item.get("state")
|
|
75
|
+
if state:
|
|
76
|
+
lines.append(f"State:\t{state}")
|
|
77
|
+
|
|
78
|
+
author = _login_from(item, *author_keys)
|
|
79
|
+
if author:
|
|
80
|
+
lines.append(f"Author:\t{author}")
|
|
81
|
+
|
|
82
|
+
if branch:
|
|
83
|
+
lines.append(f"Branch:\t{branch}")
|
|
84
|
+
|
|
85
|
+
body = item.get("body") or ""
|
|
86
|
+
lines.extend(["", "Body:", body])
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_issue_detail(item: dict) -> str:
|
|
91
|
+
return _format_detail(item, author_keys=("author", "user", "creator"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _branch_label(value) -> str:
|
|
95
|
+
if isinstance(value, dict):
|
|
96
|
+
label = value.get("label") or value.get("ref")
|
|
97
|
+
return str(label) if label else ""
|
|
98
|
+
return str(value) if value else ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_pr_detail(item: dict) -> str:
|
|
102
|
+
head = _branch_label(item.get("head"))
|
|
103
|
+
base = _branch_label(item.get("base"))
|
|
104
|
+
branch = f"{head} -> {base}" if head and base else None
|
|
105
|
+
return _format_detail(item, author_keys=("user", "author", "creator"), branch=branch)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def apply_jq(data, query: str):
|
|
109
|
+
try:
|
|
110
|
+
import jq # noqa: PLC0415
|
|
111
|
+
|
|
112
|
+
return jq.compile(query).input(data).all()
|
|
113
|
+
except ImportError:
|
|
114
|
+
pass
|
|
115
|
+
jq_bin = shutil.which("jq")
|
|
116
|
+
if jq_bin:
|
|
117
|
+
proc = subprocess.run(
|
|
118
|
+
[jq_bin, query],
|
|
119
|
+
input=json.dumps(data),
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
check=True,
|
|
123
|
+
)
|
|
124
|
+
return json.loads(proc.stdout)
|
|
125
|
+
raise click.ClickException("jq is required for --jq. Install with: pip install pyjq or install jq CLI.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render_template(data, template: str) -> str:
|
|
129
|
+
def replacer(match):
|
|
130
|
+
key = match.group(1)
|
|
131
|
+
if "." in key:
|
|
132
|
+
parts = key.split(".")
|
|
133
|
+
value = data
|
|
134
|
+
for part in parts:
|
|
135
|
+
if isinstance(value, dict):
|
|
136
|
+
value = value.get(part)
|
|
137
|
+
else:
|
|
138
|
+
value = None
|
|
139
|
+
break
|
|
140
|
+
else:
|
|
141
|
+
value = data.get(key) if isinstance(data, dict) else None
|
|
142
|
+
return str(value) if value is not None else ""
|
|
143
|
+
|
|
144
|
+
return re.sub(r"\{\{\.(\w+(?:\.\w+)*)\}\}", replacer, template)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def output_result(data, json_fields: str | None, jq_query: str | None, template: str | None, default_formatter):
|
|
148
|
+
if jq_query:
|
|
149
|
+
data = apply_jq(data, jq_query)
|
|
150
|
+
click.echo(dump_json(data))
|
|
151
|
+
return
|
|
152
|
+
if json_fields:
|
|
153
|
+
fields = [f.strip() for f in json_fields.split(",")]
|
|
154
|
+
click.echo(dump_json(data, fields=fields))
|
|
155
|
+
return
|
|
156
|
+
if template:
|
|
157
|
+
if isinstance(data, list):
|
|
158
|
+
for item in data:
|
|
159
|
+
click.echo(render_template(item, template))
|
|
160
|
+
else:
|
|
161
|
+
click.echo(render_template(data, template))
|
|
162
|
+
return
|
|
163
|
+
default_formatter(data)
|
gitcode_cli/repo.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from .errors import RepoResolutionError
|
|
7
|
+
|
|
8
|
+
HTTPS_RE = re.compile(r"https?://[^/]+/(?P<owner>[^/]+)/(?P<repo>[^/.]+?)(?:\.git)?$")
|
|
9
|
+
SSH_RE = re.compile(r"git@[^:]+:(?P<owner>[^/]+)/(?P<repo>[^/.]+?)(?:\.git)?$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_repo(repo: str) -> tuple[str, str]:
|
|
13
|
+
parts = repo.split("/")
|
|
14
|
+
if len(parts) == 3:
|
|
15
|
+
_, owner, name = parts
|
|
16
|
+
elif len(parts) == 2:
|
|
17
|
+
owner, name = parts
|
|
18
|
+
else:
|
|
19
|
+
raise RepoResolutionError("Invalid repo format. Use OWNER/REPO or HOST/OWNER/REPO.")
|
|
20
|
+
return owner, name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_remote_url(url: str) -> tuple[str, str]:
|
|
24
|
+
for pattern in (HTTPS_RE, SSH_RE):
|
|
25
|
+
match = pattern.match(url.strip())
|
|
26
|
+
if match:
|
|
27
|
+
return match.group("owner"), match.group("repo")
|
|
28
|
+
raise RepoResolutionError(f"Unsupported remote URL: {url}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_repo(explicit_repo: str | None = None) -> tuple[str, str]:
|
|
32
|
+
if explicit_repo:
|
|
33
|
+
return parse_repo(explicit_repo)
|
|
34
|
+
try:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["git", "remote", "get-url", "origin"],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
check=True,
|
|
40
|
+
)
|
|
41
|
+
except subprocess.CalledProcessError as exc:
|
|
42
|
+
raise RepoResolutionError("Unable to infer repo from current directory. Use -R OWNER/REPO.") from exc
|
|
43
|
+
return parse_remote_url(result.stdout.strip())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..client import GitCodeClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IssueService:
|
|
9
|
+
def __init__(self, client: GitCodeClient):
|
|
10
|
+
self.client = client
|
|
11
|
+
|
|
12
|
+
def list(self, owner: str, repo: str, **params: Any) -> Any | None:
|
|
13
|
+
return self.client.get(f"/repos/{owner}/{repo}/issues", params=params)
|
|
14
|
+
|
|
15
|
+
def get(self, owner: str, repo: str, number: str) -> Any | None:
|
|
16
|
+
return self.client.get(f"/repos/{owner}/{repo}/issues/{number}")
|
|
17
|
+
|
|
18
|
+
def list_comments(self, owner: str, repo: str, number: str) -> Any | None:
|
|
19
|
+
return self.client.get(f"/repos/{owner}/{repo}/issues/{number}/comments")
|
|
20
|
+
|
|
21
|
+
def create(self, owner: str, repo: str, **data: Any) -> Any | None:
|
|
22
|
+
payload: dict[str, Any] = {"repo": repo, **{k: v for k, v in data.items() if v is not None}}
|
|
23
|
+
return self.client.post(f"/repos/{owner}/issues", json=payload)
|
|
24
|
+
|
|
25
|
+
def update(self, owner: str, repo: str, number: str, **data: Any) -> Any | None:
|
|
26
|
+
payload: dict[str, Any] = {"repo": repo, **{k: v for k, v in data.items() if v is not None}}
|
|
27
|
+
return self.client.patch(f"/repos/{owner}/issues/{number}", json=payload)
|
|
28
|
+
|
|
29
|
+
def comment(self, owner: str, repo: str, number: str, body: str) -> Any | None:
|
|
30
|
+
return self.client.post(f"/repos/{owner}/{repo}/issues/{number}/comments", json={"body": body})
|
|
31
|
+
|
|
32
|
+
def delete(self, owner: str, repo: str, number: str) -> Any | None:
|
|
33
|
+
return self.client.delete(f"/repos/{owner}/{repo}/issues/{number}")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..client import GitCodeClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PullRequestService:
|
|
9
|
+
def __init__(self, client: GitCodeClient):
|
|
10
|
+
self.client = client
|
|
11
|
+
|
|
12
|
+
def list(self, owner: str, repo: str, **params: Any) -> Any | None:
|
|
13
|
+
return self.client.get(f"/repos/{owner}/{repo}/pulls", params=params)
|
|
14
|
+
|
|
15
|
+
def get(self, owner: str, repo: str, number: int) -> Any | None:
|
|
16
|
+
return self.client.get(f"/repos/{owner}/{repo}/pulls/{number}")
|
|
17
|
+
|
|
18
|
+
def create(self, owner: str, repo: str, **data: Any) -> Any | None:
|
|
19
|
+
return self.client.post(f"/repos/{owner}/{repo}/pulls", json={k: v for k, v in data.items() if v is not None})
|
|
20
|
+
|
|
21
|
+
def update(self, owner: str, repo: str, number: int, **data: Any) -> Any | None:
|
|
22
|
+
return self.client.patch(
|
|
23
|
+
f"/repos/{owner}/{repo}/pulls/{number}", json={k: v for k, v in data.items() if v is not None}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def merge(self, owner: str, repo: str, number: int, **data: Any) -> Any | None:
|
|
27
|
+
return self.client.put(
|
|
28
|
+
f"/repos/{owner}/{repo}/pulls/{number}/merge", json={k: v for k, v in data.items() if v is not None}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def comment(
|
|
32
|
+
self, owner: str, repo: str, number: int, body: str, path: str | None = None, position: int | None = None
|
|
33
|
+
) -> Any | None:
|
|
34
|
+
payload: dict[str, Any] = {"body": body, "path": path, "position": position}
|
|
35
|
+
return self.client.post(
|
|
36
|
+
f"/repos/{owner}/{repo}/pulls/{number}/comments", json={k: v for k, v in payload.items() if v is not None}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def review(self, owner: str, repo: str, number: int, body: str | None = None, force: bool = False) -> Any | None:
|
|
40
|
+
payload = {"body": body, "force": force}
|
|
41
|
+
filtered_payload = {k: v for k, v in payload.items() if v is not None}
|
|
42
|
+
return self.client.post(f"/repos/{owner}/{repo}/pulls/{number}/review", json=filtered_payload)
|
|
43
|
+
|
|
44
|
+
def diff(self, owner: str, repo: str, number: int) -> str:
|
|
45
|
+
response = self.client.request(
|
|
46
|
+
"GET",
|
|
47
|
+
f"/repos/{owner}/{repo}/pulls/{number}/diff",
|
|
48
|
+
accept="text/plain",
|
|
49
|
+
response_format="text",
|
|
50
|
+
)
|
|
51
|
+
return response or ""
|
gitcode_cli/utils.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import webbrowser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .services import PullRequestService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def prompt_if_missing(value: str | None, prompt_text: str, hide_input: bool = False) -> str:
|
|
16
|
+
"""If value is None or empty, use click.prompt interactively."""
|
|
17
|
+
if not value:
|
|
18
|
+
return click.prompt(prompt_text, hide_input=hide_input)
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_current_git_branch() -> str | None:
|
|
23
|
+
"""Get current git branch name, return None on failure."""
|
|
24
|
+
try:
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
check=True,
|
|
30
|
+
)
|
|
31
|
+
return result.stdout.strip()
|
|
32
|
+
except subprocess.CalledProcessError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_default_git_branch() -> str | None:
|
|
37
|
+
"""Try to get remote default branch (e.g., origin/HEAD), return None on failure."""
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "rev-parse", "--abbrev-ref", "origin/HEAD"],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
check=True,
|
|
44
|
+
)
|
|
45
|
+
branch = result.stdout.strip()
|
|
46
|
+
if branch.startswith("origin/"):
|
|
47
|
+
return branch[len("origin/") :]
|
|
48
|
+
return branch
|
|
49
|
+
except subprocess.CalledProcessError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_body_file(path: str) -> str:
|
|
54
|
+
"""Read body content from a file."""
|
|
55
|
+
return Path(path).read_text(encoding="utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def open_in_browser(url: str) -> None:
|
|
59
|
+
"""Open URL in default browser."""
|
|
60
|
+
webbrowser.open(url)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- Issue / PR identifier resolvers ---
|
|
64
|
+
|
|
65
|
+
ISSUE_URL_RE = re.compile(r"https?://[^/]+/(?P<owner>[^/]+)/(?P<repo>[^/]+)/issues/(?P<number>\d+)")
|
|
66
|
+
PR_URL_RE = re.compile(r"https?://[^/]+/(?P<owner>[^/]+)/(?P<repo>[^/]+)/pulls?/(?P<number>\d+)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_issue_url(url: str) -> tuple[str, str, str] | None:
|
|
70
|
+
"""Parse an issue URL into (owner, repo, number). Returns None if not a match."""
|
|
71
|
+
match = ISSUE_URL_RE.match(url.strip())
|
|
72
|
+
if match:
|
|
73
|
+
return match.group("owner"), match.group("repo"), match.group("number")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_pr_url(url: str) -> tuple[str, str, str] | None:
|
|
78
|
+
"""Parse a PR URL into (owner, repo, number). Returns None if not a match."""
|
|
79
|
+
match = PR_URL_RE.match(url.strip())
|
|
80
|
+
if match:
|
|
81
|
+
return match.group("owner"), match.group("repo"), match.group("number")
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_issue_arg(identifier: str):
|
|
86
|
+
"""Resolve an issue identifier (number or URL) into (owner, repo, number).
|
|
87
|
+
|
|
88
|
+
Returns (None, None, number) if it's just a number, or (owner, repo, number) if a URL.
|
|
89
|
+
"""
|
|
90
|
+
url_result = parse_issue_url(identifier)
|
|
91
|
+
if url_result:
|
|
92
|
+
return url_result
|
|
93
|
+
return None, None, identifier
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_pr_arg(identifier: str, owner: str, repo: str, service: PullRequestService) -> tuple[str, str, str]:
|
|
97
|
+
"""Resolve a PR identifier (number, URL, or branch) into (owner, repo, number).
|
|
98
|
+
|
|
99
|
+
Returns (owner, repo, number). If branch is given, queries the API to find the PR.
|
|
100
|
+
Raises click.ClickException if not found.
|
|
101
|
+
"""
|
|
102
|
+
# Try URL first
|
|
103
|
+
url_result = parse_pr_url(identifier)
|
|
104
|
+
if url_result:
|
|
105
|
+
return url_result
|
|
106
|
+
|
|
107
|
+
# Try pure number
|
|
108
|
+
if identifier.isdigit():
|
|
109
|
+
return owner, repo, identifier
|
|
110
|
+
|
|
111
|
+
# Treat as branch name — search open PRs with this head branch
|
|
112
|
+
items = service.list(owner, repo, state="open", head=identifier)
|
|
113
|
+
for item in items:
|
|
114
|
+
head_ref = item.get("head", {}).get("ref", "")
|
|
115
|
+
if head_ref == identifier:
|
|
116
|
+
return owner, repo, str(item["number"])
|
|
117
|
+
|
|
118
|
+
raise click.ClickException(f"No open pull request found for branch '{identifier}'.")
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pygitcode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool for GitCode (api.gitcode.com), modeled after GitHub CLI.
|
|
5
|
+
Author-email: codeasier <825770651@qq.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/pygitcode
|
|
8
|
+
Project-URL: Documentation, https://github.com/yourusername/pygitcode#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/yourusername/pygitcode.git
|
|
10
|
+
Project-URL: Issues, https://github.com/yourusername/pygitcode/issues
|
|
11
|
+
Keywords: gitcode,cli,git,api,vcs,developer-tools
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: click>=8.0
|
|
29
|
+
Requires-Dist: httpx>=0.24
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-mock>=3.14; extra == "dev"
|
|
34
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
36
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
37
|
+
Requires-Dist: basedpyright>=1.19; extra == "dev"
|
|
38
|
+
Requires-Dist: pre-commit>=3.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# pygitcode
|
|
42
|
+
|
|
43
|
+
> A CLI tool for [GitCode](https://gitcode.com/) (`api.gitcode.com`), modeled after GitHub CLI (`gh`).
|
|
44
|
+
|
|
45
|
+
[](https://pypi.org/project/pygitcode/)
|
|
46
|
+
[](https://pypi.org/project/pygitcode/)
|
|
47
|
+
[](https://github.com/yourusername/pygitcode/blob/main/LICENSE)
|
|
48
|
+
[](https://github.com/yourusername/pygitcode/actions)
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install pygitcode
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This exposes the `gc` command in your shell.
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
### Authentication
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Login with your GitCode personal access token
|
|
64
|
+
gc auth login
|
|
65
|
+
|
|
66
|
+
# Or set environment variable
|
|
67
|
+
export GC_TOKEN=your_token_here
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Issues
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# List issues (with filtering options)
|
|
74
|
+
gc issue list
|
|
75
|
+
gc issue list --state closed --author @me
|
|
76
|
+
gc issue list --label bug --label "help wanted"
|
|
77
|
+
gc issue list --web # Open in browser
|
|
78
|
+
|
|
79
|
+
# View an issue (with comments)
|
|
80
|
+
gc issue view 42
|
|
81
|
+
gc issue view 42 --comments
|
|
82
|
+
gc issue view 42 --web
|
|
83
|
+
|
|
84
|
+
# Create an issue
|
|
85
|
+
gc issue create -t "Bug report" -b "Something is broken"
|
|
86
|
+
gc issue create --web # Create in browser
|
|
87
|
+
|
|
88
|
+
# Edit an issue (add/remove labels, assignees)
|
|
89
|
+
gc issue edit 42 -t "Updated title"
|
|
90
|
+
gc issue edit 42 --add-label bug --remove-label "in progress"
|
|
91
|
+
gc issue edit 42 --add-assignee @me --remove-assignee otheruser
|
|
92
|
+
|
|
93
|
+
# Comment on an issue
|
|
94
|
+
gc issue comment 42 -b "Thanks for the report!"
|
|
95
|
+
gc issue comment 42 --editor # Use system editor
|
|
96
|
+
|
|
97
|
+
# Close / reopen / delete
|
|
98
|
+
gc issue close 42
|
|
99
|
+
gc issue reopen 42
|
|
100
|
+
gc issue delete 42
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Pull Requests
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# List PRs (with filtering options)
|
|
107
|
+
gc pr list
|
|
108
|
+
gc pr list --state merged --author @me
|
|
109
|
+
gc pr list --base main --draft
|
|
110
|
+
gc pr list --web # Open in browser
|
|
111
|
+
|
|
112
|
+
# View a PR (identifier optional - infers from current branch)
|
|
113
|
+
gc pr view # View PR for current branch
|
|
114
|
+
gc pr view 42 # By number
|
|
115
|
+
gc pr view https://gitcode.com/owner/repo/pulls/42
|
|
116
|
+
gc pr view feature-branch
|
|
117
|
+
|
|
118
|
+
# Create a PR (auto-detects current branch and default base)
|
|
119
|
+
gc pr create -t "Add new feature"
|
|
120
|
+
|
|
121
|
+
# Create with auto-fill from commits
|
|
122
|
+
gc pr create --fill # Use latest commit
|
|
123
|
+
gc pr create --fill-first # Use first commit
|
|
124
|
+
gc pr create --fill-verbose # Use all commits for body
|
|
125
|
+
|
|
126
|
+
# Create with editor
|
|
127
|
+
gc pr create --editor
|
|
128
|
+
|
|
129
|
+
# Preview without creating
|
|
130
|
+
gc pr create --dry-run
|
|
131
|
+
|
|
132
|
+
# Create in browser
|
|
133
|
+
gc pr create --web
|
|
134
|
+
|
|
135
|
+
# Close / merge / reopen (identifier optional)
|
|
136
|
+
gc pr close # Close PR for current branch
|
|
137
|
+
gc pr close 42 -c "Closing as stale"
|
|
138
|
+
gc pr close --delete-branch # Delete remote branch too
|
|
139
|
+
|
|
140
|
+
gc pr merge # Merge PR for current branch
|
|
141
|
+
gc pr merge 42 -s # Squash merge
|
|
142
|
+
gc pr merge --rebase
|
|
143
|
+
|
|
144
|
+
# Edit a PR (add/remove labels, assignees, reviewers)
|
|
145
|
+
gc pr edit 42 -t "New title"
|
|
146
|
+
gc pr edit --add-label bug --remove-label "needs review"
|
|
147
|
+
gc pr edit --add-reviewer @me --remove-reviewer otheruser
|
|
148
|
+
|
|
149
|
+
# Mark as ready or convert to draft
|
|
150
|
+
gc pr ready # Mark current branch's PR as ready
|
|
151
|
+
gc pr ready --undo # Convert back to draft
|
|
152
|
+
|
|
153
|
+
# Comment / review / diff (identifier optional)
|
|
154
|
+
gc pr comment -b "LGTM"
|
|
155
|
+
gc pr comment --path src/file.py --position 5 -b "Suggestion"
|
|
156
|
+
|
|
157
|
+
gc pr review --approve
|
|
158
|
+
gc pr review --comment -b "Looks good but needs tests"
|
|
159
|
+
gc pr review --request-changes -b "Missing documentation"
|
|
160
|
+
|
|
161
|
+
gc pr diff # View diff for current branch's PR
|
|
162
|
+
gc pr diff 42
|
|
163
|
+
|
|
164
|
+
# Checkout a PR (identifier optional)
|
|
165
|
+
gc pr checkout # Checkout PR for current branch? (prompts if ambiguous)
|
|
166
|
+
gc pr checkout 42
|
|
167
|
+
gc pr checkout -b local-branch-name
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Global Options
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
# Use a different repo without cd-ing into it
|
|
174
|
+
gc issue list -R owner/repo
|
|
175
|
+
gc pr list -R owner/repo
|
|
176
|
+
|
|
177
|
+
# Output as JSON with field selection
|
|
178
|
+
gc issue list --json number,title,state,author
|
|
179
|
+
gc pr list --json number,title,state,head,base
|
|
180
|
+
|
|
181
|
+
# Filter with jq
|
|
182
|
+
gc issue list -q '.[] | select(.state == "open")'
|
|
183
|
+
gc pr list -q '.[] | select(.draft == true)'
|
|
184
|
+
|
|
185
|
+
# Format with Go-style templates
|
|
186
|
+
gc issue list -t '{{.number}} {{.title}} ({{.state}})'
|
|
187
|
+
gc pr view -t 'PR #{{.number}}: {{.title}}\n{{.body}}'
|
|
188
|
+
|
|
189
|
+
# Open in browser
|
|
190
|
+
gc issue view 42 -w
|
|
191
|
+
gc pr view -w
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Alignment with `gh` CLI
|
|
195
|
+
|
|
196
|
+
`pygitcode` aims to be as familiar as possible to `gh` users:
|
|
197
|
+
|
|
198
|
+
| Feature | `gh` | `gc` |
|
|
199
|
+
|---------|------|------|
|
|
200
|
+
| PR identifier optional (current branch inference) | ✅ | ✅ |
|
|
201
|
+
| `--fill` / `--fill-first` / `--fill-verbose` | ✅ | ✅ |
|
|
202
|
+
| `--editor` | ✅ | ✅ |
|
|
203
|
+
| `--dry-run` | ✅ | ✅ |
|
|
204
|
+
| `--web` (create/view in browser) | ✅ | ✅ |
|
|
205
|
+
| `--remove-*` flags for edit commands | ✅ | ✅ |
|
|
206
|
+
| `pr ready --undo` (convert to draft) | ✅ | ✅ |
|
|
207
|
+
| `pr review --comment` / `--request-changes` | ✅ | ✅ (fallback) |
|
|
208
|
+
| `--json fields` | ✅ | ✅ |
|
|
209
|
+
| `-q jq` filtering | ✅ | ✅ |
|
|
210
|
+
| `-t template` formatting | ✅ | ✅ |
|
|
211
|
+
| Command aliases (`ls` → `list`, `new` → `create`) | ✅ | ✅ |
|
|
212
|
+
| Multi-value options (`--label bug --label feature`) | ✅ | ✅ |
|
|
213
|
+
|
|
214
|
+
### Known Limitations (GitCode API differences)
|
|
215
|
+
|
|
216
|
+
- **PR comment model**: GitCode uses `path + position`, not GitHub's `line/side/commit`
|
|
217
|
+
- **PR review**: GitCode review API differs from GitHub; `--comment` and `--request-changes` fall back to PR comments
|
|
218
|
+
- **Issue create/update API**: GitCode puts `repo` in request body, not URL path
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
# Clone
|
|
224
|
+
git clone https://github.com/yourusername/pygitcode.git
|
|
225
|
+
cd pygitcode
|
|
226
|
+
|
|
227
|
+
# Install in editable mode with dev dependencies
|
|
228
|
+
pip install -e ".[dev]"
|
|
229
|
+
|
|
230
|
+
# Install pre-commit hooks
|
|
231
|
+
pre-commit install
|
|
232
|
+
|
|
233
|
+
# Run all checks manually
|
|
234
|
+
pre-commit run --all-files
|
|
235
|
+
|
|
236
|
+
# Or run individually
|
|
237
|
+
python -m pytest tests/unit/ --cov=gitcode_cli
|
|
238
|
+
python -m ruff check src/ tests/
|
|
239
|
+
python -m ruff format src/ tests/
|
|
240
|
+
python -m basedpyright src/
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Roadmap
|
|
244
|
+
|
|
245
|
+
See [ROADMAP.md](ROADMAP.md) for planned features and current gaps.
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
gitcode_cli/__init__.py,sha256=4t_crzhrLum--oyowUMxtjBTzUtWp7oRTF22ewEvJG4,49
|
|
2
|
+
gitcode_cli/cli.py,sha256=ryDkhUr4rSi7lk2231pNVsOCfu70N_qZ5ETbXrzxhrI,1265
|
|
3
|
+
gitcode_cli/cli_compat.py,sha256=wHQazS-GWqDa7biLq95Bizfj0lKsb-CwJL-Urii6geI,3769
|
|
4
|
+
gitcode_cli/client.py,sha256=yTzbueKJO8Zxd4y9e_mO4b5YBq7uRHPm7y_jHXbJH0c,2502
|
|
5
|
+
gitcode_cli/config.py,sha256=c3IuVrQ6BN2Y-LKoLM5dzmvmg80z31iINbtnR77PxsA,1037
|
|
6
|
+
gitcode_cli/context.py,sha256=VLYmN3kUwI_txDpsTwif27M9pOScK9jOQLOpPdnrJ3s,259
|
|
7
|
+
gitcode_cli/errors.py,sha256=9apEzj7k9WtDST8s7KNkOHfPQKwMRmRNLTVZKkIG2aM,361
|
|
8
|
+
gitcode_cli/formatters.py,sha256=dA8hv1KR9DwkRVzowejQ6FKljFk8e5suQKzkflBt1G4,4785
|
|
9
|
+
gitcode_cli/repo.py,sha256=nCXTMOGFX1FoOYZSA-uaw5Vml4HaDwE9XBltE4C6Yjw,1392
|
|
10
|
+
gitcode_cli/utils.py,sha256=PHkTxMD6w_dfo5qCNil8-R9kUOdre6PAUvvf-KkkOA0,3774
|
|
11
|
+
gitcode_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
gitcode_cli/commands/auth.py,sha256=ovK308_GXAyYIS8ssZv9v6WtqaOTqvay9mTh4SdmY8Y,609
|
|
13
|
+
gitcode_cli/commands/issue.py,sha256=fOR44m5V7dYKsSIZ4i0rqrL7oXsrf2okruiR8AaQ0Rk,11845
|
|
14
|
+
gitcode_cli/commands/pr.py,sha256=5XKaCq8OnC3Y3l3TmMXmIkeE4LLYNbldzePvUFKCZxE,19271
|
|
15
|
+
gitcode_cli/services/__init__.py,sha256=mLdxGZzPzLCXPEjYmUPmeHxVpf3MqXBURhiwFObEE2Q,157
|
|
16
|
+
gitcode_cli/services/issues.py,sha256=cA4LdFn-u2Bm5Na4L6PapqaXRUFk8SVY32gWQYwyDPk,1503
|
|
17
|
+
gitcode_cli/services/pulls.py,sha256=OuGpJGHnegKs-XsdgA7HkB1uo0zUpxZfLRjR7fY3l5Y,2201
|
|
18
|
+
pygitcode-0.1.0.dist-info/licenses/LICENSE,sha256=J2vlMgWrjCSYp3bRVoTkK5HNyfqvNLJIRG2Q7qVI1Io,1079
|
|
19
|
+
pygitcode-0.1.0.dist-info/METADATA,sha256=7mY8G9fjDMy2fBYDgfS3OqVQps7jbXbJuzlWhpxscqA,7596
|
|
20
|
+
pygitcode-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
21
|
+
pygitcode-0.1.0.dist-info/entry_points.txt,sha256=y7KnILZ9i90n805mpqkvGE_BgOgnZ0g8FDa68qHaDv8,44
|
|
22
|
+
pygitcode-0.1.0.dist-info/top_level.txt,sha256=Q4TjfscIckcgCfrL7_GwY1A9mpfGtuFyytgqCRngcjY,12
|
|
23
|
+
pygitcode-0.1.0.dist-info/RECORD,,
|