ghp 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.
- ghp/__init__.py +0 -0
- ghp/cli.py +610 -0
- ghp-0.1.0.dist-info/METADATA +63 -0
- ghp-0.1.0.dist-info/RECORD +6 -0
- ghp-0.1.0.dist-info/WHEEL +4 -0
- ghp-0.1.0.dist-info/entry_points.txt +2 -0
ghp/__init__.py
ADDED
|
File without changes
|
ghp/cli.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Stateless GitHub activity summary for a repo.
|
|
2
|
+
|
|
3
|
+
Compact, LLM-friendly output. Caller owns the cursor via --since.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
ghp # snapshot: open issues + PRs
|
|
7
|
+
ghp 1h # deltas since 1 hour ago
|
|
8
|
+
ghp 2026-03-07T14:00:00Z
|
|
9
|
+
ghp --json # machine-readable output
|
|
10
|
+
ghp --me @clod # highlight mentions
|
|
11
|
+
ghp --repo owner/name # explicit repo (else auto-detect)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json as json_mod
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
25
|
+
|
|
26
|
+
ISO_8601_UTC = "%Y-%m-%dT%H:%M:%SZ"
|
|
27
|
+
GITHUB_HANDLE_CHARS = "A-Za-z0-9-"
|
|
28
|
+
LAST_UPDATE_FILENAME = ".ghp-last-update-timestamp"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApiError(RuntimeError):
|
|
32
|
+
"""Raised when a GitHub API request fails."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _utc_now():
|
|
36
|
+
return datetime.now(timezone.utc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pkg_version():
|
|
40
|
+
try:
|
|
41
|
+
return version("ghp")
|
|
42
|
+
except PackageNotFoundError:
|
|
43
|
+
return "0.0.0"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_utc(dt):
|
|
47
|
+
return dt.astimezone(timezone.utc).strftime(ISO_8601_UTC)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_iso8601(raw):
|
|
51
|
+
try:
|
|
52
|
+
normalized = raw.replace("Z", "+00:00")
|
|
53
|
+
dt = datetime.fromisoformat(normalized)
|
|
54
|
+
except ValueError as exc:
|
|
55
|
+
raise ValueError(f"invalid ISO 8601 timestamp: {raw}") from exc
|
|
56
|
+
if dt.tzinfo is None:
|
|
57
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
58
|
+
return dt.astimezone(timezone.utc)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_token():
|
|
62
|
+
"""Find a GitHub token from env or gh CLI."""
|
|
63
|
+
for var in ("GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"):
|
|
64
|
+
tok = os.environ.get(var)
|
|
65
|
+
if tok:
|
|
66
|
+
return tok
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["gh", "auth", "token"], capture_output=True, text=True, timeout=5
|
|
70
|
+
)
|
|
71
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
72
|
+
return result.stdout.strip()
|
|
73
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
74
|
+
pass
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _detect_repo():
|
|
79
|
+
"""Detect owner/repo from git remote."""
|
|
80
|
+
try:
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
["git", "remote", "get-url", "origin"],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=5,
|
|
86
|
+
)
|
|
87
|
+
url = result.stdout.strip()
|
|
88
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
89
|
+
return None
|
|
90
|
+
match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", url)
|
|
91
|
+
if match:
|
|
92
|
+
return match.group(1)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _api(path, token, params=None):
|
|
97
|
+
"""GET JSON from GitHub REST API."""
|
|
98
|
+
url = f"https://api.github.com/{path.lstrip('/')}"
|
|
99
|
+
if params:
|
|
100
|
+
url = f"{url}?{urllib.parse.urlencode(params, doseq=True)}"
|
|
101
|
+
|
|
102
|
+
headers = {
|
|
103
|
+
"Accept": "application/vnd.github+json",
|
|
104
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
105
|
+
}
|
|
106
|
+
if token:
|
|
107
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
108
|
+
|
|
109
|
+
req = urllib.request.Request(url, headers=headers)
|
|
110
|
+
try:
|
|
111
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
112
|
+
return json_mod.loads(resp.read())
|
|
113
|
+
except urllib.error.HTTPError as exc:
|
|
114
|
+
detail = ""
|
|
115
|
+
try:
|
|
116
|
+
payload = json_mod.loads(exc.read().decode())
|
|
117
|
+
detail = payload.get("message", "")
|
|
118
|
+
except Exception:
|
|
119
|
+
detail = ""
|
|
120
|
+
suffix = f" ({detail})" if detail else ""
|
|
121
|
+
raise ApiError(f"API error: {exc.code} {exc.reason} for {url}{suffix}") from exc
|
|
122
|
+
except urllib.error.URLError as exc:
|
|
123
|
+
raise ApiError(f"API error: {exc.reason} for {url}") from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_since(raw):
|
|
127
|
+
"""Parse --since value into a canonical UTC ISO 8601 string."""
|
|
128
|
+
if re.match(r"^\d{4}-\d{2}-\d{2}T", raw):
|
|
129
|
+
return _format_utc(_parse_iso8601(raw))
|
|
130
|
+
|
|
131
|
+
match = re.match(r"^(\d+)([mhdw])$", raw)
|
|
132
|
+
if not match:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"invalid --since: {raw} (use 30m, 2h, 1d, 1w, or ISO 8601)"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
num, unit = int(match.group(1)), match.group(2)
|
|
138
|
+
delta = {
|
|
139
|
+
"m": timedelta(minutes=num),
|
|
140
|
+
"h": timedelta(hours=num),
|
|
141
|
+
"d": timedelta(days=num),
|
|
142
|
+
"w": timedelta(weeks=num),
|
|
143
|
+
}[unit]
|
|
144
|
+
return _format_utc(_utc_now() - delta)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _fmt_issue(issue):
|
|
148
|
+
parts = [
|
|
149
|
+
f'#{issue["number"]}',
|
|
150
|
+
issue["state"],
|
|
151
|
+
f'@{issue["user"]["login"]}',
|
|
152
|
+
issue["title"],
|
|
153
|
+
]
|
|
154
|
+
labels = ",".join(label["name"] for label in issue.get("labels", []))
|
|
155
|
+
if labels:
|
|
156
|
+
parts.append(f"l:{labels}")
|
|
157
|
+
if issue.get("comments"):
|
|
158
|
+
parts.append(f'c:{issue["comments"]}')
|
|
159
|
+
return " ".join(parts)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _fmt_pr(pr):
|
|
163
|
+
status = pr["state"]
|
|
164
|
+
if pr.get("draft"):
|
|
165
|
+
status = f"{status},draft"
|
|
166
|
+
|
|
167
|
+
parts = [
|
|
168
|
+
f'#{pr["number"]}',
|
|
169
|
+
status,
|
|
170
|
+
f'@{pr["user"]["login"]}',
|
|
171
|
+
f'{pr["head"]["ref"]}->{pr["base"]["ref"]}',
|
|
172
|
+
pr["title"],
|
|
173
|
+
]
|
|
174
|
+
if isinstance(pr.get("comments"), int) and pr["comments"] > 0:
|
|
175
|
+
parts.append(f'c:{pr["comments"]}')
|
|
176
|
+
if isinstance(pr.get("review_comments"), int) and pr["review_comments"] > 0:
|
|
177
|
+
parts.append(f'rc:{pr["review_comments"]}')
|
|
178
|
+
return " ".join(parts)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _comment_number(comment):
|
|
182
|
+
url = comment.get("pull_request_url") or comment.get("issue_url") or ""
|
|
183
|
+
return url.rstrip("/").split("/")[-1] if url else "?"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _comment_timestamp(comment):
|
|
187
|
+
return comment.get("updated_at") or comment.get("created_at") or ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _trim_comment_body(body, limit):
|
|
191
|
+
flattened = re.sub(r"\s+", " ", (body or "").replace("\r", " ")).strip()
|
|
192
|
+
if not flattened:
|
|
193
|
+
return "[no body]"
|
|
194
|
+
if len(flattened) > limit:
|
|
195
|
+
return flattened[:limit] + "..."
|
|
196
|
+
return flattened
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _fmt_comment(comment):
|
|
200
|
+
kind = "review" if comment.get("comment_type") == "review" else "comment"
|
|
201
|
+
return (
|
|
202
|
+
f"#{_comment_number(comment)} {kind} @{comment['user']['login']} "
|
|
203
|
+
f"{_comment_timestamp(comment)}: {_trim_comment_body(comment.get('body'), 120)}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _print_section(name, items, formatter):
|
|
208
|
+
if not items:
|
|
209
|
+
return
|
|
210
|
+
print(f"{name} {len(items)}")
|
|
211
|
+
for item in items:
|
|
212
|
+
print(formatter(item))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _fetch_issues(repo, token, limit, cutoff):
|
|
216
|
+
params = {
|
|
217
|
+
"state": "all" if cutoff else "open",
|
|
218
|
+
"sort": "updated",
|
|
219
|
+
"direction": "desc",
|
|
220
|
+
}
|
|
221
|
+
if cutoff:
|
|
222
|
+
params["since"] = cutoff
|
|
223
|
+
|
|
224
|
+
per_page = min(max(limit * 2, 30), 100)
|
|
225
|
+
issues = []
|
|
226
|
+
page = 1
|
|
227
|
+
|
|
228
|
+
while len(issues) < limit:
|
|
229
|
+
batch = _api(
|
|
230
|
+
f"repos/{repo}/issues",
|
|
231
|
+
token,
|
|
232
|
+
{**params, "per_page": per_page, "page": page},
|
|
233
|
+
)
|
|
234
|
+
if not isinstance(batch, list):
|
|
235
|
+
raise ApiError("Unexpected issues payload from GitHub API")
|
|
236
|
+
if not batch:
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
for item in batch:
|
|
240
|
+
if "pull_request" in item:
|
|
241
|
+
continue
|
|
242
|
+
issues.append(item)
|
|
243
|
+
if len(issues) >= limit:
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
if len(batch) < per_page:
|
|
247
|
+
break
|
|
248
|
+
page += 1
|
|
249
|
+
|
|
250
|
+
return issues
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _fetch_prs(repo, token, limit, cutoff):
|
|
254
|
+
params = {
|
|
255
|
+
"state": "all" if cutoff else "open",
|
|
256
|
+
"sort": "updated",
|
|
257
|
+
"direction": "desc",
|
|
258
|
+
}
|
|
259
|
+
per_page = min(max(limit, 30), 100)
|
|
260
|
+
cutoff_dt = _parse_iso8601(cutoff) if cutoff else None
|
|
261
|
+
prs = []
|
|
262
|
+
page = 1
|
|
263
|
+
|
|
264
|
+
while len(prs) < limit:
|
|
265
|
+
batch = _api(
|
|
266
|
+
f"repos/{repo}/pulls",
|
|
267
|
+
token,
|
|
268
|
+
{**params, "per_page": per_page, "page": page},
|
|
269
|
+
)
|
|
270
|
+
if not isinstance(batch, list):
|
|
271
|
+
raise ApiError("Unexpected pull request payload from GitHub API")
|
|
272
|
+
if not batch:
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
for pr in batch:
|
|
276
|
+
if cutoff_dt and _parse_iso8601(pr["updated_at"]) < cutoff_dt:
|
|
277
|
+
return prs
|
|
278
|
+
prs.append(pr)
|
|
279
|
+
if len(prs) >= limit:
|
|
280
|
+
return prs
|
|
281
|
+
|
|
282
|
+
if len(batch) < per_page:
|
|
283
|
+
break
|
|
284
|
+
page += 1
|
|
285
|
+
|
|
286
|
+
return prs
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _fetch_comment_endpoint(repo, token, path, comment_type, limit, cutoff):
|
|
290
|
+
per_page = min(max(limit, 30), 100)
|
|
291
|
+
comments = []
|
|
292
|
+
page = 1
|
|
293
|
+
|
|
294
|
+
while len(comments) < limit:
|
|
295
|
+
batch = _api(
|
|
296
|
+
f"repos/{repo}/{path}",
|
|
297
|
+
token,
|
|
298
|
+
{
|
|
299
|
+
"since": cutoff,
|
|
300
|
+
"sort": "updated",
|
|
301
|
+
"direction": "desc",
|
|
302
|
+
"per_page": per_page,
|
|
303
|
+
"page": page,
|
|
304
|
+
},
|
|
305
|
+
)
|
|
306
|
+
if not isinstance(batch, list):
|
|
307
|
+
raise ApiError("Unexpected comments payload from GitHub API")
|
|
308
|
+
if not batch:
|
|
309
|
+
break
|
|
310
|
+
|
|
311
|
+
for comment in batch:
|
|
312
|
+
typed_comment = dict(comment)
|
|
313
|
+
typed_comment["comment_type"] = comment_type
|
|
314
|
+
comments.append(typed_comment)
|
|
315
|
+
if len(comments) >= limit:
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
if len(batch) < per_page:
|
|
319
|
+
break
|
|
320
|
+
page += 1
|
|
321
|
+
|
|
322
|
+
return comments
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _comment_sort_key(comment):
|
|
326
|
+
updated_at = _parse_iso8601(_comment_timestamp(comment))
|
|
327
|
+
created_at = _parse_iso8601(comment.get("created_at") or _comment_timestamp(comment))
|
|
328
|
+
return (updated_at, created_at, comment.get("id", 0))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _merge_comments(comment_groups, limit):
|
|
332
|
+
merged = []
|
|
333
|
+
for group in comment_groups:
|
|
334
|
+
merged.extend(group)
|
|
335
|
+
merged.sort(key=_comment_sort_key, reverse=True)
|
|
336
|
+
return merged[:limit]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _fetch_comments(repo, token, limit, cutoff):
|
|
340
|
+
if not cutoff:
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
issue_comments = _fetch_comment_endpoint(
|
|
344
|
+
repo, token, "issues/comments", "issue", limit, cutoff
|
|
345
|
+
)
|
|
346
|
+
review_comments = _fetch_comment_endpoint(
|
|
347
|
+
repo, token, "pulls/comments", "review", limit, cutoff
|
|
348
|
+
)
|
|
349
|
+
return _merge_comments([issue_comments, review_comments], limit)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _fetch_commits(repo, token, limit, cutoff):
|
|
353
|
+
if not cutoff:
|
|
354
|
+
return []
|
|
355
|
+
|
|
356
|
+
per_page = min(max(limit, 30), 100)
|
|
357
|
+
commits = []
|
|
358
|
+
page = 1
|
|
359
|
+
|
|
360
|
+
while len(commits) < limit:
|
|
361
|
+
batch = _api(
|
|
362
|
+
f"repos/{repo}/commits",
|
|
363
|
+
token,
|
|
364
|
+
{
|
|
365
|
+
"since": cutoff,
|
|
366
|
+
"per_page": per_page,
|
|
367
|
+
"page": page,
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
if not isinstance(batch, list):
|
|
371
|
+
raise ApiError("Unexpected commits payload from GitHub API")
|
|
372
|
+
if not batch:
|
|
373
|
+
break
|
|
374
|
+
|
|
375
|
+
commits.extend(batch[: max(limit - len(commits), 0)])
|
|
376
|
+
|
|
377
|
+
if len(batch) < per_page:
|
|
378
|
+
break
|
|
379
|
+
page += 1
|
|
380
|
+
|
|
381
|
+
return commits[:limit]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _mention_pattern(handle):
|
|
385
|
+
normalized = handle.lstrip("@").strip()
|
|
386
|
+
if not normalized:
|
|
387
|
+
return None
|
|
388
|
+
return re.compile(
|
|
389
|
+
rf"(?<![{GITHUB_HANDLE_CHARS}])@{re.escape(normalized)}(?![{GITHUB_HANDLE_CHARS}])",
|
|
390
|
+
re.IGNORECASE,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _emit_error(message, json_output, repo, timestamp, cutoff):
|
|
395
|
+
if json_output:
|
|
396
|
+
print(
|
|
397
|
+
json_mod.dumps(
|
|
398
|
+
{
|
|
399
|
+
"repo": repo,
|
|
400
|
+
"timestamp": timestamp,
|
|
401
|
+
"since": cutoff,
|
|
402
|
+
"error": message,
|
|
403
|
+
},
|
|
404
|
+
separators=(",", ":"),
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
return
|
|
408
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _timestamp_file_path():
|
|
412
|
+
return os.path.join(os.getcwd(), LAST_UPDATE_FILENAME)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _load_last_update_timestamp():
|
|
416
|
+
path = _timestamp_file_path()
|
|
417
|
+
if not os.path.exists(path):
|
|
418
|
+
return None
|
|
419
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
420
|
+
raw = fh.read().strip()
|
|
421
|
+
if not raw:
|
|
422
|
+
return None
|
|
423
|
+
return _resolve_since(raw)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _save_last_update_timestamp(timestamp):
|
|
427
|
+
path = _timestamp_file_path()
|
|
428
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
429
|
+
fh.write(timestamp + "\n")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _fmt_commit(commit):
|
|
433
|
+
sha = (commit.get("sha") or "")[:7] or "?"
|
|
434
|
+
message = (commit.get("commit") or {}).get("message") or ""
|
|
435
|
+
summary = message.splitlines()[0].strip() if message else "[no message]"
|
|
436
|
+
author = (
|
|
437
|
+
((commit.get("author") or {}).get("login"))
|
|
438
|
+
or ((commit.get("commit") or {}).get("author") or {}).get("name")
|
|
439
|
+
or "unknown"
|
|
440
|
+
)
|
|
441
|
+
date = ((commit.get("commit") or {}).get("author") or {}).get("date") or ""
|
|
442
|
+
return f"{sha} @{author} {date} {summary}"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def main():
|
|
446
|
+
parser = argparse.ArgumentParser(
|
|
447
|
+
prog="ghp",
|
|
448
|
+
description="Stateless GitHub activity summary — compact, LLM-friendly output",
|
|
449
|
+
)
|
|
450
|
+
parser.add_argument(
|
|
451
|
+
"since_pos",
|
|
452
|
+
nargs="?",
|
|
453
|
+
metavar="TIME",
|
|
454
|
+
help=argparse.SUPPRESS,
|
|
455
|
+
)
|
|
456
|
+
parser.add_argument(
|
|
457
|
+
"--since",
|
|
458
|
+
metavar="TIME",
|
|
459
|
+
help="Show deltas since TIME (ISO 8601 or relative: 30m, 2h, 1d, 1w)",
|
|
460
|
+
)
|
|
461
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
462
|
+
parser.add_argument(
|
|
463
|
+
"--version", action="version", version=f"%(prog)s {_pkg_version()}"
|
|
464
|
+
)
|
|
465
|
+
parser.add_argument(
|
|
466
|
+
"--me", metavar="HANDLE", help="Highlight mentions of this handle (e.g. @clod)"
|
|
467
|
+
)
|
|
468
|
+
parser.add_argument(
|
|
469
|
+
"--repo",
|
|
470
|
+
metavar="OWNER/REPO",
|
|
471
|
+
help="GitHub repo (default: auto-detect from git remote)",
|
|
472
|
+
)
|
|
473
|
+
parser.add_argument(
|
|
474
|
+
"--limit", type=int, default=30, help="Max items per category (default: 30)"
|
|
475
|
+
)
|
|
476
|
+
args = parser.parse_args()
|
|
477
|
+
if args.since and args.since_pos:
|
|
478
|
+
parser.error("use TIME or --since, not both")
|
|
479
|
+
|
|
480
|
+
now = _format_utc(_utc_now())
|
|
481
|
+
repo = args.repo or _detect_repo()
|
|
482
|
+
cutoff = None
|
|
483
|
+
|
|
484
|
+
if args.limit < 1:
|
|
485
|
+
_emit_error("--limit must be >= 1", args.json, repo, now, cutoff)
|
|
486
|
+
return 1
|
|
487
|
+
|
|
488
|
+
if not repo:
|
|
489
|
+
_emit_error(
|
|
490
|
+
"could not detect repo. Use --repo owner/name or run from a git checkout.",
|
|
491
|
+
args.json,
|
|
492
|
+
repo,
|
|
493
|
+
now,
|
|
494
|
+
cutoff,
|
|
495
|
+
)
|
|
496
|
+
return 1
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
raw_since = args.since or args.since_pos
|
|
500
|
+
cutoff = _resolve_since(raw_since) if raw_since else _load_last_update_timestamp()
|
|
501
|
+
except ValueError as exc:
|
|
502
|
+
_emit_error(str(exc), args.json, repo, now, cutoff)
|
|
503
|
+
return 1
|
|
504
|
+
|
|
505
|
+
token = _get_token()
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
issues = _fetch_issues(repo, token, args.limit, cutoff)
|
|
509
|
+
prs = _fetch_prs(repo, token, args.limit, cutoff)
|
|
510
|
+
comments = _fetch_comments(repo, token, args.limit, cutoff)
|
|
511
|
+
commits = _fetch_commits(repo, token, args.limit, cutoff)
|
|
512
|
+
except ApiError as exc:
|
|
513
|
+
_emit_error(str(exc), args.json, repo, now, cutoff)
|
|
514
|
+
return 1
|
|
515
|
+
|
|
516
|
+
if args.json:
|
|
517
|
+
out = {
|
|
518
|
+
"repo": repo,
|
|
519
|
+
"timestamp": now,
|
|
520
|
+
"since": cutoff,
|
|
521
|
+
"issues": [
|
|
522
|
+
{
|
|
523
|
+
"number": issue["number"],
|
|
524
|
+
"title": issue["title"],
|
|
525
|
+
"state": issue["state"],
|
|
526
|
+
"user": issue["user"]["login"],
|
|
527
|
+
"labels": [label["name"] for label in issue.get("labels", [])],
|
|
528
|
+
"updated_at": issue["updated_at"],
|
|
529
|
+
"created_at": issue["created_at"],
|
|
530
|
+
"comments": issue.get("comments", 0),
|
|
531
|
+
}
|
|
532
|
+
for issue in issues
|
|
533
|
+
],
|
|
534
|
+
"pull_requests": [
|
|
535
|
+
{
|
|
536
|
+
"number": pr["number"],
|
|
537
|
+
"title": pr["title"],
|
|
538
|
+
"state": pr["state"],
|
|
539
|
+
"user": pr["user"]["login"],
|
|
540
|
+
"base": pr["base"]["ref"],
|
|
541
|
+
"head": pr["head"]["ref"],
|
|
542
|
+
"draft": pr.get("draft", False),
|
|
543
|
+
"updated_at": pr["updated_at"],
|
|
544
|
+
"created_at": pr["created_at"],
|
|
545
|
+
"review_comments": pr.get("review_comments"),
|
|
546
|
+
"comments": pr.get("comments"),
|
|
547
|
+
}
|
|
548
|
+
for pr in prs
|
|
549
|
+
],
|
|
550
|
+
"recent_comments": [
|
|
551
|
+
{
|
|
552
|
+
"number": _comment_number(comment),
|
|
553
|
+
"comment_type": comment.get("comment_type", "issue"),
|
|
554
|
+
"user": comment["user"]["login"],
|
|
555
|
+
"created_at": comment.get("created_at"),
|
|
556
|
+
"updated_at": comment.get("updated_at"),
|
|
557
|
+
"body": _trim_comment_body(comment.get("body"), 200),
|
|
558
|
+
}
|
|
559
|
+
for comment in comments
|
|
560
|
+
],
|
|
561
|
+
"commits": [
|
|
562
|
+
{
|
|
563
|
+
"sha": commit.get("sha"),
|
|
564
|
+
"message": ((commit.get("commit") or {}).get("message") or "").splitlines()[0],
|
|
565
|
+
"author": (
|
|
566
|
+
((commit.get("author") or {}).get("login"))
|
|
567
|
+
or ((commit.get("commit") or {}).get("author") or {}).get("name")
|
|
568
|
+
),
|
|
569
|
+
"date": ((commit.get("commit") or {}).get("author") or {}).get("date"),
|
|
570
|
+
"url": commit.get("html_url"),
|
|
571
|
+
}
|
|
572
|
+
for commit in commits
|
|
573
|
+
],
|
|
574
|
+
}
|
|
575
|
+
print(json_mod.dumps(out, separators=(",", ":")))
|
|
576
|
+
_save_last_update_timestamp(now)
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
head = f"{repo} {now}"
|
|
580
|
+
if cutoff:
|
|
581
|
+
head = f"{head} since={cutoff}"
|
|
582
|
+
print(head)
|
|
583
|
+
|
|
584
|
+
_print_section("iss", issues, _fmt_issue)
|
|
585
|
+
_print_section("pr", prs, _fmt_pr)
|
|
586
|
+
if cutoff:
|
|
587
|
+
_print_section("com", comments, _fmt_comment)
|
|
588
|
+
_print_section("git", commits, _fmt_commit)
|
|
589
|
+
|
|
590
|
+
mention_re = _mention_pattern(args.me) if args.me else None
|
|
591
|
+
if mention_re and comments:
|
|
592
|
+
hits = [
|
|
593
|
+
comment for comment in comments if mention_re.search(comment.get("body") or "")
|
|
594
|
+
]
|
|
595
|
+
if hits:
|
|
596
|
+
handle = args.me.lstrip("@")
|
|
597
|
+
print(f"@{handle} {len(hits)}")
|
|
598
|
+
for comment in hits:
|
|
599
|
+
print(_fmt_comment(comment))
|
|
600
|
+
|
|
601
|
+
if not issues and not prs and not comments and not commits:
|
|
602
|
+
print("none")
|
|
603
|
+
|
|
604
|
+
_save_last_update_timestamp(now)
|
|
605
|
+
|
|
606
|
+
return 0
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
if __name__ == "__main__":
|
|
610
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stateless GitHub activity summary — compact, LLM-friendly output
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# ghp
|
|
10
|
+
|
|
11
|
+
Stateless GitHub activity summary. Compact, LLM-friendly output.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv tool install ghp
|
|
17
|
+
# or
|
|
18
|
+
uvx ghp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Dev
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv sync --group dev
|
|
25
|
+
uv run ghp --version
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
ghp # open issues + PRs snapshot
|
|
32
|
+
ghp 1h # deltas since 1 hour ago
|
|
33
|
+
ghp 2026-03-07T14:00:00Z # deltas since timestamp
|
|
34
|
+
ghp --json # machine-readable output
|
|
35
|
+
ghp --me @clod # highlight mentions
|
|
36
|
+
ghp --repo owner/name # explicit repo
|
|
37
|
+
# default delta cursor comes from .ghp-last-update-timestamp if present
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Behavior
|
|
41
|
+
|
|
42
|
+
- Cursor defaults to `.ghp-last-update-timestamp` in the current working directory when `--since` is omitted.
|
|
43
|
+
- Successful runs autosave the current timestamp back to `.ghp-last-update-timestamp`.
|
|
44
|
+
- `--since` accepts relative shorthands (`30m`, `2h`, `1d`, `1w`) and normalizes timestamps to canonical UTC.
|
|
45
|
+
- Snapshot mode returns open issues and open PRs.
|
|
46
|
+
- Delta mode returns issues, PRs, issue comments, PR review comments, and recent commits since the cutoff.
|
|
47
|
+
- API or auth failures exit non-zero instead of silently pretending there was no activity.
|
|
48
|
+
- `--json` prints a machine-readable payload; on failure it emits a JSON object with an `error` field and exits non-zero.
|
|
49
|
+
|
|
50
|
+
## Auth
|
|
51
|
+
|
|
52
|
+
Looks for tokens in this order:
|
|
53
|
+
1. `$GITHUB_PAT`
|
|
54
|
+
2. `$GITHUB_TOKEN`
|
|
55
|
+
3. `$GH_TOKEN`
|
|
56
|
+
4. `gh auth token` (gh CLI)
|
|
57
|
+
|
|
58
|
+
## Test
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv run python -m unittest discover -s tests
|
|
62
|
+
scripts/test-install.sh
|
|
63
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ghp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
ghp/cli.py,sha256=ijSe0CyGjFuY_M6eLAUp6pWZiFEK-jrPWhJ69TpqYbA,17969
|
|
3
|
+
ghp-0.1.0.dist-info/METADATA,sha256=VPQ7a7GhrMWfIWO3ee8FYshNo-eX7jgisEjSq8BYvgU,1771
|
|
4
|
+
ghp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
ghp-0.1.0.dist-info/entry_points.txt,sha256=JLVVMfycLFj9VnmPgtFqSKNjwuGgXgaN57NQSsDrtT8,37
|
|
6
|
+
ghp-0.1.0.dist-info/RECORD,,
|