delimit-cli 3.15.13 → 4.0.0
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.
- package/gateway/ai/license_core.py +2 -1
- package/gateway/ai/notify.py +8 -8
- package/gateway/ai/server.py +7 -15
- package/gateway/ai/swarm.py +2 -2
- package/gateway/core/contract_ledger.py +1 -1
- package/gateway/core/dependency_graph.py +1 -1
- package/gateway/core/dependency_manifest.py +1 -1
- package/gateway/core/event_backbone.py +2 -2
- package/gateway/core/event_schema.py +1 -1
- package/gateway/core/impact_analyzer.py +1 -1
- package/package.json +7 -1
- package/scripts/security-check.sh +50 -6
- package/gateway/ai/cross_model_audit.py +0 -600
- package/gateway/ai/github_scanner.py +0 -622
- package/gateway/ai/handoff_receipts.py +0 -409
- package/gateway/ai/reddit_scanner.py +0 -562
- package/gateway/ai/session_phoenix.py +0 -371
- package/gateway/ai/toolcard_cache.py +0 -327
|
@@ -1,622 +0,0 @@
|
|
|
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
|