已开启
feat(cli): add gh-style command-line interface #5
feat(cli): add gh-style command-line interface #5
已开启
happyaron创建于 3月17日
共 4 个文件变更+765-1
MREADME.md+12-0
@@ -23,6 +23,7 @@ pip install -e .
23 23 
24```bash24```bash
25gitcode-mcp25gitcode-mcp
26+gitcode
26```27```
27 28 
28### 方法二:手动安装依赖29### 方法二:手动安装依赖
@@ -47,6 +48,17 @@ HTTPS_PROXY=https://localhost:8080
47python -m gitcode_mcp48python -m gitcode_mcp
48```49```
49 50 
51+3. 使用 CLI 直接操作 GitCode:
52+```bash
53+gitcode auth status
54+gitcode repo list
55+gitcode repo view owner/repo
56+gitcode issue create owner/repo --title "Bug report" --body "Steps to reproduce"
57+gitcode pr create owner/repo --title "Ship feature" --head feature-branch --base main --body "Ready for review"
58+gitcode search repos mcp server
59+gitcode api GET /user/repos --field per_page=20 --json
60+```
61+ 
503. 在支持 MCP 的 IDE 中配置服务器连接623. 在支持 MCP 的 IDE 中配置服务器连接
51 63 
52### 代理支持64### 代理支持
@@ -39,6 +39,7 @@ setup(
39 entry_points={39 entry_points={
40 "console_scripts": [40 "console_scripts": [
41 "gitcode-mcp=gitcode_mcp.server:main",41 "gitcode-mcp=gitcode_mcp.server:main",
42+ "gitcode=gitcode_mcp.cli:main",
42 ],43 ],
43 },44 },
44-) 45+)
@@ -0,0 +1,493 @@
1+import argparse
2+import asyncio
3+import json
4+import os
5+import sys
6+from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, TextIO, Tuple
7+ 
8+from .api import GitCodeAPI
9+ 
10+ 
11+ClientFactory = Callable[[argparse.Namespace], GitCodeAPI]
12+ 
13+ 
14+def build_parser() -> argparse.ArgumentParser:
15+ parser = argparse.ArgumentParser(
16+ prog="gitcode",
17+ description="GitCode command-line client inspired by GitHub's gh CLI.",
18+ )
19+ parser.add_argument("--json", action="store_true", help="Print raw JSON responses.")
20+ parser.add_argument("--token", help="GitCode personal access token. Defaults to GITCODE_TOKEN.")
21+ parser.add_argument(
22+ "--api-base-url",
23+ default="https://api.gitcode.com/api/v5",
24+ help="GitCode API base URL.",
25+ )
26+ 
27+ subparsers = parser.add_subparsers(dest="command")
28+ subparsers.required = True
29+ 
30+ auth = subparsers.add_parser("auth", help="Authentication helpers.")
31+ auth_subparsers = auth.add_subparsers(dest="auth_command")
32+ auth_subparsers.required = True
33+ auth_subparsers.add_parser("status", help="Show token status.")
34+ 
35+ repo = subparsers.add_parser("repo", help="Repository commands.")
36+ repo_subparsers = repo.add_subparsers(dest="repo_command")
37+ repo_subparsers.required = True
38+ 
39+ repo_list = repo_subparsers.add_parser("list", help="List your repositories.")
40+ repo_list.add_argument("--visibility", choices=["all", "public", "private"])
41+ repo_list.add_argument("--sort", choices=["created", "updated", "pushed", "full_name"])
42+ 
43+ repo_view = repo_subparsers.add_parser("view", help="View a repository.")
44+ repo_view.add_argument("repo", help="Repository in OWNER/NAME format.")
45+ 
46+ repo_create = repo_subparsers.add_parser("create", help="Create a repository.")
47+ repo_create.add_argument("name", help="Repository name.")
48+ repo_create.add_argument("--description", default="", help="Repository description.")
49+ repo_create.add_argument("--private", action="store_true", help="Create a private repository.")
50+ 
51+ issue = subparsers.add_parser("issue", help="Issue commands.")
52+ issue_subparsers = issue.add_subparsers(dest="issue_command")
53+ issue_subparsers.required = True
54+ 
55+ issue_list = issue_subparsers.add_parser("list", help="List issues for a repository.")
56+ issue_list.add_argument("repo", help="Repository in OWNER/NAME format.")
57+ issue_list.add_argument("--state", choices=["open", "closed", "all"], default="open")
58+ 
59+ issue_view = issue_subparsers.add_parser("view", help="View an issue.")
60+ issue_view.add_argument("repo", help="Repository in OWNER/NAME format.")
61+ issue_view.add_argument("number", type=int, help="Issue number.")
62+ 
63+ issue_create = issue_subparsers.add_parser("create", help="Create an issue.")
64+ issue_create.add_argument("repo", help="Repository in OWNER/NAME format.")
65+ issue_create.add_argument("--title", required=True, help="Issue title.")
66+ issue_create.add_argument("--body", default="", help="Issue body.")
67+ 
68+ issue_comment = issue_subparsers.add_parser("comment", help="Comment on an issue.")
69+ issue_comment.add_argument("repo", help="Repository in OWNER/NAME format.")
70+ issue_comment.add_argument("number", type=int, help="Issue number.")
71+ issue_comment.add_argument("--body", required=True, help="Comment body.")
72+ 
73+ pr = subparsers.add_parser("pr", help="Pull request commands.")
74+ pr_subparsers = pr.add_subparsers(dest="pr_command")
75+ pr_subparsers.required = True
76+ 
77+ pr_list = pr_subparsers.add_parser("list", help="List pull requests for a repository.")
78+ pr_list.add_argument("repo", help="Repository in OWNER/NAME format.")
79+ pr_list.add_argument("--state", choices=["open", "closed", "all"], default="open")
80+ 
81+ pr_view = pr_subparsers.add_parser("view", help="View a pull request.")
82+ pr_view.add_argument("repo", help="Repository in OWNER/NAME format.")
83+ pr_view.add_argument("number", type=int, help="Pull request number.")
84+ 
85+ pr_create = pr_subparsers.add_parser("create", help="Create a pull request.")
86+ pr_create.add_argument("repo", help="Repository in OWNER/NAME format.")
87+ pr_create.add_argument("--title", required=True, help="Pull request title.")
88+ pr_create.add_argument("--head", required=True, help="Source branch.")
89+ pr_create.add_argument("--base", required=True, help="Target branch.")
90+ pr_create.add_argument("--body", default="", help="Pull request body.")
91+ 
92+ pr_comment = pr_subparsers.add_parser("comment", help="Comment on a pull request.")
93+ pr_comment.add_argument("repo", help="Repository in OWNER/NAME format.")
94+ pr_comment.add_argument("number", type=int, help="Pull request number.")
95+ pr_comment.add_argument("--body", required=True, help="Comment body.")
96+ pr_comment.add_argument("--path", help="Optional file path for a review comment.")
97+ pr_comment.add_argument("--position", type=int, help="Optional line position for a review comment.")
98+ 
99+ pr_merge = pr_subparsers.add_parser("merge", help="Merge a pull request.")
100+ pr_merge.add_argument("repo", help="Repository in OWNER/NAME format.")
101+ pr_merge.add_argument("number", type=int, help="Pull request number.")
102+ pr_merge.add_argument("--method", choices=["merge", "squash", "rebase"], help="Merge strategy.")
103+ pr_merge.add_argument("--title", help="Merge commit title.")
104+ pr_merge.add_argument("--body", help="Merge commit message.")
105+ 
106+ search = subparsers.add_parser("search", help="Search GitCode resources.")
107+ search_subparsers = search.add_subparsers(dest="search_command")
108+ search_subparsers.required = True
109+ for subject, help_text in (
110+ ("repos", "Search repositories."),
111+ ("issues", "Search issues and pull requests."),
112+ ("users", "Search users."),
113+ ):
114+ search_parser = search_subparsers.add_parser(subject, help=help_text)
115+ search_parser.add_argument("query", nargs="+", help="Search query.")
116+ search_parser.add_argument("--sort", help="Sort field supported by the API.")
117+ search_parser.add_argument("--order", choices=["asc", "desc"], help="Sort order.")
118+ search_parser.add_argument("--limit", type=int, dest="per_page", help="Results per page.")
119+ search_parser.add_argument("--page", type=int, help="Page number.")
120+ 
121+ api = subparsers.add_parser("api", help="Call a GitCode API endpoint directly.")
122+ api.add_argument("method", help="HTTP method.")
123+ api.add_argument("path", help="API path, such as /user/repos.")
124+ api.add_argument(
125+ "--field",
126+ action="append",
127+ default=[],
128+ metavar="KEY=VALUE",
129+ help="Request field. Reuse for multiple values.",
130+ )
131+ 
132+ return parser
133+ 
134+ 
135+def parse_repo_slug(value: str) -> Tuple[str, str]:
136+ if "/" not in value:
137+ raise ValueError("Repository must be in OWNER/NAME format.")
138+ owner, repo = value.split("/", 1)
139+ if not owner or not repo:
140+ raise ValueError("Repository must be in OWNER/NAME format.")
141+ return owner, repo
142+ 
143+ 
144+def default_client_factory(args: argparse.Namespace) -> GitCodeAPI:
145+ token = args.token or os.environ.get("GITCODE_TOKEN")
146+ return GitCodeAPI(token=token, api_base_url=args.api_base_url)
147+ 
148+ 
149+def infer_field_value(raw_value: str) -> Any:
150+ try:
151+ return json.loads(raw_value)
152+ except json.JSONDecodeError:
153+ return raw_value
154+ 
155+ 
156+def parse_fields(items: Iterable[str]) -> Dict[str, Any]:
157+ parsed: Dict[str, Any] = {}
158+ for item in items:
159+ if "=" not in item:
160+ raise ValueError(f"Invalid field '{item}'. Expected KEY=VALUE.")
161+ key, raw_value = item.split("=", 1)
162+ if not key:
163+ raise ValueError(f"Invalid field '{item}'. Expected KEY=VALUE.")
164+ parsed[key] = infer_field_value(raw_value)
165+ return parsed
166+ 
167+ 
168+def print_json(data: Any, stdout: TextIO) -> None:
169+ stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
170+ 
171+ 
172+def print_lines(lines: Iterable[str], stdout: TextIO) -> None:
173+ for line in lines:
174+ stdout.write(line + "\n")
175+ 
176+ 
177+def format_repo_summary(repo: Dict[str, Any]) -> List[str]:
178+ visibility = "private" if repo.get("private") else "public"
179+ description = repo.get("description") or ""
180+ stars = repo.get("stargazers_count", 0)
181+ forks = repo.get("forks_count", 0)
182+ default_branch = repo.get("default_branch", "main")
183+ return [
184+ repo.get("full_name", repo.get("name", "unknown")),
185+ f" visibility: {visibility} | stars: {stars} | forks: {forks} | default branch: {default_branch}",
186+ f" {description}" if description else "",
187+ ]
188+ 
189+ 
190+def format_issue_summary(issue: Dict[str, Any]) -> str:
191+ number = issue.get("number", "?")
192+ state = issue.get("state", "unknown")
193+ title = issue.get("title", "")
194+ return f"#{number} [{state}] {title}"
195+ 
196+ 
197+def format_pr_summary(pr: Dict[str, Any]) -> str:
198+ number = pr.get("number", "?")
199+ state = pr.get("state", "unknown")
200+ title = pr.get("title", "")
201+ return f"#{number} [{state}] {title}"
202+ 
203+ 
204+def format_search_item(item: Dict[str, Any], kind: str) -> str:
205+ if kind == "repos":
206+ return item.get("full_name", item.get("name", "unknown"))
207+ if kind == "users":
208+ return item.get("login", item.get("name", "unknown"))
209+ return format_issue_summary(item)
210+ 
211+ 
212+def emit_output(data: Any, args: argparse.Namespace, stdout: TextIO, formatter: Optional[Callable[[Any], List[str]]] = None) -> None:
213+ if args.json:
214+ print_json(data, stdout)
215+ return
216+ 
217+ if formatter is None:
218+ print_json(data, stdout)
219+ return
220+ 
221+ print_lines(formatter(data), stdout)
222+ 
223+ 
224+async def execute_command(
225+ args: argparse.Namespace,
226+ client_factory: ClientFactory,
227+ stdout: TextIO,
228+) -> int:
229+ if args.command == "auth":
230+ token = args.token or os.environ.get("GITCODE_TOKEN")
231+ if not token:
232+ raise ValueError("GitCode token not found. Set GITCODE_TOKEN or pass --token.")
233+ stdout.write("Authenticated with GitCode token\n")
234+ stdout.write(f"API base URL: {args.api_base_url}\n")
235+ return 0
236+ 
237+ client = client_factory(args)
238+ try:
239+ if args.command == "repo":
240+ return await handle_repo_command(args, client, stdout)
241+ if args.command == "issue":
242+ return await handle_issue_command(args, client, stdout)
243+ if args.command == "pr":
244+ return await handle_pr_command(args, client, stdout)
245+ if args.command == "search":
246+ return await handle_search_command(args, client, stdout)
247+ if args.command == "api":
248+ return await handle_api_command(args, client, stdout)
249+ raise ValueError(f"Unsupported command: {args.command}")
250+ finally:
251+ await client.close()
252+ 
253+ 
254+async def handle_repo_command(args: argparse.Namespace, client: GitCodeAPI, stdout: TextIO) -> int:
255+ if args.repo_command == "list":
256+ repos = await client.repos.list_user_repos(visibility=args.visibility, sort=args.sort)
257+ emit_output(repos, args, stdout, formatter=lambda items: format_repo_list(items))
258+ return 0
259+ 
260+ if args.repo_command == "view":
261+ owner, repo = parse_repo_slug(args.repo)
262+ repo_data = await client.repos.get_repo(owner=owner, repo=repo)
263+ emit_output(repo_data, args, stdout, formatter=format_repo_detail)
264+ return 0
265+ 
266+ if args.repo_command == "create":
267+ repo_data = await client.repos.create_repo(
268+ name=args.name,
269+ description=args.description,
270+ private=args.private,
271+ )
272+ emit_output(repo_data, args, stdout, formatter=format_repo_detail)
273+ return 0
274+ 
275+ raise ValueError(f"Unsupported repo command: {args.repo_command}")
276+ 
277+ 
278+async def handle_issue_command(args: argparse.Namespace, client: GitCodeAPI, stdout: TextIO) -> int:
279+ owner, repo = parse_repo_slug(args.repo)
280+ if args.issue_command == "list":
281+ issues = await client.issues.list_issues(owner=owner, repo=repo, state=args.state)
282+ emit_output(issues, args, stdout, formatter=lambda items: [format_issue_summary(item) for item in items])
283+ return 0
284+ 
285+ if args.issue_command == "view":
286+ issue = await client.issues.get_issue(owner=owner, repo=repo, issue_number=args.number)
287+ emit_output(issue, args, stdout, formatter=format_issue_detail)
288+ return 0
289+ 
290+ if args.issue_command == "create":
291+ issue = await client.issues.create_issue(owner=owner, repo=repo, title=args.title, body=args.body)
292+ emit_output(issue, args, stdout, formatter=format_issue_detail)
293+ return 0
294+ 
295+ if args.issue_command == "comment":
296+ comment = await client.issues.add_comment(owner=owner, repo=repo, issue_number=args.number, body=args.body)
297+ emit_output(comment, args, stdout, formatter=format_comment_detail)
298+ return 0
299+ 
300+ raise ValueError(f"Unsupported issue command: {args.issue_command}")
301+ 
302+ 
303+async def handle_pr_command(args: argparse.Namespace, client: GitCodeAPI, stdout: TextIO) -> int:
304+ owner, repo = parse_repo_slug(args.repo)
305+ if args.pr_command == "list":
306+ prs = await client.pulls.list_pull_requests(owner=owner, repo=repo, state=args.state)
307+ emit_output(prs, args, stdout, formatter=lambda items: [format_pr_summary(item) for item in items])
308+ return 0
309+ 
310+ if args.pr_command == "view":
311+ pr = await client.pulls.get_pull_request(owner=owner, repo=repo, pr_number=args.number)
312+ emit_output(pr, args, stdout, formatter=format_pr_detail)
313+ return 0
314+ 
315+ if args.pr_command == "create":
316+ pr = await client.pulls.create_pull_request(
317+ owner=owner,
318+ repo=repo,
319+ title=args.title,
320+ head=args.head,
321+ base=args.base,
322+ body=args.body,
323+ )
324+ emit_output(pr, args, stdout, formatter=format_pr_detail)
325+ return 0
326+ 
327+ if args.pr_command == "comment":
328+ comment = await client.pulls.create_pull_request_comment(
329+ owner=owner,
330+ repo=repo,
331+ pr_number=args.number,
332+ body=args.body,
333+ path=args.path,
334+ position=args.position,
335+ )
336+ emit_output(comment, args, stdout, formatter=format_comment_detail)
337+ return 0
338+ 
339+ if args.pr_command == "merge":
340+ result = await client.pulls.merge_pull_request(
341+ owner=owner,
342+ repo=repo,
343+ pr_number=args.number,
344+ commit_title=args.title,
345+ commit_message=args.body,
346+ merge_method=args.method,
347+ )
348+ emit_output(result, args, stdout, formatter=format_merge_result)
349+ return 0
350+ 
351+ raise ValueError(f"Unsupported pr command: {args.pr_command}")
352+ 
353+ 
354+async def handle_search_command(args: argparse.Namespace, client: GitCodeAPI, stdout: TextIO) -> int:
355+ query = " ".join(args.query)
356+ if args.search_command == "repos":
357+ results = await client.search.search_repositories(
358+ query=query,
359+ sort=args.sort,
360+ order=args.order,
361+ per_page=args.per_page,
362+ page=args.page,
363+ )
364+ elif args.search_command == "issues":
365+ results = await client.search.search_issues(
366+ query=query,
367+ sort=args.sort,
368+ order=args.order,
369+ per_page=args.per_page,
370+ page=args.page,
371+ )
372+ elif args.search_command == "users":
373+ results = await client.search.search_users(
374+ query=query,
375+ sort=args.sort,
376+ order=args.order,
377+ per_page=args.per_page,
378+ page=args.page,
379+ )
380+ else:
381+ raise ValueError(f"Unsupported search command: {args.search_command}")
382+ 
383+ emit_output(results, args, stdout, formatter=lambda payload: format_search_results(payload, args.search_command))
384+ return 0
385+ 
386+ 
387+async def handle_api_command(args: argparse.Namespace, client: GitCodeAPI, stdout: TextIO) -> int:
388+ fields = parse_fields(args.field)
389+ method = args.method.upper()
390+ path = args.path if args.path.startswith("/") else f"/{args.path}"
391+ request_kwargs: Dict[str, Any]
392+ if method == "GET":
393+ request_kwargs = {"params": fields}
394+ else:
395+ request_kwargs = {"data": fields}
396+ 
397+ result = await client.request(method, path, **request_kwargs)
398+ emit_output(result, args, stdout)
399+ return 0
400+ 
401+ 
402+def format_repo_list(repos: List[Dict[str, Any]]) -> List[str]:
403+ lines: List[str] = []
404+ for repo in repos:
405+ lines.extend(line for line in format_repo_summary(repo) if line)
406+ return lines or ["No repositories found."]
407+ 
408+ 
409+def format_repo_detail(repo: Dict[str, Any]) -> List[str]:
410+ lines = format_repo_summary(repo)
411+ homepage = repo.get("homepage")
412+ if homepage:
413+ lines.append(f" homepage: {homepage}")
414+ clone_url = repo.get("clone_url")
415+ if clone_url:
416+ lines.append(f" clone: {clone_url}")
417+ return [line for line in lines if line]
418+ 
419+ 
420+def format_issue_detail(issue: Dict[str, Any]) -> List[str]:
421+ body = issue.get("body") or ""
422+ return [
423+ format_issue_summary(issue),
424+ f" author: {issue.get('user', {}).get('login', 'unknown')}",
425+ f" updated: {issue.get('updated_at', 'unknown')}",
426+ f" url: {issue.get('html_url', issue.get('url', ''))}",
427+ f" {body}" if body else "",
428+ ]
429+ 
430+ 
431+def format_pr_detail(pr: Dict[str, Any]) -> List[str]:
432+ body = pr.get("body") or ""
433+ head_ref = pr.get("head", {}).get("ref", "unknown")
434+ base_ref = pr.get("base", {}).get("ref", "unknown")
435+ return [
436+ format_pr_summary(pr),
437+ f" branches: {head_ref} -> {base_ref}",
438+ f" author: {pr.get('user', {}).get('login', 'unknown')}",
439+ f" updated: {pr.get('updated_at', 'unknown')}",
440+ f" url: {pr.get('html_url', pr.get('url', ''))}",
441+ f" {body}" if body else "",
442+ ]
443+ 
444+ 
445+def format_comment_detail(comment: Dict[str, Any]) -> List[str]:
446+ return [
447+ f"Comment #{comment.get('id', 'unknown')}",
448+ f" author: {comment.get('user', {}).get('login', 'unknown')}",
449+ f" {comment.get('body', '')}",
450+ ]
451+ 
452+ 
453+def format_merge_result(result: Dict[str, Any]) -> List[str]:
454+ message = result.get("message", "Merge completed.")
455+ sha = result.get("sha")
456+ lines = [message]
457+ if sha:
458+ lines.append(f" sha: {sha}")
459+ return lines
460+ 
461+ 
462+def format_search_results(payload: Dict[str, Any], kind: str) -> List[str]:
463+ items = payload.get("items", [])
464+ total_count = payload.get("total_count")
465+ lines = [f"Found {total_count} result(s)."] if total_count is not None else []
466+ lines.extend(format_search_item(item, kind) for item in items)
467+ return lines or ["No results found."]
468+ 
469+ 
470+def main(
471+ argv: Optional[Sequence[str]] = None,
472+ client_factory: Optional[ClientFactory] = None,
473+ stdout: Optional[TextIO] = None,
474+ stderr: Optional[TextIO] = None,
475+) -> int:
476+ parser = build_parser()
477+ args = parser.parse_args(argv)
478+ client_factory = client_factory or default_client_factory
479+ stdout_stream: TextIO = stdout if stdout is not None else sys.stdout
480+ stderr_stream: TextIO = stderr if stderr is not None else sys.stderr
481+ 
482+ try:
483+ return asyncio.run(execute_command(args, client_factory, stdout_stream))
484+ except KeyboardInterrupt:
485+ stderr_stream.write("Interrupted.\n")
486+ return 130
487+ except Exception as exc:
488+ stderr_stream.write(f"Error: {exc}\n")
489+ return 1
490+ 
491+ 
492+if __name__ == "__main__":
493+ raise SystemExit(main())
@@ -0,0 +1,258 @@
1+import importlib
2+import json
3+import sys
4+from io import StringIO
5+from pathlib import Path
6+ 
7+sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
8+_CLI_MODULE = importlib.import_module("gitcode_mcp.cli")
9+main = _CLI_MODULE.main
10+ 
11+ 
12+class FakeRepos:
13+ def __init__(self):
14+ self.create_calls = []
15+ 
16+ async def list_user_repos(self, visibility=None, sort=None):
17+ return [
18+ {
19+ "full_name": "octo/demo",
20+ "private": False,
21+ "stargazers_count": 7,
22+ "forks_count": 2,
23+ "default_branch": "main",
24+ "description": "Demo repository",
25+ }
26+ ]
27+ 
28+ async def get_repo(self, owner, repo):
29+ return {
30+ "full_name": f"{owner}/{repo}",
31+ "private": False,
32+ "stargazers_count": 7,
33+ "forks_count": 2,
34+ "default_branch": "main",
35+ "description": "Demo repository",
36+ }
37+ 
38+ async def create_repo(self, name, description="", private=False):
39+ self.create_calls.append({"name": name, "description": description, "private": private})
40+ return {
41+ "full_name": f"octo/{name}",
42+ "private": private,
43+ "stargazers_count": 0,
44+ "forks_count": 0,
45+ "default_branch": "main",
46+ "description": description,
47+ }
48+ 
49+ 
50+class FakeIssues:
51+ async def list_issues(self, owner, repo, state=None):
52+ return [{"number": 12, "state": state or "open", "title": "Example issue"}]
53+ 
54+ async def get_issue(self, owner, repo, issue_number):
55+ return {
56+ "number": issue_number,
57+ "state": "open",
58+ "title": "Example issue",
59+ "user": {"login": "octocat"},
60+ "updated_at": "2026-03-17T00:00:00Z",
61+ "html_url": f"https://gitcode.com/{owner}/{repo}/issues/{issue_number}",
62+ "body": "Issue details",
63+ }
64+ 
65+ async def create_issue(self, owner, repo, title, body=""):
66+ return {
67+ "number": 13,
68+ "state": "open",
69+ "title": title,
70+ "user": {"login": "octocat"},
71+ "updated_at": "2026-03-17T00:00:00Z",
72+ "html_url": f"https://gitcode.com/{owner}/{repo}/issues/13",
73+ "body": body,
74+ }
75+ 
76+ async def add_comment(self, owner, repo, issue_number, body):
77+ return {"id": 99, "user": {"login": "octocat"}, "body": body}
78+ 
79+ 
80+class FakePulls:
81+ def __init__(self):
82+ self.create_calls = []
83+ 
84+ async def list_pull_requests(self, owner, repo, state=None):
85+ return [{"number": 21, "state": state or "open", "title": "Example PR"}]
86+ 
87+ async def get_pull_request(self, owner, repo, pr_number):
88+ return {
89+ "number": pr_number,
90+ "state": "open",
91+ "title": "Example PR",
92+ "user": {"login": "octocat"},
93+ "updated_at": "2026-03-17T00:00:00Z",
94+ "html_url": f"https://gitcode.com/{owner}/{repo}/pulls/{pr_number}",
95+ "body": "Pull request details",
96+ "head": {"ref": "feature"},
97+ "base": {"ref": "main"},
98+ }
99+ 
100+ async def create_pull_request(self, owner, repo, title, head, base, body=""):
101+ self.create_calls.append(
102+ {"owner": owner, "repo": repo, "title": title, "head": head, "base": base, "body": body}
103+ )
104+ return {
105+ "number": 22,
106+ "state": "open",
107+ "title": title,
108+ "user": {"login": "octocat"},
109+ "updated_at": "2026-03-17T00:00:00Z",
110+ "html_url": f"https://gitcode.com/{owner}/{repo}/pulls/22",
111+ "body": body,
112+ "head": {"ref": head},
113+ "base": {"ref": base},
114+ }
115+ 
116+ async def create_pull_request_comment(self, owner, repo, pr_number, body, path=None, position=None):
117+ return {"id": 100, "user": {"login": "octocat"}, "body": body, "path": path, "position": position}
118+ 
119+ async def merge_pull_request(self, owner, repo, pr_number, commit_title=None, commit_message=None, merge_method=None):
120+ return {"message": "Merged", "sha": "abc123"}
121+ 
122+ 
123+class FakeSearch:
124+ async def search_repositories(self, query, sort=None, order=None, per_page=None, page=None):
125+ return {"total_count": 1, "items": [{"full_name": f"octo/{query.replace(' ', '-')}", "name": query}]}
126+ 
127+ async def search_issues(self, query, sort=None, order=None, per_page=None, page=None):
128+ return {"total_count": 1, "items": [{"number": 1, "state": "open", "title": query}]}
129+ 
130+ async def search_users(self, query, sort=None, order=None, per_page=None, page=None):
131+ return {"total_count": 1, "items": [{"login": query.replace(' ', '-')}]}
132+ 
133+ 
134+class FakeClient:
135+ def __init__(self):
136+ self.repos = FakeRepos()
137+ self.issues = FakeIssues()
138+ self.pulls = FakePulls()
139+ self.search = FakeSearch()
140+ self.closed = False
141+ self.request_calls = []
142+ 
143+ async def request(self, method, path, **kwargs):
144+ self.request_calls.append({"method": method, "path": path, "kwargs": kwargs})
145+ return {"method": method, "path": path, "kwargs": kwargs}
146+ 
147+ async def close(self):
148+ self.closed = True
149+ 
150+ 
151+def make_factory(client):
152+ return lambda args: client
153+ 
154+ 
155+def test_repo_list_outputs_human_readable_summary():
156+ client = FakeClient()
157+ stdout = StringIO()
158+ stderr = StringIO()
159+ 
160+ exit_code = main(["repo", "list"], client_factory=make_factory(client), stdout=stdout, stderr=stderr)
161+ 
162+ assert exit_code == 0
163+ assert "octo/demo" in stdout.getvalue()
164+ assert "visibility: public" in stdout.getvalue()
165+ assert stderr.getvalue() == ""
166+ assert client.closed is True
167+ 
168+ 
169+def test_pr_create_outputs_json_payload():
170+ client = FakeClient()
171+ stdout = StringIO()
172+ stderr = StringIO()
173+ 
174+ exit_code = main(
175+ [
176+ "--json",
177+ "pr",
178+ "create",
179+ "octo/demo",
180+ "--title",
181+ "Ship CLI",
182+ "--head",
183+ "feature/cli",
184+ "--base",
185+ "main",
186+ "--body",
187+ "Adds a gh-style GitCode CLI.",
188+ ],
189+ client_factory=make_factory(client),
190+ stdout=stdout,
191+ stderr=stderr,
192+ )
193+ 
194+ payload = json.loads(stdout.getvalue())
195+ 
196+ assert exit_code == 0
197+ assert payload["title"] == "Ship CLI"
198+ assert payload["head"]["ref"] == "feature/cli"
199+ assert client.pulls.create_calls == [
200+ {
201+ "owner": "octo",
202+ "repo": "demo",
203+ "title": "Ship CLI",
204+ "head": "feature/cli",
205+ "base": "main",
206+ "body": "Adds a gh-style GitCode CLI.",
207+ }
208+ ]
209+ assert stderr.getvalue() == ""
210+ 
211+ 
212+def test_api_post_maps_fields_into_request_body():
213+ client = FakeClient()
214+ stdout = StringIO()
215+ stderr = StringIO()
216+ 
217+ exit_code = main(
218+ [
219+ "--json",
220+ "api",
221+ "POST",
222+ "/repos/octo/demo/issues",
223+ "--field",
224+ "title=Bug report",
225+ "--field",
226+ "number=3",
227+ "--field",
228+ "draft=false",
229+ ],
230+ client_factory=make_factory(client),
231+ stdout=stdout,
232+ stderr=stderr,
233+ )
234+ 
235+ payload = json.loads(stdout.getvalue())
236+ 
237+ assert exit_code == 0
238+ assert payload["kwargs"]["data"] == {"title": "Bug report", "number": 3, "draft": False}
239+ assert client.request_calls == [
240+ {
241+ "method": "POST",
242+ "path": "/repos/octo/demo/issues",
243+ "kwargs": {"data": {"title": "Bug report", "number": 3, "draft": False}},
244+ }
245+ ]
246+ assert stderr.getvalue() == ""
247+ 
248+ 
249+def test_auth_status_requires_token(monkeypatch):
250+ monkeypatch.delenv("GITCODE_TOKEN", raising=False)
251+ stdout = StringIO()
252+ stderr = StringIO()
253+ 
254+ exit_code = main(["auth", "status"], stdout=stdout, stderr=stderr)
255+ 
256+ assert exit_code == 1
257+ assert stdout.getvalue() == ""
258+ assert "GitCode token not found" in stderr.getvalue()