bt-ghcli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.1
2
+ Name: bt-ghcli
3
+ Version: 0.1.0
4
+ Summary: CLI for interacting with the GitHub API
5
+ Author-email: Bert Tejeda <berttejeda@gmail.com>
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: requests (>=2.31.0)
8
+ Requires-Dist: httpx (>=0.26.0)
9
+ Requires-Dist: PyYAML (>=6.0.0)
10
+ Requires-Dist: typer[all] (>=0.9.0)
11
+
@@ -0,0 +1,9 @@
1
+ ghpr/__init__.py,sha256=VTv8qrt-S25ykSAmjMx1VGNR5wDxDqvptyPvscVZqoU,157
2
+ ghpr/cli.py,sha256=jZvlj5E8lLrteFKsQ1c-0RljHaFzvkw6_m3x6hRyncY,14264
3
+ ghsearch/__init__.py,sha256=-Hcgur_I6pA7pTYTMHAKiE7SvmM_B3NO40MUATJBYzs,172
4
+ ghsearch/cli.py,sha256=nrCSWBkbol74Of0B-tKw942xZGjS0pENmZzIHAy3te8,27196
5
+ bt_ghcli-0.1.0.dist-info/METADATA,sha256=Vwc9AQUTXzKFqLhblKonCsUqksvrV2KdM-8aB7JUY7E,309
6
+ bt_ghcli-0.1.0.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
7
+ bt_ghcli-0.1.0.dist-info/entry_points.txt,sha256=taK6VKNIbx5HEU-0uuQ6LoTnIzFIiCiFNI8_hfDQF90,68
8
+ bt_ghcli-0.1.0.dist-info/top_level.txt,sha256=sbViyDTXdsE1pGihb5Mi94uXv-v1GbfUD2tA18UtyBA,14
9
+ bt_ghcli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.37.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ghpr = ghpr.cli:main
3
+ ghsearch = ghsearch.cli:main
@@ -0,0 +1,2 @@
1
+ ghpr
2
+ ghsearch
ghpr/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ ghpr provides a GitHub/GitHub Enterprise Pull Request management CLI.
3
+ """
4
+ from .cli import app
5
+
6
+ __all__ = ["app", "__version__"]
7
+
8
+ __version__ = "0.1.0"
9
+
ghpr/cli.py ADDED
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import requests
10
+ import typer
11
+
12
+ app = typer.Typer(help="GitHub / GitHub Enterprise Pull Request management CLI tool")
13
+
14
+
15
+ def resolve_auth_token(cli_token: Optional[str], config_token: Optional[str]) -> Optional[str]:
16
+ """
17
+ Determine which auth token to use based on CLI, config, or environment.
18
+ """
19
+ if cli_token:
20
+ return cli_token
21
+ if config_token:
22
+ return config_token
23
+ env_token = os.environ.get("GHPR_TOKEN")
24
+ if env_token:
25
+ return env_token
26
+ env_token = os.environ.get("GHE_TOKEN")
27
+ if env_token:
28
+ return env_token
29
+ return os.environ.get("GITHUB_TOKEN")
30
+
31
+
32
+ def resolve_api_base(cli_api_base: Optional[str], config_api_base: Optional[str]) -> str:
33
+ """
34
+ Determine the API base URL using precedence: CLI > config > env > default.
35
+ """
36
+ if cli_api_base:
37
+ return cli_api_base
38
+ if config_api_base:
39
+ return config_api_base
40
+ env_base = os.environ.get("GHPR_API_BASE")
41
+ if env_base:
42
+ return env_base
43
+ env_base = os.environ.get("GHE_URL")
44
+ if env_base:
45
+ # Convert GHE_URL format to API base if needed
46
+ if not env_base.endswith("/api/v3"):
47
+ if env_base.endswith("/"):
48
+ env_base = env_base.rstrip("/")
49
+ env_base = f"{env_base}/api/v3"
50
+ return env_base
51
+ return "https://api.github.com"
52
+
53
+
54
+ def build_headers(token: Optional[str]) -> Dict[str, str]:
55
+ """
56
+ Build GitHub API headers with optional Bearer token.
57
+ """
58
+ headers: Dict[str, str] = {
59
+ "Accept": "application/vnd.github+json",
60
+ "X-GitHub-Api-Version": "2022-11-28",
61
+ "User-Agent": "ghpr-cli",
62
+ }
63
+ if token:
64
+ headers["Authorization"] = f"Bearer {token}"
65
+ return headers
66
+
67
+
68
+ def load_config(path: Optional[str]) -> Dict[str, Any]:
69
+ """
70
+ Load CLI configuration from YAML or JSON. Returns an empty dict on failure.
71
+ """
72
+ config_path = Path(path).expanduser() if path else Path.home() / ".ghpr.yml"
73
+ if not config_path.exists():
74
+ if path:
75
+ print(f"[ghpr] Config file not found: {config_path}", file=sys.stderr)
76
+ return {}
77
+ data = config_path.read_text(encoding="utf-8")
78
+ if not data.strip():
79
+ return {}
80
+ try:
81
+ import yaml
82
+ parsed = yaml.safe_load(data)
83
+ except Exception:
84
+ try:
85
+ parsed = json.loads(data)
86
+ except Exception:
87
+ print(f"[ghpr] Failed to parse config file: {config_path}", file=sys.stderr)
88
+ return {}
89
+ if parsed is None:
90
+ return {}
91
+ if isinstance(parsed, dict):
92
+ return parsed
93
+ return {}
94
+
95
+
96
+ def merge_config_cli(
97
+ config: Dict[str, Any],
98
+ *,
99
+ cli_api_base: Optional[str],
100
+ cli_token: Optional[str],
101
+ cli_owner: Optional[str],
102
+ cli_repo: Optional[str],
103
+ cli_proxy: Optional[str],
104
+ cli_verify_tls: Optional[bool],
105
+ ) -> Dict[str, Any]:
106
+ """
107
+ Merge CLI options with config file values.
108
+ """
109
+ subcfg = config.get("ghpr", {}) if isinstance(config.get("ghpr"), dict) else {}
110
+ merged: Dict[str, Any] = {}
111
+ merged["api_base"] = resolve_api_base(cli_api_base, subcfg.get("api_base"))
112
+ merged["token"] = resolve_auth_token(cli_token, subcfg.get("token"))
113
+ merged["owner"] = cli_owner or subcfg.get("owner") or os.environ.get("GHE_PROJECT")
114
+ merged["repo"] = cli_repo or subcfg.get("repo") or os.environ.get("GHE_REPO_NAME")
115
+ merged["proxy"] = cli_proxy or subcfg.get("proxy")
116
+ merged["verify_tls"] = cli_verify_tls if cli_verify_tls is not None else subcfg.get("verify_tls", True)
117
+ return merged
118
+
119
+
120
+ def get_proxies(proxy: Optional[str]) -> Dict[str, str]:
121
+ """
122
+ Build proxies dict for requests.
123
+ """
124
+ if proxy:
125
+ return {"http": proxy, "https": proxy}
126
+ return {}
127
+
128
+
129
+ def make_request(
130
+ method: str,
131
+ url: str,
132
+ headers: Dict[str, str],
133
+ json_data: Optional[Dict[str, Any]] = None,
134
+ proxies: Optional[Dict[str, str]] = None,
135
+ verify: bool = True,
136
+ ) -> requests.Response:
137
+ """
138
+ Make an HTTP request with error handling.
139
+ """
140
+ proxies = proxies or {}
141
+ response = None
142
+ try:
143
+ response = requests.request(
144
+ method=method,
145
+ url=url,
146
+ headers=headers,
147
+ json=json_data,
148
+ proxies=proxies,
149
+ verify=verify,
150
+ )
151
+ response.raise_for_status()
152
+ return response
153
+ except requests.exceptions.ProxyError as e:
154
+ print(f"[ghpr] Error: Could not connect to proxy. Ensure PySocks is installed and the proxy is running.", file=sys.stderr)
155
+ print(f"[ghpr] Details: {e}", file=sys.stderr)
156
+ raise typer.Exit(code=1)
157
+ except requests.exceptions.ConnectionError as e:
158
+ print(f"[ghpr] Error: Could not connect to the GitHub API at {url}.", file=sys.stderr)
159
+ print(f"[ghpr] Details: {e}", file=sys.stderr)
160
+ raise typer.Exit(code=1)
161
+ except requests.exceptions.HTTPError as e:
162
+ if response is not None:
163
+ print(f"[ghpr] Error: HTTP request failed with status code {response.status_code}.", file=sys.stderr)
164
+ print(f"[ghpr] Response: {response.text[:500]}", file=sys.stderr)
165
+ else:
166
+ print(f"[ghpr] Error: HTTP request failed.", file=sys.stderr)
167
+ print(f"[ghpr] Details: {e}", file=sys.stderr)
168
+ raise typer.Exit(code=1)
169
+ except requests.exceptions.RequestException as e:
170
+ print(f"[ghpr] An unexpected error occurred during the request: {e}", file=sys.stderr)
171
+ raise typer.Exit(code=1)
172
+
173
+
174
+ @app.command(
175
+ "create",
176
+ help="Create a new Pull Request. Examples:\n\n"
177
+ " # Create a PR with title and body\n"
178
+ " ghpr create --title 'Fix bug' --body 'This fixes the issue' --head feature-branch --base main\n\n"
179
+ " # Create a draft PR\n"
180
+ " ghpr create --title 'WIP: Feature' --head feature --base main --draft\n\n"
181
+ " # Create PR with labels\n"
182
+ " ghpr create --title 'Feature' --head feature --base main --label bug --label enhancement",
183
+ )
184
+ def create_command(
185
+ config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"),
186
+ api_base: Optional[str] = typer.Option(None, "--api-base", help="Override API base URL"),
187
+ token: Optional[str] = typer.Option(None, "--token", "-t", help="GitHub token for authentication"),
188
+ owner: Optional[str] = typer.Option(None, "--owner", "-o", help="Repository owner/organization"),
189
+ repo: Optional[str] = typer.Option(None, "--repo", "-r", help="Repository name"),
190
+ title: str = typer.Option(..., "--title", help="PR title"),
191
+ body: Optional[str] = typer.Option(None, "--body", "-b", help="PR body/description"),
192
+ head: str = typer.Option(..., "--head", help="Branch to merge from"),
193
+ base: str = typer.Option("main", "--base", help="Branch to merge into"),
194
+ draft: bool = typer.Option(False, "--draft", help="Create as draft PR"),
195
+ label: Optional[List[str]] = typer.Option(None, "--label", help="Labels to add (can be used multiple times)"),
196
+ proxy: Optional[str] = typer.Option(None, "--proxy", "-x", help="SOCKS5h proxy address"),
197
+ verify_tls: bool = typer.Option(True, "--verify-tls/--no-verify-tls", help="Enable/disable TLS verification"),
198
+ ) -> None:
199
+ cfg = load_config(config)
200
+ merged = merge_config_cli(
201
+ cfg,
202
+ cli_api_base=api_base,
203
+ cli_token=token,
204
+ cli_owner=owner,
205
+ cli_repo=repo,
206
+ cli_proxy=proxy,
207
+ cli_verify_tls=verify_tls,
208
+ )
209
+
210
+ if not merged["owner"] or not merged["repo"]:
211
+ typer.echo("[ghpr] Error: --owner and --repo are required", err=True)
212
+ raise typer.Exit(code=1)
213
+
214
+ if not merged["token"]:
215
+ typer.echo("[ghpr] Error: --token is required", err=True)
216
+ raise typer.Exit(code=1)
217
+
218
+ api_url = f"{merged['api_base'].rstrip('/')}/repos/{merged['owner']}/{merged['repo']}/pulls"
219
+ headers = build_headers(merged["token"])
220
+ proxies = get_proxies(merged["proxy"])
221
+
222
+ payload: Dict[str, Any] = {
223
+ "title": title,
224
+ "head": head,
225
+ "base": base,
226
+ }
227
+ if body:
228
+ payload["body"] = body
229
+ if draft:
230
+ payload["draft"] = True
231
+ if label:
232
+ payload["labels"] = label
233
+
234
+ typer.echo(f"Creating PR: {title} ({head} -> {base})")
235
+ response = make_request("POST", api_url, headers, json_data=payload, proxies=proxies, verify=merged["verify_tls"])
236
+
237
+ result = response.json()
238
+ typer.echo(f"✓ Pull Request created successfully!")
239
+ typer.echo(f" PR #{result.get('number')}: {result.get('html_url')}")
240
+ typer.echo(f" State: {result.get('state')}")
241
+ if result.get("draft"):
242
+ typer.echo(f" Draft: Yes")
243
+
244
+
245
+ @app.command(
246
+ "approve",
247
+ help="Approve a Pull Request. Examples:\n\n"
248
+ " # Approve a PR\n"
249
+ " ghpr approve --pr-number 123\n\n"
250
+ " # Approve with a comment\n"
251
+ " ghpr approve --pr-number 123 --comment 'Looks good!'",
252
+ )
253
+ def approve_command(
254
+ config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"),
255
+ api_base: Optional[str] = typer.Option(None, "--api-base", help="Override API base URL"),
256
+ token: Optional[str] = typer.Option(None, "--token", "-t", help="GitHub token for authentication"),
257
+ owner: Optional[str] = typer.Option(None, "--owner", "-o", help="Repository owner/organization"),
258
+ repo: Optional[str] = typer.Option(None, "--repo", "-r", help="Repository name"),
259
+ pr_number: int = typer.Option(..., "--pr-number", "-pr", help="Pull Request number"),
260
+ comment: Optional[str] = typer.Option(None, "--comment", help="Optional approval comment"),
261
+ proxy: Optional[str] = typer.Option(None, "--proxy", "-x", help="SOCKS5h proxy address"),
262
+ verify_tls: bool = typer.Option(True, "--verify-tls/--no-verify-tls", help="Enable/disable TLS verification"),
263
+ ) -> None:
264
+ cfg = load_config(config)
265
+ merged = merge_config_cli(
266
+ cfg,
267
+ cli_api_base=api_base,
268
+ cli_token=token,
269
+ cli_owner=owner,
270
+ cli_repo=repo,
271
+ cli_proxy=proxy,
272
+ cli_verify_tls=verify_tls,
273
+ )
274
+
275
+ if not merged["owner"] or not merged["repo"]:
276
+ typer.echo("[ghpr] Error: --owner and --repo are required", err=True)
277
+ raise typer.Exit(code=1)
278
+
279
+ if not merged["token"]:
280
+ typer.echo("[ghpr] Error: --token is required", err=True)
281
+ raise typer.Exit(code=1)
282
+
283
+ api_url = f"{merged['api_base'].rstrip('/')}/repos/{merged['owner']}/{merged['repo']}/pulls/{pr_number}/reviews"
284
+ headers = build_headers(merged["token"])
285
+ proxies = get_proxies(merged["proxy"])
286
+
287
+ payload: Dict[str, Any] = {"event": "APPROVE"}
288
+ if comment:
289
+ payload["body"] = comment
290
+
291
+ typer.echo(f"Approving PR #{pr_number} in {merged['owner']}/{merged['repo']}...")
292
+ response = make_request("POST", api_url, headers, json_data=payload, proxies=proxies, verify=merged["verify_tls"])
293
+
294
+ result = response.json()
295
+ typer.echo(f"✓ Pull Request approved successfully!")
296
+ typer.echo(f" Review ID: {result.get('id')}")
297
+ typer.echo(f" Review URL: {result.get('html_url')}")
298
+ typer.echo(f" State: {result.get('state')}")
299
+
300
+
301
+ @app.command(
302
+ "comment",
303
+ help="Add a comment to a Pull Request. Examples:\n\n"
304
+ " # Add a review comment\n"
305
+ " ghpr comment --pr-number 123 --comment 'Great work!' --type review\n\n"
306
+ " # Add a conversational comment\n"
307
+ " ghpr comment --pr-number 123 --comment 'Thanks for the PR!' --type issue",
308
+ )
309
+ def comment_command(
310
+ config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"),
311
+ api_base: Optional[str] = typer.Option(None, "--api-base", help="Override API base URL"),
312
+ token: Optional[str] = typer.Option(None, "--token", "-t", help="GitHub token for authentication"),
313
+ owner: Optional[str] = typer.Option(None, "--owner", "-o", help="Repository owner/organization"),
314
+ repo: Optional[str] = typer.Option(None, "--repo", "-r", help="Repository name"),
315
+ pr_number: int = typer.Option(..., "--pr-number", "-pr", help="Pull Request number"),
316
+ comment: str = typer.Option(..., "--comment", help="Comment text"),
317
+ comment_type: str = typer.Option("review", "--type", help="Comment type: 'review' or 'issue'"),
318
+ proxy: Optional[str] = typer.Option(None, "--proxy", "-x", help="SOCKS5h proxy address"),
319
+ verify_tls: bool = typer.Option(True, "--verify-tls/--no-verify-tls", help="Enable/disable TLS verification"),
320
+ ) -> None:
321
+ cfg = load_config(config)
322
+ merged = merge_config_cli(
323
+ cfg,
324
+ cli_api_base=api_base,
325
+ cli_token=token,
326
+ cli_owner=owner,
327
+ cli_repo=repo,
328
+ cli_proxy=proxy,
329
+ cli_verify_tls=verify_tls,
330
+ )
331
+
332
+ if not merged["owner"] or not merged["repo"]:
333
+ typer.echo("[ghpr] Error: --owner and --repo are required", err=True)
334
+ raise typer.Exit(code=1)
335
+
336
+ if not merged["token"]:
337
+ typer.echo("[ghpr] Error: --token is required", err=True)
338
+ raise typer.Exit(code=1)
339
+
340
+ if comment_type not in ["review", "issue"]:
341
+ typer.echo("[ghpr] Error: --type must be 'review' or 'issue'", err=True)
342
+ raise typer.Exit(code=1)
343
+
344
+ headers = build_headers(merged["token"])
345
+ proxies = get_proxies(merged["proxy"])
346
+
347
+ if comment_type == "review":
348
+ api_url = f"{merged['api_base'].rstrip('/')}/repos/{merged['owner']}/{merged['repo']}/pulls/{pr_number}/reviews"
349
+ payload = {"body": comment, "event": "COMMENT"}
350
+ comment_label = "review comment"
351
+ else:
352
+ api_url = f"{merged['api_base'].rstrip('/')}/repos/{merged['owner']}/{merged['repo']}/issues/{pr_number}/comments"
353
+ payload = {"body": comment}
354
+ comment_label = "comment"
355
+
356
+ typer.echo(f"Adding {comment_label} to PR #{pr_number} in {merged['owner']}/{merged['repo']}...")
357
+ response = make_request("POST", api_url, headers, json_data=payload, proxies=proxies, verify=merged["verify_tls"])
358
+
359
+ result = response.json()
360
+ typer.echo(f"✓ {comment_label.capitalize()} added successfully!")
361
+ typer.echo(f" ID: {result.get('id')}")
362
+ typer.echo(f" URL: {result.get('html_url')}")
363
+
364
+
365
+ def main() -> None:
366
+ app()
367
+
ghsearch/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ ghsearch provides a GitHub/GitHub Enterprise search CLI with repo and code reports.
3
+ """
4
+ from .cli import app
5
+
6
+ __all__ = ["app", "__version__"]
7
+
8
+ __version__ = "0.1.0"
9
+
10
+
ghsearch/cli.py ADDED
@@ -0,0 +1,740 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ import httpx
11
+ import requests
12
+ import typer
13
+ import yaml
14
+
15
+ app = typer.Typer(help="GitHub / GitHub Enterprise search CLI tool")
16
+
17
+
18
+ def resolve_auth_token(cli_token: Optional[str], config_token: Optional[str]) -> Optional[str]:
19
+ """
20
+ Determine which auth token to use based on CLI, config, or environment.
21
+ """
22
+ if cli_token:
23
+ return cli_token
24
+ if config_token:
25
+ return config_token
26
+ env_token = os.environ.get("GHSEARCH_TOKEN")
27
+ if env_token:
28
+ return env_token
29
+ return os.environ.get("GITHUB_TOKEN")
30
+
31
+
32
+ def resolve_api_base(cli_api_base: Optional[str], config_api_base: Optional[str]) -> str:
33
+ """
34
+ Determine the API base URL using precedence: CLI > config > env > default.
35
+ """
36
+ if cli_api_base:
37
+ return cli_api_base
38
+ if config_api_base:
39
+ return config_api_base
40
+ env_base = os.environ.get("GHSEARCH_API_BASE")
41
+ if env_base:
42
+ return env_base
43
+ return "https://api.github.com"
44
+
45
+
46
+ def build_headers(token: Optional[str]) -> Dict[str, str]:
47
+ """
48
+ Build GitHub API headers with optional Bearer token.
49
+ Includes media types for topics (mercy-preview) and commits (cloak-preview).
50
+ """
51
+ headers: Dict[str, str] = {
52
+ "Accept": "application/vnd.github.mercy-preview+json, application/vnd.github.cloak-preview+json",
53
+ "X-GitHub-Api-Version": "2022-11-28",
54
+ "User-Agent": "ghsearch-cli",
55
+ }
56
+ if token:
57
+ headers["Authorization"] = f"Bearer {token}"
58
+ return headers
59
+
60
+
61
+ def load_config(path: Optional[str]) -> Dict[str, Any]:
62
+ """
63
+ Load CLI configuration from YAML or JSON. Returns an empty dict on failure.
64
+ """
65
+ config_path = Path(path).expanduser() if path else Path.home() / ".ghsearch.yml"
66
+ if not config_path.exists():
67
+ if path:
68
+ print(f"[ghsearch] Config file not found: {config_path}", file=sys.stderr)
69
+ return {}
70
+ data = config_path.read_text(encoding="utf-8")
71
+ if not data.strip():
72
+ return {}
73
+ for loader in (yaml.safe_load, json.loads):
74
+ try:
75
+ parsed = loader(data)
76
+ except Exception:
77
+ continue
78
+ if parsed is None:
79
+ return {}
80
+ if isinstance(parsed, dict):
81
+ return parsed
82
+ break
83
+ print(f"[ghsearch] Failed to parse config file: {config_path}", file=sys.stderr)
84
+ return {}
85
+
86
+
87
+ def merge_repos_config_cli(
88
+ config: Dict[str, Any],
89
+ *,
90
+ cli_api_base: Optional[str],
91
+ cli_token: Optional[str],
92
+ query: Optional[str],
93
+ per_page: Optional[int],
94
+ max_pages: Optional[int],
95
+ min_stars: Optional[int],
96
+ language: Optional[str],
97
+ sort_by: Optional[str],
98
+ sort_direction: Optional[str],
99
+ group_by_language: Optional[bool],
100
+ top_n: Optional[int],
101
+ ) -> Dict[str, Any]:
102
+ subcfg = config.get("repos", {}) if isinstance(config.get("repos"), dict) else {}
103
+ merged: Dict[str, Any] = {}
104
+ merged["api_base"] = resolve_api_base(cli_api_base, subcfg.get("api_base"))
105
+ merged["token"] = resolve_auth_token(cli_token, subcfg.get("token"))
106
+ merged["query"] = query or subcfg.get("query") or "topic:astro topic:template"
107
+ merged["per_page"] = per_page or subcfg.get("per_page") or 50
108
+ merged["max_pages"] = max_pages or subcfg.get("max_pages") or 3
109
+ merged["min_stars"] = min_stars if min_stars is not None else subcfg.get("min_stars")
110
+ merged["language"] = language or subcfg.get("language")
111
+ merged["sort_by"] = sort_by or subcfg.get("sort_by")
112
+ merged["sort_direction"] = sort_direction or subcfg.get("sort_direction") or "desc"
113
+ merged["group_by_language"] = (
114
+ group_by_language if group_by_language is not None else subcfg.get("group_by_language", False)
115
+ )
116
+ merged["top_n"] = top_n if top_n is not None else subcfg.get("top_n")
117
+ return merged
118
+
119
+
120
+ def merge_code_config_cli(
121
+ config: Dict[str, Any],
122
+ *,
123
+ cli_api_base: Optional[str],
124
+ cli_token: Optional[str],
125
+ query: Optional[str],
126
+ per_page: Optional[int],
127
+ max_pages: Optional[int],
128
+ repo: Optional[str],
129
+ language: Optional[str],
130
+ path: Optional[str],
131
+ ) -> Dict[str, Any]:
132
+ subcfg = config.get("code", {}) if isinstance(config.get("code"), dict) else {}
133
+ merged: Dict[str, Any] = {}
134
+ merged["api_base"] = resolve_api_base(cli_api_base, subcfg.get("api_base"))
135
+ merged["token"] = resolve_auth_token(cli_token, subcfg.get("token"))
136
+ merged["query"] = query or subcfg.get("query") or "test"
137
+ merged["per_page"] = per_page or subcfg.get("per_page") or 50
138
+ merged["max_pages"] = max_pages or subcfg.get("max_pages") or 3
139
+ merged["repo"] = repo or subcfg.get("repo")
140
+ merged["language"] = language or subcfg.get("language")
141
+ merged["path"] = path or subcfg.get("path")
142
+ return merged
143
+
144
+
145
+ def merge_commits_config_cli(
146
+ config: Dict[str, Any],
147
+ *,
148
+ cli_api_base: Optional[str],
149
+ cli_token: Optional[str],
150
+ query: Optional[str],
151
+ per_page: Optional[int],
152
+ max_pages: Optional[int],
153
+ repo: Optional[str],
154
+ author: Optional[str],
155
+ committer: Optional[str],
156
+ stats: Optional[bool],
157
+ ) -> Dict[str, Any]:
158
+ subcfg = config.get("commits", {}) if isinstance(config.get("commits"), dict) else {}
159
+ merged: Dict[str, Any] = {}
160
+ merged["api_base"] = resolve_api_base(cli_api_base, subcfg.get("api_base"))
161
+ merged["token"] = resolve_auth_token(cli_token, subcfg.get("token"))
162
+ merged["query"] = query or subcfg.get("query") or "fix"
163
+ merged["per_page"] = per_page or subcfg.get("per_page") or 50
164
+ merged["max_pages"] = max_pages or subcfg.get("max_pages") or 3
165
+ merged["repo"] = repo or subcfg.get("repo")
166
+ merged["author"] = author or subcfg.get("author")
167
+ merged["committer"] = committer or subcfg.get("committer")
168
+ merged["stats"] = stats if stats is not None else subcfg.get("stats", False)
169
+ return merged
170
+
171
+
172
+ def search_repositories(
173
+ api_base: str,
174
+ headers: Dict[str, str],
175
+ query: str,
176
+ per_page: int = 50,
177
+ max_pages: int = 3,
178
+ timeout: int = 10,
179
+ ) -> Dict[str, Any]:
180
+ url = api_base.rstrip("/") + "/search/repositories"
181
+ items: List[Dict[str, Any]] = []
182
+ total_count = None
183
+ incomplete_results = False
184
+ session = requests.Session()
185
+ for page in range(1, max_pages + 1):
186
+ params = {"q": query, "per_page": per_page, "page": page}
187
+ try:
188
+ resp = session.get(url, headers=headers, params=params, timeout=timeout)
189
+ except requests.RequestException as exc:
190
+ print(f"[ghsearch] Request error: {exc}", file=sys.stderr)
191
+ break
192
+ if resp.status_code >= 400:
193
+ body = resp.text[:500]
194
+ print(f"[ghsearch] HTTP {resp.status_code} error: {body}", file=sys.stderr)
195
+ break
196
+ payload = resp.json()
197
+ if total_count is None:
198
+ total_count = payload.get("total_count")
199
+ incomplete_results = bool(payload.get("incomplete_results"))
200
+ page_items = payload.get("items") or []
201
+ if not page_items:
202
+ break
203
+ items.extend(page_items)
204
+ if len(items) >= 1000 or "next" not in resp.links:
205
+ break
206
+ session.close()
207
+ return {
208
+ "query": query,
209
+ "total_count": total_count if total_count is not None else len(items),
210
+ "incomplete_results": incomplete_results,
211
+ "items": items[:1000],
212
+ }
213
+
214
+
215
+ def simplify_repos(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
216
+ simplified: List[Dict[str, Any]] = []
217
+ for item in items:
218
+ license_info = item.get("license")
219
+ if isinstance(license_info, dict):
220
+ license_entry = {
221
+ "key": license_info.get("key"),
222
+ "name": license_info.get("name"),
223
+ "spdx_id": license_info.get("spdx_id"),
224
+ }
225
+ else:
226
+ license_entry = None
227
+ simplified.append(
228
+ {
229
+ "full_name": item.get("full_name"),
230
+ "html_url": item.get("html_url"),
231
+ "description": item.get("description"),
232
+ "stars": item.get("stargazers_count", 0),
233
+ "watchers": item.get("watchers_count", 0),
234
+ "forks": item.get("forks_count", 0),
235
+ "language": item.get("language"),
236
+ "archived": item.get("archived", False),
237
+ "fork": item.get("fork", False),
238
+ "topics": item.get("topics") or [],
239
+ "license": license_entry,
240
+ "default_branch": item.get("default_branch"),
241
+ "pushed_at": item.get("pushed_at"),
242
+ "updated_at": item.get("updated_at"),
243
+ "created_at": item.get("created_at"),
244
+ "score": item.get("score"),
245
+ }
246
+ )
247
+ return simplified
248
+
249
+
250
+ def apply_filters(
251
+ repos: List[Dict[str, Any]],
252
+ min_stars: Optional[int] = None,
253
+ language: Optional[str] = None,
254
+ ) -> List[Dict[str, Any]]:
255
+ filtered = repos
256
+ if min_stars is not None:
257
+ filtered = [r for r in filtered if (r.get("stars") or 0) >= min_stars]
258
+ if language:
259
+ lang_lower = language.lower()
260
+ filtered = [r for r in filtered if (r.get("language") or "").lower() == lang_lower]
261
+ return filtered
262
+
263
+
264
+ def apply_sorting(
265
+ repos: List[Dict[str, Any]],
266
+ sort_by: Optional[str] = None,
267
+ sort_direction: str = "desc",
268
+ ) -> List[Dict[str, Any]]:
269
+ reverse = sort_direction.lower() != "asc"
270
+ if sort_by not in {None, "stars", "forks", "updated", "created"}:
271
+ return repos
272
+ if sort_by == "stars":
273
+ key_fn = lambda r: r.get("stars") or 0
274
+ elif sort_by == "forks":
275
+ key_fn = lambda r: r.get("forks") or 0
276
+ elif sort_by == "updated":
277
+ key_fn = lambda r: r.get("updated_at") or ""
278
+ elif sort_by == "created":
279
+ key_fn = lambda r: r.get("created_at") or ""
280
+ else:
281
+ return repos if not reverse else list(reversed(repos))
282
+ return sorted(repos, key=key_fn, reverse=reverse)
283
+
284
+
285
+ def group_by_language(repos: List[Dict[str, Any]]) -> Dict[Optional[str], List[Dict[str, Any]]]:
286
+ groups: Dict[Optional[str], List[Dict[str, Any]]] = {}
287
+ for repo in repos:
288
+ lang = repo.get("language")
289
+ groups.setdefault(lang, []).append(repo)
290
+ return groups
291
+
292
+
293
+ async def search_code_async(
294
+ api_base: str,
295
+ headers: Dict[str, str],
296
+ query: str,
297
+ per_page: int = 50,
298
+ max_pages: int = 3,
299
+ timeout: int = 10,
300
+ repo: Optional[str] = None,
301
+ language: Optional[str] = None,
302
+ path: Optional[str] = None,
303
+ ) -> Dict[str, Any]:
304
+ url = api_base.rstrip("/") + "/search/code"
305
+ final_query = query
306
+ if repo:
307
+ final_query += f" repo:{repo}"
308
+ if language:
309
+ final_query += f" language:{language}"
310
+ if path:
311
+ final_query += f" path:{path}"
312
+ items: List[Dict[str, Any]] = []
313
+ total_count = None
314
+ incomplete_results = False
315
+ async with httpx.AsyncClient(timeout=timeout) as client:
316
+ for page in range(1, max_pages + 1):
317
+ params = {"q": final_query, "per_page": per_page, "page": page}
318
+ try:
319
+ resp = await client.get(url, headers=headers, params=params)
320
+ except httpx.HTTPError as exc:
321
+ print(f"[ghsearch] HTTPX error: {exc}", file=sys.stderr)
322
+ break
323
+ if resp.status_code >= 400:
324
+ body = resp.text[:500]
325
+ print(f"[ghsearch] HTTP {resp.status_code} error: {body}", file=sys.stderr)
326
+ break
327
+ payload = resp.json()
328
+ if total_count is None:
329
+ total_count = payload.get("total_count")
330
+ incomplete_results = bool(payload.get("incomplete_results"))
331
+ page_items = payload.get("items") or []
332
+ if not page_items:
333
+ break
334
+ items.extend(page_items)
335
+ if len(items) >= 1000 or "next" not in resp.links:
336
+ break
337
+ return {
338
+ "query": final_query,
339
+ "total_count": total_count if total_count is not None else len(items),
340
+ "incomplete_results": incomplete_results,
341
+ "items": items[:1000],
342
+ }
343
+
344
+
345
+ async def search_commits_async(
346
+ api_base: str,
347
+ headers: Dict[str, str],
348
+ query: str,
349
+ per_page: int = 50,
350
+ max_pages: int = 3,
351
+ timeout: int = 10,
352
+ repo: Optional[str] = None,
353
+ author: Optional[str] = None,
354
+ committer: Optional[str] = None,
355
+ ) -> Dict[str, Any]:
356
+ url = api_base.rstrip("/") + "/search/commits"
357
+ final_query = query
358
+ if repo:
359
+ final_query += f" repo:{repo}"
360
+ if author:
361
+ final_query += f" author:{author}"
362
+ if committer:
363
+ final_query += f" committer:{committer}"
364
+ items: List[Dict[str, Any]] = []
365
+ total_count = None
366
+ incomplete_results = False
367
+ async with httpx.AsyncClient(timeout=timeout) as client:
368
+ for page in range(1, max_pages + 1):
369
+ params = {"q": final_query, "per_page": per_page, "page": page}
370
+ try:
371
+ resp = await client.get(url, headers=headers, params=params)
372
+ except httpx.HTTPError as exc:
373
+ print(f"[ghsearch] HTTPX error: {exc}", file=sys.stderr)
374
+ break
375
+ if resp.status_code >= 400:
376
+ body = resp.text[:500]
377
+ print(f"[ghsearch] HTTP {resp.status_code} error: {body}", file=sys.stderr)
378
+ break
379
+ payload = resp.json()
380
+ if total_count is None:
381
+ total_count = payload.get("total_count")
382
+ incomplete_results = bool(payload.get("incomplete_results"))
383
+ page_items = payload.get("items") or []
384
+ if not page_items:
385
+ break
386
+ items.extend(page_items)
387
+ if len(items) >= 1000 or "next" not in resp.links:
388
+ break
389
+ return {
390
+ "query": final_query,
391
+ "total_count": total_count if total_count is not None else len(items),
392
+ "incomplete_results": incomplete_results,
393
+ "items": items[:1000],
394
+ }
395
+
396
+
397
+ def simplify_code_results(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
398
+ simplified: List[Dict[str, Any]] = []
399
+ for item in items:
400
+ repo = item.get("repository") or {}
401
+ simplified.append(
402
+ {
403
+ "name": item.get("name"),
404
+ "path": item.get("path"),
405
+ "sha": item.get("sha"),
406
+ "html_url": item.get("html_url"),
407
+ "repository_full_name": repo.get("full_name"),
408
+ "repository_html_url": repo.get("html_url"),
409
+ }
410
+ )
411
+ return simplified
412
+
413
+
414
+ def simplify_commits_results(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
415
+ simplified: List[Dict[str, Any]] = []
416
+ for item in items:
417
+ commit = item.get("commit") or {}
418
+ author = commit.get("author") or {}
419
+ committer = commit.get("committer") or {}
420
+ repo = item.get("repository") or {}
421
+ simplified.append(
422
+ {
423
+ "sha": item.get("sha"),
424
+ "html_url": item.get("html_url"),
425
+ "url": item.get("url"),
426
+ "message": commit.get("message", "").split("\n")[0] if commit.get("message") else None,
427
+ "author_name": author.get("name"),
428
+ "author_email": author.get("email"),
429
+ "author_date": author.get("date"),
430
+ "committer_name": committer.get("name"),
431
+ "committer_email": committer.get("email"),
432
+ "committer_date": committer.get("date"),
433
+ "repository_full_name": repo.get("full_name"),
434
+ "repository_html_url": repo.get("html_url"),
435
+ "score": item.get("score"),
436
+ }
437
+ )
438
+ return simplified
439
+
440
+
441
+ def build_repos_report(
442
+ raw: Dict[str, Any],
443
+ repos: List[Dict[str, Any]],
444
+ group_by_lang: bool,
445
+ top_n: Optional[int],
446
+ min_stars: Optional[int],
447
+ language: Optional[str],
448
+ sort_by: Optional[str],
449
+ sort_direction: str,
450
+ api_base: str,
451
+ ) -> Dict[str, Any]:
452
+ processed = repos
453
+ if top_n and top_n > 0:
454
+ processed = processed[:top_n]
455
+ report: Dict[str, Any] = {
456
+ "query": raw["query"],
457
+ "api_base": api_base,
458
+ "total_count": raw.get("total_count"),
459
+ "incomplete_results": raw.get("incomplete_results"),
460
+ "returned": len(processed),
461
+ "filters": {"min_stars": min_stars, "language": language},
462
+ "sorting": {"sort_by": sort_by, "sort_direction": sort_direction},
463
+ }
464
+ if group_by_lang:
465
+ report["group_by"] = "language"
466
+ report["groups"] = group_by_language(processed)
467
+ else:
468
+ report["repositories"] = processed
469
+ return report
470
+
471
+
472
+ def build_code_report(
473
+ raw: Dict[str, Any],
474
+ items: List[Dict[str, Any]],
475
+ api_base: str,
476
+ repo: Optional[str],
477
+ language: Optional[str],
478
+ path: Optional[str],
479
+ ) -> Dict[str, Any]:
480
+ return {
481
+ "query": raw["query"],
482
+ "api_base": api_base,
483
+ "total_count": raw.get("total_count"),
484
+ "incomplete_results": raw.get("incomplete_results"),
485
+ "returned": len(items),
486
+ "filters": {"repo": repo, "language": language, "path": path},
487
+ "results": items,
488
+ }
489
+
490
+
491
+ def aggregate_commits_by_repo(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
492
+ """
493
+ Aggregate commits by repository and calculate statistics.
494
+ """
495
+ repo_stats: Dict[str, Dict[str, Any]] = {}
496
+ for item in items:
497
+ repo_name = item.get("repository_full_name")
498
+ repo_url = item.get("repository_html_url")
499
+ if not repo_name:
500
+ continue
501
+ if repo_name not in repo_stats:
502
+ repo_stats[repo_name] = {
503
+ "repository_full_name": repo_name,
504
+ "repository_html_url": repo_url,
505
+ "total_number_of_commits": 0,
506
+ }
507
+ repo_stats[repo_name]["total_number_of_commits"] += 1
508
+ return list(repo_stats.values())
509
+
510
+
511
+ def build_commits_report(
512
+ raw: Dict[str, Any],
513
+ items: List[Dict[str, Any]],
514
+ api_base: str,
515
+ repo: Optional[str],
516
+ author: Optional[str],
517
+ committer: Optional[str],
518
+ stats: bool = False,
519
+ ) -> Dict[str, Any]:
520
+ report: Dict[str, Any] = {
521
+ "query": raw["query"],
522
+ "api_base": api_base,
523
+ "total_count": raw.get("total_count"),
524
+ "incomplete_results": raw.get("incomplete_results"),
525
+ "returned": len(items),
526
+ "filters": {"repo": repo, "author": author, "committer": committer},
527
+ }
528
+ if stats:
529
+ report["repositories"] = aggregate_commits_by_repo(items)
530
+ else:
531
+ report["commits"] = items
532
+ return report
533
+
534
+
535
+ def validate_sort_options(sort_by: Optional[str], sort_direction: str) -> Tuple[bool, str]:
536
+ valid_sort_by = {None, "stars", "forks", "updated", "created"}
537
+ if sort_by not in valid_sort_by:
538
+ return False, "sort-by must be one of: stars, forks, updated, created"
539
+ if sort_direction.lower() not in {"asc", "desc"}:
540
+ return False, "sort-direction must be 'asc' or 'desc'"
541
+ return True, ""
542
+
543
+
544
+ @app.command(
545
+ "repos",
546
+ help="Search GitHub repositories. Examples:\n\n"
547
+ " # Search for repositories with topics\n"
548
+ " ghsearch repos --query 'topic:astro topic:template'\n\n"
549
+ " # Find Python repos with minimum stars\n"
550
+ " ghsearch repos --query 'language:python' --min-stars 100 --sort-by stars\n\n"
551
+ " # Search with grouping by language\n"
552
+ " ghsearch repos --query 'topic:cli' --group-by-language --top-n 20\n\n"
553
+ " # Use GitHub Enterprise\n"
554
+ " ghsearch repos --api-base 'https://github.company.com/api/v3' --query 'org:myorg'\n\n"
555
+ " # Search with config file\n"
556
+ " ghsearch repos --config ~/.ghsearch.yml",
557
+ )
558
+ def repos_command(
559
+ config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"),
560
+ api_base: Optional[str] = typer.Option(None, "--api-base", help="Override API base URL"),
561
+ token: Optional[str] = typer.Option(None, "--token", help="GitHub token for authentication"),
562
+ query: Optional[str] = typer.Option(None, "--query", "-q", help="Search query"),
563
+ per_page: Optional[int] = typer.Option(None, "--per-page", help="Results per page (max 100)"),
564
+ max_pages: Optional[int] = typer.Option(None, "--max-pages", help="Maximum pages to fetch"),
565
+ min_stars: Optional[int] = typer.Option(None, "--min-stars", help="Minimum stars filter"),
566
+ language: Optional[str] = typer.Option(None, "--language", help="Language filter"),
567
+ sort_by: Optional[str] = typer.Option(None, "--sort-by", help="Sort by field"),
568
+ sort_direction: str = typer.Option("desc", "--sort-direction", help="Sort direction"),
569
+ group_by_language_flag: Optional[bool] = typer.Option(
570
+ None,
571
+ "--group-by-language/--no-group-by-language",
572
+ help="Group results by language",
573
+ ),
574
+ top_n: Optional[int] = typer.Option(None, "--top-n", help="Limit results to top N"),
575
+ ) -> None:
576
+ cfg = load_config(config)
577
+ merged = merge_repos_config_cli(
578
+ cfg,
579
+ cli_api_base=api_base,
580
+ cli_token=token,
581
+ query=query,
582
+ per_page=per_page,
583
+ max_pages=max_pages,
584
+ min_stars=min_stars,
585
+ language=language,
586
+ sort_by=sort_by,
587
+ sort_direction=sort_direction,
588
+ group_by_language=group_by_language_flag,
589
+ top_n=top_n,
590
+ )
591
+ valid, message = validate_sort_options(merged["sort_by"], merged["sort_direction"])
592
+ if not valid:
593
+ typer.echo(f"[ghsearch] {message}", err=True)
594
+ raise typer.Exit(code=1)
595
+ headers = build_headers(merged["token"])
596
+ raw = search_repositories(
597
+ merged["api_base"],
598
+ headers,
599
+ merged["query"],
600
+ merged["per_page"],
601
+ merged["max_pages"],
602
+ )
603
+ simplified = simplify_repos(raw["items"])
604
+ filtered = apply_filters(simplified, merged["min_stars"], merged["language"])
605
+ sorted_repos = apply_sorting(filtered, merged["sort_by"], merged["sort_direction"])
606
+ report = build_repos_report(
607
+ raw,
608
+ sorted_repos,
609
+ merged["group_by_language"],
610
+ merged["top_n"],
611
+ merged["min_stars"],
612
+ merged["language"],
613
+ merged["sort_by"],
614
+ merged["sort_direction"],
615
+ merged["api_base"],
616
+ )
617
+ yaml.safe_dump(report, stream=sys.stdout, sort_keys=False, default_flow_style=False, allow_unicode=True)
618
+
619
+
620
+ @app.command("code")
621
+ def code_command(
622
+ config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"),
623
+ api_base: Optional[str] = typer.Option(None, "--api-base", help="Override API base URL"),
624
+ token: Optional[str] = typer.Option(None, "--token", help="GitHub token for authentication"),
625
+ query: Optional[str] = typer.Option(None, "--query", "-q", help="Search query"),
626
+ per_page: Optional[int] = typer.Option(None, "--per-page", help="Results per page (max 100)"),
627
+ max_pages: Optional[int] = typer.Option(None, "--max-pages", help="Maximum pages to fetch"),
628
+ repo: Optional[str] = typer.Option(None, "--repo", help="Repository filter"),
629
+ language: Optional[str] = typer.Option(None, "--language", help="Language filter"),
630
+ path: Optional[str] = typer.Option(None, "--path", help="Path filter"),
631
+ ) -> None:
632
+ cfg = load_config(config)
633
+ merged = merge_code_config_cli(
634
+ cfg,
635
+ cli_api_base=api_base,
636
+ cli_token=token,
637
+ query=query,
638
+ per_page=per_page,
639
+ max_pages=max_pages,
640
+ repo=repo,
641
+ language=language,
642
+ path=path,
643
+ )
644
+ headers = build_headers(merged["token"])
645
+ raw = asyncio.run(
646
+ search_code_async(
647
+ merged["api_base"],
648
+ headers,
649
+ merged["query"],
650
+ merged["per_page"],
651
+ merged["max_pages"],
652
+ repo=merged["repo"],
653
+ language=merged["language"],
654
+ path=merged["path"],
655
+ )
656
+ )
657
+ simplified = simplify_code_results(raw["items"])
658
+ report = build_code_report(
659
+ raw,
660
+ simplified,
661
+ merged["api_base"],
662
+ merged["repo"],
663
+ merged["language"],
664
+ merged["path"],
665
+ )
666
+ yaml.safe_dump(report, stream=sys.stdout, sort_keys=False, default_flow_style=False, allow_unicode=True)
667
+
668
+
669
+ @app.command(
670
+ "commits",
671
+ help="Search GitHub commits. Examples:\n\n"
672
+ " # Search for commits with a query\n"
673
+ " ghsearch commits --query 'fix bug'\n\n"
674
+ " # Search commits in a specific repository\n"
675
+ " ghsearch commits --query 'performance' --repo 'owner/repo'\n\n"
676
+ " # Search commits by author\n"
677
+ " ghsearch commits --query 'refactor' --author 'username'\n\n"
678
+ " # Search commits by committer\n"
679
+ " ghsearch commits --query 'merge' --committer 'username'\n\n"
680
+ " # Output repository statistics\n"
681
+ " ghsearch commits --query 'fix' --stats\n\n"
682
+ " # Use GitHub Enterprise\n"
683
+ " ghsearch commits --api-base 'https://github.company.com/api/v3' --query 'security'\n\n"
684
+ " # Search with config file\n"
685
+ " ghsearch commits --config ~/.ghsearch.yml",
686
+ )
687
+ def commits_command(
688
+ config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to config file"),
689
+ api_base: Optional[str] = typer.Option(None, "--api-base", help="Override API base URL"),
690
+ token: Optional[str] = typer.Option(None, "--token", help="GitHub token for authentication"),
691
+ query: Optional[str] = typer.Option(None, "--query", "-q", help="Search query"),
692
+ per_page: Optional[int] = typer.Option(None, "--per-page", help="Results per page (max 100)"),
693
+ max_pages: Optional[int] = typer.Option(None, "--max-pages", help="Maximum pages to fetch"),
694
+ repo: Optional[str] = typer.Option(None, "--repo", help="Repository filter (owner/repo)"),
695
+ author: Optional[str] = typer.Option(None, "--author", help="Author filter (username or email)"),
696
+ committer: Optional[str] = typer.Option(None, "--committer", help="Committer filter (username or email)"),
697
+ stats: bool = typer.Option(False, "--stats", help="Output repository statistics instead of individual commits"),
698
+ ) -> None:
699
+ cfg = load_config(config)
700
+ merged = merge_commits_config_cli(
701
+ cfg,
702
+ cli_api_base=api_base,
703
+ cli_token=token,
704
+ query=query,
705
+ per_page=per_page,
706
+ max_pages=max_pages,
707
+ repo=repo,
708
+ author=author,
709
+ committer=committer,
710
+ stats=stats,
711
+ )
712
+ headers = build_headers(merged["token"])
713
+ raw = asyncio.run(
714
+ search_commits_async(
715
+ merged["api_base"],
716
+ headers,
717
+ merged["query"],
718
+ merged["per_page"],
719
+ merged["max_pages"],
720
+ repo=merged["repo"],
721
+ author=merged["author"],
722
+ committer=merged["committer"],
723
+ )
724
+ )
725
+ simplified = simplify_commits_results(raw["items"])
726
+ report = build_commits_report(
727
+ raw,
728
+ simplified,
729
+ merged["api_base"],
730
+ merged["repo"],
731
+ merged["author"],
732
+ merged["committer"],
733
+ stats=merged["stats"],
734
+ )
735
+ yaml.safe_dump(report, stream=sys.stdout, sort_keys=False, default_flow_style=False, allow_unicode=True)
736
+
737
+
738
+ def main() -> None:
739
+ app()
740
+