delimit-cli 4.0.1 → 4.0.3

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,622 @@
1
+ """GitHub scanner -- pulse health checks and hunter lead discovery.
2
+
3
+ Pulse (every 10 min): own-repo stars, forks, issues, traffic, referrers.
4
+ Hunter (hourly): competitor action users, adoption leads, pain threads.
5
+ Deep (daily): stub for ecosystem intel and pain clustering.
6
+
7
+ All GitHub API calls use ``gh api`` via subprocess (already authenticated).
8
+ Rate limited to 2 seconds between search API calls (30/min limit).
9
+ Results persisted to ~/.delimit/github_scans/{date}_{cadence}.json.
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import subprocess
16
+ import time
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ logger = logging.getLogger("delimit.ai.github_scanner")
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Constants
25
+ # ---------------------------------------------------------------------------
26
+
27
+ OWN_REPOS = [
28
+ "delimit-ai/delimit-mcp-server",
29
+ "delimit-ai/delimit-action",
30
+ "delimit-ai/delimit-quickstart",
31
+ ]
32
+
33
+ INTERNAL_USERS = set() # Configure via env
34
+
35
+ COMPETITOR_ACTIONS = [
36
+ "tufin/oasdiff-action",
37
+ "stoplightio/spectral-action",
38
+ "redocly/openapi-cli",
39
+ "opticdev/optic",
40
+ ]
41
+
42
+ SCANS_DIR = Path.home() / ".delimit" / "github_scans"
43
+ KNOWN_FILE = Path.home() / ".delimit" / "github_known.json"
44
+
45
+ # Rate limit: 2 seconds between search queries
46
+ SEARCH_RATE_LIMIT = 2.0
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Pain categories (extend reddit_scanner's taxonomy)
50
+ # ---------------------------------------------------------------------------
51
+
52
+ # Import base pain categories from reddit scanner, with fallback
53
+ try:
54
+ from ai.reddit_scanner import PAIN_CATEGORIES as _BASE_PAIN
55
+ except ImportError:
56
+ _BASE_PAIN = {
57
+ "context_loss": ["lost context", "re-explain", "starting from zero", "forgot", "doesn't remember"],
58
+ "rate_limits": ["rate limit", "session limit", "throttled", "burned through", "ran out"],
59
+ "multi_model": ["switching between", "codex and claude", "multiple models", "different tool"],
60
+ "code_quality": ["broke my", "deleted", "undid", "regression", "broke production"],
61
+ "session_management": ["session died", "context window", "compact", "handoff"],
62
+ "governance": ["breaking change", "API broke", "schema", "backward compat"],
63
+ "onboarding": ["how to start", "getting started", "setup", "configure"],
64
+ "cost": ["expensive", "pricing", "cost", "$200", "billing"],
65
+ }
66
+
67
+ GITHUB_PAIN_CATEGORIES: Dict[str, List[str]] = {
68
+ **_BASE_PAIN,
69
+ "breaking_changes": ["broke our clients", "backward compatibility", "breaking change", "api contract"],
70
+ "schema_drift": ["schema drift", "spec out of sync", "generated client broke", "stale openapi"],
71
+ "ci_governance": ["no gate", "merged without review", "api review process", "caught in production"],
72
+ "monorepo": ["multiple specs", "monorepo", "workspace openapi"],
73
+ }
74
+
75
+ # Which pain categories map to Delimit features
76
+ _PAIN_TO_RELEVANCE: Dict[str, str] = {
77
+ "context_loss": "existing_feature",
78
+ "session_management": "existing_feature",
79
+ "governance": "existing_feature",
80
+ "multi_model": "existing_feature",
81
+ "breaking_changes": "existing_feature",
82
+ "schema_drift": "existing_feature",
83
+ "ci_governance": "existing_feature",
84
+ "code_quality": "planned_feature",
85
+ "onboarding": "planned_feature",
86
+ "monorepo": "planned_feature",
87
+ "rate_limits": "new_opportunity",
88
+ "cost": "new_opportunity",
89
+ }
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # GitHub API helpers
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _gh_api(endpoint: str, *, accept: str = "") -> Optional[Dict[str, Any]]:
98
+ """Call ``gh api <endpoint>`` and return parsed JSON, or None on error."""
99
+ cmd = ["gh", "api", endpoint]
100
+ if accept:
101
+ cmd.extend(["-H", f"Accept: {accept}"])
102
+ try:
103
+ result = subprocess.run(
104
+ cmd,
105
+ capture_output=True,
106
+ text=True,
107
+ timeout=30,
108
+ )
109
+ if result.returncode != 0:
110
+ logger.warning("gh api %s failed: %s", endpoint, result.stderr.strip()[:200])
111
+ return None
112
+ return json.loads(result.stdout)
113
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
114
+ logger.warning("gh api %s error: %s", endpoint, exc)
115
+ return None
116
+
117
+
118
+ def _gh_search(query: str, endpoint: str = "search/code") -> Optional[Dict[str, Any]]:
119
+ """Run a GitHub search API query with rate limiting.
120
+
121
+ Sleeps SEARCH_RATE_LIMIT seconds before each call to stay within
122
+ the 30 requests/minute search API limit.
123
+ """
124
+ time.sleep(SEARCH_RATE_LIMIT)
125
+ return _gh_api(f"{endpoint}?{query}")
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Known state management (stargazers, forkers)
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def _load_known() -> Dict[str, Any]:
134
+ """Load known stargazers/forkers from disk."""
135
+ if KNOWN_FILE.exists():
136
+ try:
137
+ return json.loads(KNOWN_FILE.read_text())
138
+ except (json.JSONDecodeError, OSError):
139
+ pass
140
+ return {"stargazers": {}, "forkers": {}, "last_updated": None}
141
+
142
+
143
+ def _save_known(data: Dict[str, Any]) -> None:
144
+ """Save known stargazers/forkers to disk."""
145
+ KNOWN_FILE.parent.mkdir(parents=True, exist_ok=True)
146
+ data["last_updated"] = datetime.now(timezone.utc).isoformat()
147
+ KNOWN_FILE.write_text(json.dumps(data, indent=2, default=str))
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Scoring
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def _score_finding(
156
+ category: str,
157
+ stars: int = 0,
158
+ recency_days: float = 999,
159
+ relevance_signals: int = 0,
160
+ ) -> int:
161
+ """Score a finding 0-100 based on stars, recency, and relevance.
162
+
163
+ Category weights:
164
+ competitor_user: high base (they already believe in governance)
165
+ adoption_lead: medium base
166
+ pain_thread: weighted by recency and reactions
167
+ own_repo_activity: always 50 (informational)
168
+ competitive_move: high base
169
+ """
170
+ base_scores = {
171
+ "competitor_user": 60,
172
+ "adoption_lead": 40,
173
+ "pain_thread": 30,
174
+ "own_repo_activity": 50,
175
+ "competitive_move": 70,
176
+ }
177
+ score = base_scores.get(category, 30)
178
+
179
+ # Star bonus: +1 per 100 stars, max +20
180
+ score += min(20, stars // 100)
181
+
182
+ # Recency bonus: +20 for <1 day, +10 for <7 days
183
+ if recency_days < 1:
184
+ score += 20
185
+ elif recency_days < 7:
186
+ score += 10
187
+ elif recency_days < 30:
188
+ score += 5
189
+
190
+ # Relevance signal bonus: +5 per signal, max +15
191
+ score += min(15, relevance_signals * 5)
192
+
193
+ return min(100, max(0, score))
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Pain-point extraction
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def extract_pain_points(title: str, body: str = "") -> Dict[str, Any]:
202
+ """Extract pain categories from an issue title and body.
203
+
204
+ Returns dict with matched_categories, relevance, and pain_tags.
205
+ """
206
+ combined = (title + " " + body).lower()
207
+
208
+ matched_cats: List[str] = []
209
+ pain_tags: List[str] = []
210
+ for category, phrases in GITHUB_PAIN_CATEGORIES.items():
211
+ for phrase in phrases:
212
+ if phrase in combined:
213
+ if category not in matched_cats:
214
+ matched_cats.append(category)
215
+ if phrase not in pain_tags:
216
+ pain_tags.append(phrase)
217
+
218
+ if not matched_cats:
219
+ return {
220
+ "matched_categories": [],
221
+ "pain_tags": [],
222
+ "delimit_relevance": "not_relevant",
223
+ }
224
+
225
+ # Best relevance
226
+ relevance_priority = ["existing_feature", "planned_feature", "new_opportunity"]
227
+ best_relevance = "not_relevant"
228
+ for cat in matched_cats:
229
+ cat_rel = _PAIN_TO_RELEVANCE.get(cat, "not_relevant")
230
+ if cat_rel in relevance_priority:
231
+ idx = relevance_priority.index(cat_rel)
232
+ best_idx = (
233
+ relevance_priority.index(best_relevance)
234
+ if best_relevance in relevance_priority
235
+ else len(relevance_priority)
236
+ )
237
+ if idx < best_idx:
238
+ best_relevance = cat_rel
239
+
240
+ return {
241
+ "matched_categories": matched_cats,
242
+ "pain_tags": pain_tags,
243
+ "delimit_relevance": best_relevance,
244
+ }
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Pulse module: own-repo health
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ def pulse_scan() -> Dict[str, Any]:
253
+ """Own-repo health check: stars, forks, issues, traffic, referrers.
254
+
255
+ Detects new stargazers and forkers by comparing against known state.
256
+ """
257
+ known = _load_known()
258
+ repos_data: List[Dict[str, Any]] = []
259
+
260
+ for repo in OWN_REPOS:
261
+ repo_info = _gh_api(f"repos/{repo}")
262
+ if not repo_info:
263
+ repos_data.append({"repo": repo, "error": "failed to fetch"})
264
+ continue
265
+
266
+ stars = repo_info.get("stargazers_count", 0)
267
+ forks = repo_info.get("forks_count", 0)
268
+
269
+ # Fetch stargazers for delta detection
270
+ stargazers_data = _gh_api(
271
+ f"repos/{repo}/stargazers?per_page=100",
272
+ accept="application/vnd.github.star+json",
273
+ )
274
+ current_stargazers = set()
275
+ if isinstance(stargazers_data, list):
276
+ for sg in stargazers_data:
277
+ user = sg.get("user", {}).get("login", "")
278
+ if user:
279
+ current_stargazers.add(user)
280
+
281
+ known_stargazers = set(known.get("stargazers", {}).get(repo, []))
282
+ new_stargazers = current_stargazers - known_stargazers
283
+
284
+ # Fetch forkers for delta detection
285
+ forks_data = _gh_api(f"repos/{repo}/forks?sort=newest&per_page=50")
286
+ current_forkers = set()
287
+ if isinstance(forks_data, list):
288
+ for fork in forks_data:
289
+ owner = fork.get("owner", {}).get("login", "")
290
+ if owner:
291
+ current_forkers.add(owner)
292
+
293
+ known_forkers = set(known.get("forkers", {}).get(repo, []))
294
+ new_forkers = current_forkers - known_forkers
295
+
296
+ # Fetch external issues/PRs (not from internal users)
297
+ issues_data = _gh_api(
298
+ f"repos/{repo}/issues?state=open&sort=created&direction=desc&per_page=20"
299
+ )
300
+ external_issues: List[Dict[str, str]] = []
301
+ if isinstance(issues_data, list):
302
+ for issue in issues_data:
303
+ author = issue.get("user", {}).get("login", "")
304
+ if author and author not in INTERNAL_USERS:
305
+ external_issues.append({
306
+ "number": issue.get("number", 0),
307
+ "title": issue.get("title", ""),
308
+ "author": author,
309
+ "url": issue.get("html_url", ""),
310
+ "is_pr": "pull_request" in issue,
311
+ })
312
+
313
+ # Clone traffic
314
+ traffic = _gh_api(f"repos/{repo}/traffic/clones")
315
+ clone_count = 0
316
+ clone_uniques = 0
317
+ if traffic:
318
+ clone_count = traffic.get("count", 0)
319
+ clone_uniques = traffic.get("uniques", 0)
320
+
321
+ # Referrers
322
+ referrers_data = _gh_api(f"repos/{repo}/traffic/popular/referrers")
323
+ referrers: List[Dict[str, Any]] = []
324
+ if isinstance(referrers_data, list):
325
+ for ref in referrers_data:
326
+ referrers.append({
327
+ "referrer": ref.get("referrer", ""),
328
+ "count": ref.get("count", 0),
329
+ "uniques": ref.get("uniques", 0),
330
+ })
331
+
332
+ # Update known state
333
+ if current_stargazers:
334
+ known.setdefault("stargazers", {})[repo] = sorted(current_stargazers)
335
+ if current_forkers:
336
+ known.setdefault("forkers", {})[repo] = sorted(current_forkers)
337
+
338
+ repos_data.append({
339
+ "repo": repo,
340
+ "stars": stars,
341
+ "forks": forks,
342
+ "new_stargazers": sorted(new_stargazers),
343
+ "new_forkers": sorted(new_forkers),
344
+ "external_issues": external_issues,
345
+ "clones_14d": clone_count,
346
+ "clone_uniques_14d": clone_uniques,
347
+ "referrers": referrers,
348
+ "score": _score_finding(
349
+ "own_repo_activity",
350
+ stars=stars,
351
+ relevance_signals=len(new_stargazers) + len(new_forkers) + len(external_issues),
352
+ ),
353
+ "category": "own_repo_activity",
354
+ })
355
+
356
+ _save_known(known)
357
+
358
+ return {
359
+ "cadence": "pulse",
360
+ "scanned_at": datetime.now(timezone.utc).isoformat(),
361
+ "repos": repos_data,
362
+ }
363
+
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # Hunter module: competitor users, adoption leads, pain threads
367
+ # ---------------------------------------------------------------------------
368
+
369
+
370
+ def _search_competitor_users(limit: int = 20) -> List[Dict[str, Any]]:
371
+ """Find repos using competitor GitHub Actions in their workflows."""
372
+ findings: List[Dict[str, Any]] = []
373
+ seen_repos = set()
374
+
375
+ for action in COMPETITOR_ACTIONS:
376
+ query = f"q=uses:+{action}+path:.github/workflows&per_page={min(limit, 30)}"
377
+ data = _gh_search(query, endpoint="search/code")
378
+ if not data:
379
+ continue
380
+
381
+ items = data.get("items", [])
382
+ for item in items:
383
+ repo_info = item.get("repository", {})
384
+ full_name = repo_info.get("full_name", "")
385
+ if not full_name or full_name in seen_repos:
386
+ continue
387
+ seen_repos.add(full_name)
388
+
389
+ stars = repo_info.get("stargazers_count", 0)
390
+ description = repo_info.get("description") or ""
391
+
392
+ findings.append({
393
+ "repo": full_name,
394
+ "stars": stars,
395
+ "description": description[:200],
396
+ "competitor_action": action,
397
+ "workflow_file": item.get("path", ""),
398
+ "url": repo_info.get("html_url", ""),
399
+ "category": "competitor_user",
400
+ "score": _score_finding(
401
+ "competitor_user",
402
+ stars=stars,
403
+ relevance_signals=1,
404
+ ),
405
+ "auto_ledger": False, # set below
406
+ })
407
+
408
+ # Mark high-score findings for auto-ledger
409
+ for f in findings:
410
+ if f["score"] >= 75:
411
+ f["auto_ledger"] = True
412
+
413
+ # Sort by score descending
414
+ findings.sort(key=lambda x: x["score"], reverse=True)
415
+ return findings[:limit]
416
+
417
+
418
+ def _search_adoption_leads(limit: int = 20) -> List[Dict[str, Any]]:
419
+ """Find repos with OpenAPI specs and repos using MCP + Claude."""
420
+ findings: List[Dict[str, Any]] = []
421
+ seen_repos = set()
422
+
423
+ # Repos with OpenAPI specs
424
+ query = f"q=openapi+path:api+extension:yaml&per_page={min(limit, 20)}"
425
+ data = _gh_search(query, endpoint="search/code")
426
+ if data:
427
+ for item in data.get("items", []):
428
+ repo_info = item.get("repository", {})
429
+ full_name = repo_info.get("full_name", "")
430
+ if not full_name or full_name in seen_repos:
431
+ continue
432
+ seen_repos.add(full_name)
433
+
434
+ stars = repo_info.get("stargazers_count", 0)
435
+ findings.append({
436
+ "repo": full_name,
437
+ "stars": stars,
438
+ "description": (repo_info.get("description") or "")[:200],
439
+ "spec_path": item.get("path", ""),
440
+ "url": repo_info.get("html_url", ""),
441
+ "category": "adoption_lead",
442
+ "subcategory": "openapi_spec",
443
+ "score": _score_finding("adoption_lead", stars=stars),
444
+ })
445
+
446
+ # Repos mentioning MCP + claude
447
+ query2 = f"q=MCP+server+claude+code&sort=updated&per_page={min(limit, 20)}"
448
+ data2 = _gh_search(query2, endpoint="search/repositories")
449
+ if data2:
450
+ for item in data2.get("items", []):
451
+ full_name = item.get("full_name", "")
452
+ if not full_name or full_name in seen_repos:
453
+ continue
454
+ seen_repos.add(full_name)
455
+
456
+ stars = item.get("stargazers_count", 0)
457
+ findings.append({
458
+ "repo": full_name,
459
+ "stars": stars,
460
+ "description": (item.get("description") or "")[:200],
461
+ "url": item.get("html_url", ""),
462
+ "category": "adoption_lead",
463
+ "subcategory": "mcp_ecosystem",
464
+ "score": _score_finding("adoption_lead", stars=stars, relevance_signals=2),
465
+ })
466
+
467
+ findings.sort(key=lambda x: x["score"], reverse=True)
468
+ return findings[:limit]
469
+
470
+
471
+ def _search_pain_threads(limit: int = 20) -> List[Dict[str, Any]]:
472
+ """Find open issues mentioning breaking changes and API pain."""
473
+ findings: List[Dict[str, Any]] = []
474
+
475
+ queries = [
476
+ "q=breaking+changes+API+is:open&sort=created",
477
+ "q=schema+drift+openapi+is:open&sort=created",
478
+ "q=api+contract+backward+compatible+is:open&sort=created",
479
+ ]
480
+
481
+ seen_urls = set()
482
+ for q in queries:
483
+ full_query = f"{q}&per_page={min(limit, 20)}"
484
+ data = _gh_search(full_query, endpoint="search/issues")
485
+ if not data:
486
+ continue
487
+
488
+ for item in data.get("items", []):
489
+ url = item.get("html_url", "")
490
+ if url in seen_urls:
491
+ continue
492
+ seen_urls.add(url)
493
+
494
+ title = item.get("title", "")
495
+ body = (item.get("body") or "")[:500]
496
+ reactions = item.get("reactions", {})
497
+ total_reactions = sum(
498
+ reactions.get(k, 0)
499
+ for k in ["+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes"]
500
+ )
501
+
502
+ # Calculate recency
503
+ created_at = item.get("created_at", "")
504
+ recency_days = 999.0
505
+ if created_at:
506
+ try:
507
+ created_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
508
+ delta = datetime.now(timezone.utc) - created_dt
509
+ recency_days = delta.total_seconds() / 86400
510
+ except (ValueError, TypeError):
511
+ pass
512
+
513
+ pain = extract_pain_points(title, body)
514
+
515
+ findings.append({
516
+ "title": title,
517
+ "url": url,
518
+ "repo": "/".join(url.split("/")[3:5]) if "github.com" in url else "",
519
+ "author": item.get("user", {}).get("login", ""),
520
+ "reactions": total_reactions,
521
+ "comments": item.get("comments", 0),
522
+ "created_at": created_at,
523
+ "recency_days": round(recency_days, 1),
524
+ "category": "pain_thread",
525
+ "pain_analysis": pain,
526
+ "score": _score_finding(
527
+ "pain_thread",
528
+ recency_days=recency_days,
529
+ relevance_signals=len(pain.get("matched_categories", [])) + min(3, total_reactions),
530
+ ),
531
+ })
532
+
533
+ findings.sort(key=lambda x: x["score"], reverse=True)
534
+ return findings[:limit]
535
+
536
+
537
+ def hunter_scan(limit: int = 20) -> Dict[str, Any]:
538
+ """Hunter scan: competitor users, adoption leads, pain threads."""
539
+ competitor_users = _search_competitor_users(limit=limit)
540
+ adoption_leads = _search_adoption_leads(limit=limit)
541
+ pain_threads = _search_pain_threads(limit=limit)
542
+
543
+ # Collect auto-ledger items
544
+ auto_ledger_items = [
545
+ f for f in competitor_users if f.get("auto_ledger")
546
+ ]
547
+
548
+ return {
549
+ "cadence": "hunter",
550
+ "scanned_at": datetime.now(timezone.utc).isoformat(),
551
+ "competitor_users": competitor_users,
552
+ "adoption_leads": adoption_leads,
553
+ "pain_threads": pain_threads,
554
+ "summary": {
555
+ "competitor_users_found": len(competitor_users),
556
+ "adoption_leads_found": len(adoption_leads),
557
+ "pain_threads_found": len(pain_threads),
558
+ "auto_ledger_count": len(auto_ledger_items),
559
+ },
560
+ "auto_ledger_items": auto_ledger_items,
561
+ }
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # Deep module (stub)
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ def deep_scan(limit: int = 20) -> Dict[str, Any]:
570
+ """Deep scan: ecosystem intel and pain clustering (stub).
571
+
572
+ Will include: competitor release tracking, MCP ecosystem mapping,
573
+ weekly pain clustering, trending repo analysis.
574
+ """
575
+ return {
576
+ "cadence": "deep",
577
+ "scanned_at": datetime.now(timezone.utc).isoformat(),
578
+ "status": "stub",
579
+ "message": "Deep scan not yet implemented. Use pulse or hunter.",
580
+ }
581
+
582
+
583
+ # ---------------------------------------------------------------------------
584
+ # Main orchestrator
585
+ # ---------------------------------------------------------------------------
586
+
587
+
588
+ def scan(cadence: str = "pulse", limit: int = 20) -> Dict[str, Any]:
589
+ """Run a GitHub scan at the specified cadence.
590
+
591
+ Args:
592
+ cadence: pulse, hunter, or deep.
593
+ limit: Max results per search query.
594
+
595
+ Returns:
596
+ Scan results dict with findings, scores, and metadata.
597
+ """
598
+ scan_start = datetime.now(timezone.utc)
599
+
600
+ if cadence == "pulse":
601
+ result = pulse_scan()
602
+ elif cadence == "hunter":
603
+ result = hunter_scan(limit=limit)
604
+ elif cadence == "deep":
605
+ result = deep_scan(limit=limit)
606
+ else:
607
+ return {"error": f"Unknown cadence: {cadence}. Supported: pulse, hunter, deep"}
608
+
609
+ # Persist results
610
+ _save_scan(result, cadence, scan_start)
611
+
612
+ return result
613
+
614
+
615
+ def _save_scan(result: Dict[str, Any], cadence: str, scan_time: datetime) -> Path:
616
+ """Save scan results to ~/.delimit/github_scans/{date}_{cadence}.json."""
617
+ SCANS_DIR.mkdir(parents=True, exist_ok=True)
618
+ filename = scan_time.strftime("%Y-%m-%dT%H%M%S") + f"_{cadence}.json"
619
+ path = SCANS_DIR / filename
620
+ path.write_text(json.dumps(result, indent=2, default=str))
621
+ logger.info("GitHub scan saved to %s", path)
622
+ return path