已开启
feat(cli): add gh-style command-line interface #5
happyaron创建于 3月17日
feat(cli): add gh-style command-line interface #5
已开启
共 4 个文件变更+765-1
| @@ -23,6 +23,7 @@ pip install -e . | |||
| 23 | 23 | ||
| 24 | ```bash | 24 | ```bash |
| 25 | gitcode-mcp | 25 | gitcode-mcp |
| 26 | +gitcode | ||
| 26 | ``` | 27 | ``` |
| 27 | 28 | ||
| 28 | ### 方法二:手动安装依赖 | 29 | ### 方法二:手动安装依赖 |
| @@ -47,6 +48,17 @@ HTTPS_PROXY=https://localhost:8080 | |||
| 47 | python -m gitcode_mcp | 48 | python -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 | + | ||
| 50 | 3. 在支持 MCP 的 IDE 中配置服务器连接 | 62 | 3. 在支持 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() | ||