luckiest-co 1.0.47 → 1.0.49
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -17,7 +17,7 @@ import urllib.request
|
|
|
17
17
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
18
18
|
from typing import Any, Dict, List, Optional
|
|
19
19
|
|
|
20
|
-
from . import dates, log
|
|
20
|
+
from . import dates, env, http, log, schema
|
|
21
21
|
from .query import extract_core_subject
|
|
22
22
|
from .relevance import token_overlap_relevance
|
|
23
23
|
|
|
@@ -50,7 +50,7 @@ def _resolve_token(token: Optional[str] = None) -> Optional[str]:
|
|
|
50
50
|
"""Resolve GitHub auth token from argument, env, or gh CLI."""
|
|
51
51
|
if token:
|
|
52
52
|
return token
|
|
53
|
-
env_token =
|
|
53
|
+
env_token = env.read_secret_env("GITHUB_TOKEN")
|
|
54
54
|
if env_token:
|
|
55
55
|
return env_token
|
|
56
56
|
# Fallback: try gh CLI
|
|
@@ -81,8 +81,18 @@ def _fetch_json(
|
|
|
81
81
|
url: str,
|
|
82
82
|
token: Optional[str] = None,
|
|
83
83
|
timeout: int = 15,
|
|
84
|
+
failure_out: Optional[List[str]] = None,
|
|
84
85
|
) -> Optional[Dict[str, Any]]:
|
|
85
|
-
"""Fetch JSON from GitHub API. Returns None on failure.
|
|
86
|
+
"""Fetch JSON from GitHub API. Returns None on failure.
|
|
87
|
+
|
|
88
|
+
When ``failure_out`` is provided, a short human-readable reason is
|
|
89
|
+
appended for every failure branch so callers can distinguish transport
|
|
90
|
+
failures from genuinely empty results (issue #384).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def _note(msg: str) -> None:
|
|
94
|
+
if failure_out is not None:
|
|
95
|
+
failure_out.append(msg)
|
|
86
96
|
headers = {
|
|
87
97
|
"User-Agent": USER_AGENT,
|
|
88
98
|
"Accept": "application/vnd.github+json",
|
|
@@ -92,23 +102,28 @@ def _fetch_json(
|
|
|
92
102
|
|
|
93
103
|
req = urllib.request.Request(url, headers=headers)
|
|
94
104
|
try:
|
|
95
|
-
with
|
|
105
|
+
with http.open_request(req, timeout) as resp:
|
|
96
106
|
body = resp.read().decode("utf-8")
|
|
97
107
|
return json.loads(body)
|
|
98
108
|
except urllib.error.HTTPError as e:
|
|
99
109
|
if e.code == 403:
|
|
100
110
|
_log(f"403 rate limited or forbidden: {url}")
|
|
111
|
+
_note("HTTP 403: rate limited or forbidden")
|
|
101
112
|
return None
|
|
102
113
|
if e.code == 422:
|
|
103
114
|
_log(f"422 unprocessable: {url}")
|
|
115
|
+
_note("HTTP 422: unprocessable query")
|
|
104
116
|
return None
|
|
105
117
|
_log(f"HTTP {e.code}: {e.reason}")
|
|
118
|
+
_note(f"HTTP {e.code}: {e.reason}")
|
|
106
119
|
return None
|
|
107
120
|
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
|
108
121
|
_log(f"Network error: {e}")
|
|
122
|
+
_note(f"network error: {e}")
|
|
109
123
|
return None
|
|
110
124
|
except json.JSONDecodeError as e:
|
|
111
125
|
_log(f"JSON decode error: {e}")
|
|
126
|
+
_note(f"invalid JSON: {e}")
|
|
112
127
|
return None
|
|
113
128
|
|
|
114
129
|
|
|
@@ -151,6 +166,79 @@ def _compute_relevance(
|
|
|
151
166
|
return round(relevance, 2)
|
|
152
167
|
|
|
153
168
|
|
|
169
|
+
# GitHub search qualifiers the planner sometimes writes straight into the topic
|
|
170
|
+
# string (e.g. "open source AI stars:>1000 created:>2025-03-20"). They must not
|
|
171
|
+
# reach the query builder in `search_github`: it appends its own
|
|
172
|
+
# `created:>{from_date}`, and when two `created:` qualifiers collide GitHub
|
|
173
|
+
# honours the FIRST and silently ignores ours. The API then returns
|
|
174
|
+
# out-of-window items that `parse_github_response`'s date filter drops
|
|
175
|
+
# wholesale — a source that fetches results and reports zero (issue #949).
|
|
176
|
+
# Qualifiers may also arrive fully wrapped in one pair of parens/brackets/
|
|
177
|
+
# quotes ("(created:>2025-03-20)", '"stars:>1000"'), which an LLM planner
|
|
178
|
+
# plausibly emits; _WRAPPED_QUALIFIER_RE consumes the whole wrapper pair with
|
|
179
|
+
# the qualifier so no stray `()`/`""` residue reaches the query (issue #952).
|
|
180
|
+
# The plain regex's boundary also accepts wrapper openers so a qualifier with
|
|
181
|
+
# a missing closer ("(created:>2025-03-20") is still stripped rather than
|
|
182
|
+
# leaking into the query; only the stray opener survives, harmlessly.
|
|
183
|
+
QUALIFIER_KEYS = frozenset({
|
|
184
|
+
"archived", "assignee", "author", "base", "closed", "comments", "commenter",
|
|
185
|
+
"created", "fork", "forks", "head", "in", "interactions", "involves", "is",
|
|
186
|
+
"label", "language", "license", "linked", "mentions", "merged", "milestone",
|
|
187
|
+
"no", "org", "project", "pushed", "reactions", "repo", "review",
|
|
188
|
+
"review-requested", "reviewed-by", "size", "sort", "stars", "state", "team",
|
|
189
|
+
"topic", "topics", "type", "updated", "user",
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
_QUALIFIER_KEYS_ALT = "|".join(sorted(QUALIFIER_KEYS))
|
|
193
|
+
|
|
194
|
+
_QUALIFIER_RE = re.compile(
|
|
195
|
+
r"(?:(?<=[\s,;(\[\"'])|^)(?:" + _QUALIFIER_KEYS_ALT + r"):(?:[<>]=?)?(?:\"[^\"]*\"|[^\s,;()\[\]]+)[,;]?",
|
|
196
|
+
re.IGNORECASE,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# A qualifier fully wrapped in a single pair of parens/brackets/quotes. The
|
|
200
|
+
# wrapper pair is consumed with the qualifier, so stripping leaves no residue.
|
|
201
|
+
# An unbalanced quote (e.g. `label:"bug`) is not a wrapper shape and is left
|
|
202
|
+
# to _QUALIFIER_RE, which strips the qualifier as before.
|
|
203
|
+
_WRAPPED_QUALIFIER_RE = re.compile(
|
|
204
|
+
r"[\(\[\"'](?:" + _QUALIFIER_KEYS_ALT + r"):(?:[<>]=?)?(?:\"[^\"]*\"|[^\s,;()\[\]]+)[\)\]\"']",
|
|
205
|
+
re.IGNORECASE,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Empty wrapper pairs left behind when a nested wrapper collapses (e.g. the
|
|
209
|
+
# `()` from "((created:>2025-03-20))"). Removed to fixpoint; a pair enclosing
|
|
210
|
+
# real text stays.
|
|
211
|
+
_EMPTY_WRAPPER_RE = re.compile(r"\(\s*\)|\[\s*\]|\"\s*\"|'\s*'")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def strip_search_qualifiers(text: str) -> str:
|
|
215
|
+
"""Strip GitHub search qualifiers from a topic, leaving plain-language text.
|
|
216
|
+
|
|
217
|
+
Whitespace is collapsed. Returns an empty string when the topic was nothing
|
|
218
|
+
but qualifiers; callers must handle that rather than searching on an empty
|
|
219
|
+
term, which would match the entire site.
|
|
220
|
+
"""
|
|
221
|
+
stripped = _WRAPPED_QUALIFIER_RE.sub(" ", text)
|
|
222
|
+
stripped = _QUALIFIER_RE.sub(" ", stripped)
|
|
223
|
+
while True:
|
|
224
|
+
cleaned = _EMPTY_WRAPPER_RE.sub("", stripped)
|
|
225
|
+
if cleaned == stripped:
|
|
226
|
+
break
|
|
227
|
+
stripped = cleaned
|
|
228
|
+
return " ".join(stripped.split())
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# Bound topic fragments in logs/error envelopes so fanout cannot blow them up (#954).
|
|
232
|
+
_DIAGNOSTIC_TOPIC_LIMIT = 120
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _truncate_diagnostic(text: str, limit: int = _DIAGNOSTIC_TOPIC_LIMIT) -> str:
|
|
236
|
+
"""Cap a topic fragment used in logs or error envelopes."""
|
|
237
|
+
if len(text) <= limit:
|
|
238
|
+
return text
|
|
239
|
+
return f"{text[:limit]}..."
|
|
240
|
+
|
|
241
|
+
|
|
154
242
|
def search_github(
|
|
155
243
|
topic: str,
|
|
156
244
|
from_date: str,
|
|
@@ -178,6 +266,26 @@ def search_github(
|
|
|
178
266
|
"""
|
|
179
267
|
count = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
|
|
180
268
|
core = extract_core_subject(topic)
|
|
269
|
+
plain_core = strip_search_qualifiers(core)
|
|
270
|
+
if not plain_core:
|
|
271
|
+
# A qualifier-only (or empty) topic leaves nothing to search on.
|
|
272
|
+
# Skip the network rather than querying an empty term, which would
|
|
273
|
+
# match the whole site (#949). Return a clean empty envelope, not an
|
|
274
|
+
# error, so the pipeline records NO_RESULTS instead of ERROR (#953)
|
|
275
|
+
# and GitHub is not marked as a failed attempt. Mixed-topic strip
|
|
276
|
+
# logs below stay bounded by _truncate_diagnostic (#954).
|
|
277
|
+
_log("Topic contained only search qualifiers or was empty; nothing to search")
|
|
278
|
+
return {
|
|
279
|
+
"items": [],
|
|
280
|
+
"context": {"core": core, "from_date": from_date,
|
|
281
|
+
"to_date": to_date, "count": count},
|
|
282
|
+
}
|
|
283
|
+
if plain_core != core:
|
|
284
|
+
_log(
|
|
285
|
+
"Stripped search qualifiers: "
|
|
286
|
+
f"'{_truncate_diagnostic(core)}' -> '{_truncate_diagnostic(plain_core)}'"
|
|
287
|
+
)
|
|
288
|
+
core = plain_core
|
|
181
289
|
resolved_token = _resolve_token(token)
|
|
182
290
|
authed = bool(resolved_token)
|
|
183
291
|
if not authed:
|
|
@@ -188,20 +296,70 @@ def search_github(
|
|
|
188
296
|
_log(f"Searching for '{core}' (raw: '{topic}', since {from_date}, count={count})")
|
|
189
297
|
|
|
190
298
|
# Build search query with date filter
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
"
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
299
|
+
base_q = f"{core} created:>{from_date}"
|
|
300
|
+
|
|
301
|
+
def _search(qualifier: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
302
|
+
q = f"{base_q} {qualifier}" if qualifier else base_q
|
|
303
|
+
params = {
|
|
304
|
+
"q": q,
|
|
305
|
+
"sort": "reactions",
|
|
306
|
+
"order": "desc",
|
|
307
|
+
"per_page": str(min(count, 100)),
|
|
308
|
+
}
|
|
309
|
+
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
|
|
310
|
+
return _fetch_json(url, token=resolved_token, timeout=30,
|
|
311
|
+
failure_out=fetch_failures)
|
|
312
|
+
|
|
313
|
+
fetch_failures: List[str] = []
|
|
314
|
+
partition_failure: Optional[str] = None
|
|
315
|
+
if authed:
|
|
316
|
+
# GitHub rejects AUTHENTICATED /search/issues queries that carry
|
|
317
|
+
# neither `is:issue` nor `is:pull-request` (HTTP 422). Anonymous
|
|
318
|
+
# queries are still grandfathered, which is why this only bites once
|
|
319
|
+
# a token is present -- including via the `gh auth token` fallback.
|
|
320
|
+
#
|
|
321
|
+
# Appending a single qualifier would silently halve the corpus: for
|
|
322
|
+
# "rust async created:>2026-08-02" GitHub reports 1,072 issues and
|
|
323
|
+
# 7,044 pull requests against 8,115 combined, so `is:issue` alone
|
|
324
|
+
# drops ~87% of matches. Query both and merge instead, which keeps
|
|
325
|
+
# coverage AND the authenticated rate limit.
|
|
326
|
+
merged: List[Dict[str, Any]] = []
|
|
327
|
+
seen_ids = set()
|
|
328
|
+
failed_qualifiers: List[str] = []
|
|
329
|
+
for qualifier in ("is:issue", "is:pull-request"):
|
|
330
|
+
part = _search(qualifier)
|
|
331
|
+
if part is None:
|
|
332
|
+
failed_qualifiers.append(qualifier)
|
|
333
|
+
continue
|
|
334
|
+
for item in part.get("items", []):
|
|
335
|
+
item_id = item.get("id")
|
|
336
|
+
if item_id in seen_ids:
|
|
337
|
+
continue
|
|
338
|
+
seen_ids.add(item_id)
|
|
339
|
+
merged.append(item)
|
|
340
|
+
# Both sub-queries are reaction-sorted; the merge is not, so re-sort
|
|
341
|
+
# before truncating or the second query's tail would outrank the
|
|
342
|
+
# first query's head.
|
|
343
|
+
merged.sort(key=lambda i: (i.get("reactions") or {}).get("total_count", 0),
|
|
344
|
+
reverse=True)
|
|
345
|
+
data = {"items": merged[:count]} if merged else None
|
|
346
|
+
if failed_qualifiers:
|
|
347
|
+
partition_failure = (
|
|
348
|
+
f"GitHub partition(s) failed: {', '.join(failed_qualifiers)}"
|
|
349
|
+
+ (f" ({fetch_failures[-1]})" if fetch_failures else "")
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
data = _search(None)
|
|
201
353
|
if not data:
|
|
202
354
|
envelope = {"items": [], "context": {"core": core, "from_date": from_date,
|
|
203
355
|
"to_date": to_date, "count": count}}
|
|
204
|
-
if
|
|
356
|
+
if authed and partition_failure:
|
|
357
|
+
envelope["error"] = partition_failure
|
|
358
|
+
elif authed and fetch_failures:
|
|
359
|
+
# Authenticated transport failures must not be laundered into a
|
|
360
|
+
# clean no-results outcome (issue #384).
|
|
361
|
+
envelope["error"] = f"GitHub API request failed: {fetch_failures[-1]}"
|
|
362
|
+
elif not authed:
|
|
205
363
|
# Could be the anon rate limit (403) or an unprocessable query (422)
|
|
206
364
|
# -- _fetch_json maps both to None. Don't over-claim which; suggest a
|
|
207
365
|
# token since that fixes the common (rate-limit) case.
|
|
@@ -214,7 +372,7 @@ def search_github(
|
|
|
214
372
|
raw_items = data.get("items", [])
|
|
215
373
|
_log(f"Found {len(raw_items)} issues/PRs")
|
|
216
374
|
|
|
217
|
-
|
|
375
|
+
envelope: Dict[str, Any] = {
|
|
218
376
|
"items": raw_items,
|
|
219
377
|
"context": {
|
|
220
378
|
"core": core,
|
|
@@ -223,6 +381,9 @@ def search_github(
|
|
|
223
381
|
"count": count,
|
|
224
382
|
},
|
|
225
383
|
}
|
|
384
|
+
if partition_failure:
|
|
385
|
+
envelope["error"] = partition_failure
|
|
386
|
+
return envelope
|
|
226
387
|
|
|
227
388
|
|
|
228
389
|
def parse_github_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
@@ -249,7 +410,7 @@ def parse_github_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
249
410
|
title = item.get("title", "")
|
|
250
411
|
body_text = item.get("body") or ""
|
|
251
412
|
reactions_total = item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0
|
|
252
|
-
comment_count = item.get("comments"
|
|
413
|
+
comment_count = item.get("comments") or 0
|
|
253
414
|
labels = [
|
|
254
415
|
lbl.get("name", "") for lbl in (item.get("labels") or [])
|
|
255
416
|
if isinstance(lbl, dict)
|
|
@@ -407,6 +568,8 @@ PERSON_DEPTH_LIMITS = {
|
|
|
407
568
|
"deep": {"pr_pages": 2, "own_repos": 5, "external_repos": 15},
|
|
408
569
|
}
|
|
409
570
|
|
|
571
|
+
PERSON_EVENTS_PER_PAGE = 100
|
|
572
|
+
|
|
410
573
|
|
|
411
574
|
def _fetch_readme_snippet(repo: str, token: str, max_chars: int = 500) -> Optional[str]:
|
|
412
575
|
"""Fetch README content for a repo, truncated to first ~max_chars."""
|
|
@@ -420,7 +583,7 @@ def _fetch_readme_snippet(repo: str, token: str, max_chars: int = 500) -> Option
|
|
|
420
583
|
|
|
421
584
|
req = urllib.request.Request(url, headers=headers)
|
|
422
585
|
try:
|
|
423
|
-
with
|
|
586
|
+
with http.open_request(req, 10) as resp:
|
|
424
587
|
raw = resp.read().decode("utf-8", errors="replace")
|
|
425
588
|
except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError):
|
|
426
589
|
return None
|
|
@@ -468,7 +631,7 @@ def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
|
|
|
468
631
|
result["top_feature_request"] = {
|
|
469
632
|
"title": item.get("title", ""),
|
|
470
633
|
"reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
|
|
471
|
-
"comments": item.get("comments"
|
|
634
|
+
"comments": item.get("comments") or 0,
|
|
472
635
|
"url": item.get("html_url", ""),
|
|
473
636
|
}
|
|
474
637
|
elif feat_data and feat_data.get("total_count", 0) == 0:
|
|
@@ -481,7 +644,7 @@ def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
|
|
|
481
644
|
result["top_feature_request"] = {
|
|
482
645
|
"title": item.get("title", ""),
|
|
483
646
|
"reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
|
|
484
|
-
"comments": item.get("comments"
|
|
647
|
+
"comments": item.get("comments") or 0,
|
|
485
648
|
"url": item.get("html_url", ""),
|
|
486
649
|
}
|
|
487
650
|
|
|
@@ -494,7 +657,7 @@ def _fetch_top_issues(repo: str, token: str) -> Dict[str, Any]:
|
|
|
494
657
|
result["top_complaint"] = {
|
|
495
658
|
"title": item.get("title", ""),
|
|
496
659
|
"reactions": item.get("reactions", {}).get("total_count", 0) if isinstance(item.get("reactions"), dict) else 0,
|
|
497
|
-
"comments": item.get("comments"
|
|
660
|
+
"comments": item.get("comments") or 0,
|
|
498
661
|
"url": item.get("html_url", ""),
|
|
499
662
|
}
|
|
500
663
|
|
|
@@ -525,6 +688,45 @@ def _format_stars(n: int) -> str:
|
|
|
525
688
|
return str(n)
|
|
526
689
|
|
|
527
690
|
|
|
691
|
+
def refetch_datum(item: schema.SourceItem | None, datum_key: str) -> dict[str, Any]:
|
|
692
|
+
"""Re-fetch one repository counter through the shared HTTP wrapper.
|
|
693
|
+
|
|
694
|
+
``datum_key`` is either the literal ``"stars"`` (item-level claim; the
|
|
695
|
+
repo derives from the grounding item) or an ``owner/repo`` slug
|
|
696
|
+
(candidate-enrichment claim; the repo itself is the refetch subject and
|
|
697
|
+
the item is not consulted, so it may be ``None``).
|
|
698
|
+
"""
|
|
699
|
+
if re.fullmatch(r"[^/\s]+/[^/\s]+", datum_key):
|
|
700
|
+
repo = datum_key
|
|
701
|
+
elif datum_key != "stars":
|
|
702
|
+
raise KeyError(f"Unsupported GitHub datum: {datum_key}")
|
|
703
|
+
else:
|
|
704
|
+
if item is None:
|
|
705
|
+
raise ValueError("Item-level star refetch requires the grounding item")
|
|
706
|
+
repo = item.container or ""
|
|
707
|
+
if not re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
|
|
708
|
+
match = re.match(r"https?://github\.com/([^/]+/[^/#?]+)", item.url)
|
|
709
|
+
repo = match.group(1).removesuffix(".git") if match else ""
|
|
710
|
+
if not repo:
|
|
711
|
+
raise ValueError("GitHub item has no owner/repository reference")
|
|
712
|
+
headers = {"Accept": "application/vnd.github+json"}
|
|
713
|
+
token = _resolve_token()
|
|
714
|
+
if token:
|
|
715
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
716
|
+
data = http.request(
|
|
717
|
+
"GET", f"https://api.github.com/repos/{repo}",
|
|
718
|
+
headers=headers, timeout=10, retries=2,
|
|
719
|
+
)
|
|
720
|
+
if not isinstance(data, dict) or not isinstance(data.get("stargazers_count"), int):
|
|
721
|
+
raise KeyError("GitHub star count was not returned")
|
|
722
|
+
fallback_url = item.url if item is not None else f"https://github.com/{repo}"
|
|
723
|
+
return {
|
|
724
|
+
"value": data["stargazers_count"],
|
|
725
|
+
"url": str(data.get("html_url") or fallback_url),
|
|
726
|
+
"timestamp": data.get("updated_at"),
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
|
|
528
730
|
def search_github_person(
|
|
529
731
|
username: str,
|
|
530
732
|
from_date: str,
|
|
@@ -564,6 +766,17 @@ def search_github_person(
|
|
|
564
766
|
_log(f"Found {total_prs} total PRs, {merged_count} merged")
|
|
565
767
|
|
|
566
768
|
if total_prs == 0 and merged_count == 0:
|
|
769
|
+
# An empty PR search can mean no PRs in the window or an account that
|
|
770
|
+
# GitHub's issue index cannot search. Public PushEvents provide an
|
|
771
|
+
# actor-attributed fallback for either case.
|
|
772
|
+
search_unavailable = total_data is None or merged_data is None
|
|
773
|
+
recent = _person_recent_pushes(
|
|
774
|
+
username, from_date, to_date, limits, resolved_token,
|
|
775
|
+
)
|
|
776
|
+
if recent:
|
|
777
|
+
reason = "account not searchable" if search_unavailable else "no PRs in window"
|
|
778
|
+
_log(f"PR search empty ({reason}); public events returned {len(recent)} items")
|
|
779
|
+
return recent
|
|
567
780
|
_log("No PRs found, falling back to keyword search")
|
|
568
781
|
return []
|
|
569
782
|
|
|
@@ -681,7 +894,7 @@ def search_github_person(
|
|
|
681
894
|
"id": f"GH{idx}",
|
|
682
895
|
"title": f"{repo} ({stars_str} stars) - {pr_count} PRs merged",
|
|
683
896
|
"url": f"https://github.com/{repo}",
|
|
684
|
-
"date": releases
|
|
897
|
+
"date": _snapshot_date(releases, from_date, to_date),
|
|
685
898
|
"author": username,
|
|
686
899
|
"source": "github",
|
|
687
900
|
"score": stars,
|
|
@@ -696,6 +909,7 @@ def search_github_person(
|
|
|
696
909
|
"comment_count": pr_count,
|
|
697
910
|
"reactions": stars,
|
|
698
911
|
"is_pr": False,
|
|
912
|
+
"date_is_snapshot": not _has_in_window_release(releases, from_date, to_date),
|
|
699
913
|
},
|
|
700
914
|
})
|
|
701
915
|
|
|
@@ -739,7 +953,7 @@ def search_github_person(
|
|
|
739
953
|
"id": f"GH{idx}",
|
|
740
954
|
"title": f"{repo_name} ({stars_str} stars) - own project, {open_issues} open issues",
|
|
741
955
|
"url": f"https://github.com/{repo_name}",
|
|
742
|
-
"date": releases
|
|
956
|
+
"date": _snapshot_date(releases, from_date, to_date),
|
|
743
957
|
"author": username,
|
|
744
958
|
"source": "github",
|
|
745
959
|
"score": stars,
|
|
@@ -754,6 +968,7 @@ def search_github_person(
|
|
|
754
968
|
"comment_count": open_issues,
|
|
755
969
|
"reactions": stars,
|
|
756
970
|
"is_pr": False,
|
|
971
|
+
"date_is_snapshot": not _has_in_window_release(releases, from_date, to_date),
|
|
757
972
|
},
|
|
758
973
|
})
|
|
759
974
|
|
|
@@ -763,6 +978,167 @@ def search_github_person(
|
|
|
763
978
|
return items
|
|
764
979
|
|
|
765
980
|
|
|
981
|
+
def _person_recent_pushes(
|
|
982
|
+
username: str,
|
|
983
|
+
from_date: str,
|
|
984
|
+
to_date: str,
|
|
985
|
+
limits: Dict[str, int],
|
|
986
|
+
token: str,
|
|
987
|
+
) -> List[Dict[str, Any]]:
|
|
988
|
+
"""Return repos the selected actor publicly pushed inside the window."""
|
|
989
|
+
latest_by_repo: Dict[str, Dict[str, str]] = {}
|
|
990
|
+
encoded_username = urllib.parse.quote(username, safe="")
|
|
991
|
+
|
|
992
|
+
page = 1
|
|
993
|
+
while True:
|
|
994
|
+
url = (
|
|
995
|
+
f"https://api.github.com/users/{encoded_username}/events/public"
|
|
996
|
+
f"?per_page={PERSON_EVENTS_PER_PAGE}&page={page}"
|
|
997
|
+
)
|
|
998
|
+
data = _fetch_json(url, token=token, timeout=15)
|
|
999
|
+
if not data or not isinstance(data, list):
|
|
1000
|
+
break
|
|
1001
|
+
|
|
1002
|
+
reached_before_window = False
|
|
1003
|
+
for event in data:
|
|
1004
|
+
created_at = event.get("created_at")
|
|
1005
|
+
pushed = _parse_date(created_at)
|
|
1006
|
+
if not pushed:
|
|
1007
|
+
continue
|
|
1008
|
+
if pushed < from_date:
|
|
1009
|
+
reached_before_window = True
|
|
1010
|
+
break
|
|
1011
|
+
if pushed > to_date or event.get("type") != "PushEvent":
|
|
1012
|
+
continue
|
|
1013
|
+
|
|
1014
|
+
actor = event.get("actor")
|
|
1015
|
+
actor_login = actor.get("login", "") if isinstance(actor, dict) else ""
|
|
1016
|
+
if actor_login.casefold() != username.casefold():
|
|
1017
|
+
continue
|
|
1018
|
+
|
|
1019
|
+
repo = event.get("repo")
|
|
1020
|
+
full_name = repo.get("name", "") if isinstance(repo, dict) else ""
|
|
1021
|
+
if not re.fullmatch(r"[^/\s]+/[^/\s]+", full_name):
|
|
1022
|
+
continue
|
|
1023
|
+
|
|
1024
|
+
previous = latest_by_repo.get(full_name)
|
|
1025
|
+
if previous is None or created_at > previous["created_at"]:
|
|
1026
|
+
latest_by_repo[full_name] = {
|
|
1027
|
+
"full_name": full_name,
|
|
1028
|
+
"pushed": pushed,
|
|
1029
|
+
"created_at": created_at,
|
|
1030
|
+
"actor": actor_login,
|
|
1031
|
+
"event_id": str(event.get("id") or ""),
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if reached_before_window or len(data) < PERSON_EVENTS_PER_PAGE:
|
|
1035
|
+
break
|
|
1036
|
+
page += 1
|
|
1037
|
+
|
|
1038
|
+
if not latest_by_repo:
|
|
1039
|
+
return []
|
|
1040
|
+
|
|
1041
|
+
recent = sorted(
|
|
1042
|
+
latest_by_repo.values(),
|
|
1043
|
+
key=lambda r: r["created_at"],
|
|
1044
|
+
reverse=True,
|
|
1045
|
+
)
|
|
1046
|
+
_log(
|
|
1047
|
+
f"Public events: {len(recent)} actor-attributed repos pushed in window, "
|
|
1048
|
+
"loading repository metadata for ranking"
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
repo_info: Dict[str, Dict[str, Any]] = {}
|
|
1052
|
+
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
1053
|
+
info_futures = {
|
|
1054
|
+
executor.submit(_fetch_repo_info, r["full_name"], token): r["full_name"]
|
|
1055
|
+
for r in recent
|
|
1056
|
+
}
|
|
1057
|
+
for future in as_completed(info_futures):
|
|
1058
|
+
name = info_futures[future]
|
|
1059
|
+
try:
|
|
1060
|
+
repo_info[name] = future.result(timeout=20) or {}
|
|
1061
|
+
except Exception as exc:
|
|
1062
|
+
_log(f"Push-event repo metadata failed for {name}: {exc}")
|
|
1063
|
+
repo_info[name] = {}
|
|
1064
|
+
|
|
1065
|
+
recent.sort(
|
|
1066
|
+
key=lambda r: (
|
|
1067
|
+
repo_info.get(r["full_name"], {}).get("stars", 0),
|
|
1068
|
+
r["created_at"],
|
|
1069
|
+
),
|
|
1070
|
+
reverse=True,
|
|
1071
|
+
)
|
|
1072
|
+
selected = recent[:limits["own_repos"]]
|
|
1073
|
+
|
|
1074
|
+
enrichments: Dict[str, Dict[str, Any]] = {}
|
|
1075
|
+
_log(f"Public events: enriching {len(selected)} top-ranked repositories")
|
|
1076
|
+
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
1077
|
+
enrichment_futures = {
|
|
1078
|
+
executor.submit(_enrich_own_repo, r["full_name"], token): r["full_name"]
|
|
1079
|
+
for r in selected
|
|
1080
|
+
}
|
|
1081
|
+
for future in as_completed(enrichment_futures):
|
|
1082
|
+
name = enrichment_futures[future]
|
|
1083
|
+
try:
|
|
1084
|
+
enrichments[name] = future.result(timeout=25)
|
|
1085
|
+
except Exception as exc:
|
|
1086
|
+
_log(f"Push-event enrichment failed for {name}: {exc}")
|
|
1087
|
+
enrichments[name] = {}
|
|
1088
|
+
|
|
1089
|
+
items: List[Dict[str, Any]] = []
|
|
1090
|
+
for idx, repo in enumerate(selected, start=1):
|
|
1091
|
+
name = repo["full_name"]
|
|
1092
|
+
info = repo_info.get(name, {})
|
|
1093
|
+
stars = info.get("stars", 0)
|
|
1094
|
+
stars_str = _format_stars(stars)
|
|
1095
|
+
open_issues = info.get("open_issues", 0)
|
|
1096
|
+
enrichment = enrichments.get(name, {})
|
|
1097
|
+
readme = enrichment.get("readme")
|
|
1098
|
+
releases = enrichment.get("releases", [])
|
|
1099
|
+
|
|
1100
|
+
snippet_parts = [
|
|
1101
|
+
f"@{repo['actor']} pushed {name} on {repo['pushed']} "
|
|
1102
|
+
f"({stars_str} stars, {open_issues} open issues)"
|
|
1103
|
+
]
|
|
1104
|
+
if info.get("description"):
|
|
1105
|
+
snippet_parts.append(f" {info['description']}")
|
|
1106
|
+
if readme:
|
|
1107
|
+
snippet_parts.append(f" README: {readme[:300]}")
|
|
1108
|
+
for rel in releases[:2]:
|
|
1109
|
+
body_preview = f" - {rel['body'][:150]}" if rel.get("body") else ""
|
|
1110
|
+
snippet_parts.append(f" Release: {rel['name']} ({rel['date']}){body_preview}")
|
|
1111
|
+
|
|
1112
|
+
items.append({
|
|
1113
|
+
"id": f"GH{idx}",
|
|
1114
|
+
"title": f"@{repo['actor']} pushed {name} on {repo['pushed']}",
|
|
1115
|
+
"url": f"https://github.com/{name}",
|
|
1116
|
+
"date": repo["pushed"],
|
|
1117
|
+
"author": repo["actor"],
|
|
1118
|
+
"source": "github",
|
|
1119
|
+
"score": stars,
|
|
1120
|
+
"container": name,
|
|
1121
|
+
"snippet": "\n".join(snippet_parts),
|
|
1122
|
+
"relevance": min(0.9, 0.6 + math.log1p(stars) / 30),
|
|
1123
|
+
"why_relevant": (
|
|
1124
|
+
f"GitHub activity: @{repo['actor']} pushed {name} on {repo['pushed']} "
|
|
1125
|
+
f"({stars_str} stars)"
|
|
1126
|
+
),
|
|
1127
|
+
"engagement": {"stars": stars, "comments": open_issues},
|
|
1128
|
+
"metadata": {
|
|
1129
|
+
"labels": ["person-profile", "recent-push"],
|
|
1130
|
+
"state": "open",
|
|
1131
|
+
"comment_count": open_issues,
|
|
1132
|
+
"reactions": stars,
|
|
1133
|
+
"is_pr": False,
|
|
1134
|
+
"event_type": "PushEvent",
|
|
1135
|
+
"event_id": repo["event_id"],
|
|
1136
|
+
},
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
return items
|
|
1140
|
+
|
|
1141
|
+
|
|
766
1142
|
def _enrich_external_repo(repo: str, token: str) -> Dict[str, Any]:
|
|
767
1143
|
"""Fetch star count + releases for an external repo."""
|
|
768
1144
|
info = _fetch_repo_info(repo, token)
|
|
@@ -782,6 +1158,29 @@ def _enrich_own_repo(repo: str, token: str) -> Dict[str, Any]:
|
|
|
782
1158
|
# Project-mode search: fetch comprehensive data for specific repos
|
|
783
1159
|
# ---------------------------------------------------------------------------
|
|
784
1160
|
|
|
1161
|
+
def _snapshot_date(releases: List[Dict[str, Any]], from_date: str, to_date: str) -> str:
|
|
1162
|
+
"""Date for a live repo snapshot item (stars, issues, PRs read now).
|
|
1163
|
+
|
|
1164
|
+
The newest release's date when it falls in the window, else ``to_date``.
|
|
1165
|
+
An older release date would make the report's date filter drop a repo the
|
|
1166
|
+
user pinned: Ghostty's newest release object is its 2022 "tip" nightly.
|
|
1167
|
+
"""
|
|
1168
|
+
if _has_in_window_release(releases, from_date, to_date):
|
|
1169
|
+
return releases[0]["date"]
|
|
1170
|
+
return to_date
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _has_in_window_release(releases: List[Dict[str, Any]], from_date: str, to_date: str) -> bool:
|
|
1174
|
+
"""True when the newest release date is a real in-window event.
|
|
1175
|
+
|
|
1176
|
+
False means ``_snapshot_date`` fell back to ``to_date``: that date keeps the
|
|
1177
|
+
item through the date filter but says nothing about recency, so the item is
|
|
1178
|
+
marked ``date_is_snapshot`` and ranks on neutral freshness.
|
|
1179
|
+
"""
|
|
1180
|
+
release_date = releases[0].get("date") if releases else None
|
|
1181
|
+
return bool(release_date and from_date <= release_date <= to_date)
|
|
1182
|
+
|
|
1183
|
+
|
|
785
1184
|
def search_github_project(
|
|
786
1185
|
repos: List[str],
|
|
787
1186
|
from_date: str,
|
|
@@ -859,7 +1258,7 @@ def search_github_project(
|
|
|
859
1258
|
"id": f"GH{idx + 1}",
|
|
860
1259
|
"title": f"{repo} ({stars_str} stars) - {open_issues} open issues",
|
|
861
1260
|
"url": f"https://github.com/{repo}",
|
|
862
|
-
"date": releases
|
|
1261
|
+
"date": _snapshot_date(releases, from_date, to_date),
|
|
863
1262
|
"author": repo.split("/")[0],
|
|
864
1263
|
"source": "github",
|
|
865
1264
|
"score": stars,
|
|
@@ -874,6 +1273,7 @@ def search_github_project(
|
|
|
874
1273
|
"comment_count": open_issues,
|
|
875
1274
|
"reactions": stars,
|
|
876
1275
|
"is_pr": False,
|
|
1276
|
+
"date_is_snapshot": not _has_in_window_release(releases, from_date, to_date),
|
|
877
1277
|
"github_stars": {repo: stars},
|
|
878
1278
|
},
|
|
879
1279
|
})
|
|
@@ -931,6 +1331,7 @@ def enrich_candidates_with_stars(
|
|
|
931
1331
|
token: Optional[str] = None,
|
|
932
1332
|
already_enriched: Optional[set] = None,
|
|
933
1333
|
max_repos: int = 10,
|
|
1334
|
+
collect_map: Optional[Dict[str, int]] = None,
|
|
934
1335
|
) -> int:
|
|
935
1336
|
"""Annotate candidates with live GitHub star counts.
|
|
936
1337
|
|
|
@@ -964,9 +1365,22 @@ def enrich_candidates_with_stars(
|
|
|
964
1365
|
except Exception:
|
|
965
1366
|
pass
|
|
966
1367
|
|
|
1368
|
+
if collect_map is not None:
|
|
1369
|
+
collect_map.update(star_map)
|
|
967
1370
|
if not star_map:
|
|
968
1371
|
return 0
|
|
969
1372
|
|
|
1373
|
+
return apply_star_map(candidates, star_map)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def apply_star_map(candidates: List[Any], star_map: Dict[str, int]) -> int:
|
|
1377
|
+
"""Annotate candidates from a repo->stars map (fetch/apply split).
|
|
1378
|
+
|
|
1379
|
+
Split out so offline replay (the eval harness) can apply a recorded map
|
|
1380
|
+
without any network or gh-credential access.
|
|
1381
|
+
"""
|
|
1382
|
+
if not star_map:
|
|
1383
|
+
return 0
|
|
970
1384
|
# Annotate candidates
|
|
971
1385
|
enriched_count = 0
|
|
972
1386
|
for c in candidates:
|