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.
- bt_ghcli-0.1.0.dist-info/METADATA +11 -0
- bt_ghcli-0.1.0.dist-info/RECORD +9 -0
- bt_ghcli-0.1.0.dist-info/WHEEL +5 -0
- bt_ghcli-0.1.0.dist-info/entry_points.txt +3 -0
- bt_ghcli-0.1.0.dist-info/top_level.txt +2 -0
- ghpr/__init__.py +9 -0
- ghpr/cli.py +367 -0
- ghsearch/__init__.py +10 -0
- ghsearch/cli.py +740 -0
|
@@ -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,,
|
ghpr/__init__.py
ADDED
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
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
|
+
|