Compare commits

...
16 Commits
Author SHA1 Message Date
Nubenetes Bot f4fb5fe19b release: v2.6.7 — Reduce enrichment CI time from 12min to ~2min 2026-06-19 10:28:39 +02:00
Inaki f46886abf3 Merge pull request #358 from nubenetes/feat/fix-enrichment-speed
perf: reduce enrichment from 12min to ~2min
2026-06-19 10:28:22 +02:00
Nubenetes BotandClaude Sonnet 4.6 6ccda021e2 perf: reduce enrichment from 12min to ~2min (1 API call/repo, 200 limit)
- Consolidate 2 GitHub API calls per repo into 1: open_issues_count
  already includes PRs on GitHub, second /pulls call was redundant
- Reduce MAX_REPOS_DEFAULT 500→200: sufficient for meaningful enrichment,
  200 × 0.5s = ~100s vs 500 × 1.5s = ~12.5min in CI
- Reduce GITHUB_RATE_DELAY 0.75s→0.5s: still safely under 5000 req/hr
- Add 429 rate-limit backoff (5s sleep instead of crashing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:28:07 +02:00
Nubenetes Bot 3f44948119 merge: back-merge master v2.6.6 into develop 2026-06-19 10:16:41 +02:00
Nubenetes Bot fe024b4fd7 release: v2.6.6 — Fix CNCF landscape API (SPA→GitHub topic search) 2026-06-19 10:16:37 +02:00
Inaki bdcedd6950 Merge pull request #357 from nubenetes/feat/fix-cncf-api
fix: CNCF landscape API now SPA — use GitHub topic search instead
2026-06-19 10:16:20 +02:00
Nubenetes BotandClaude Sonnet 4.6 6769fff528 fix: replace CNCF landscape SPA endpoint with GitHub topic search
The legacy landscape.cncf.io/api/items endpoint now returns HTML (SPA)
instead of JSON. Switch to GitHub Search API querying cncf-graduated,
cncf-incubating, and cncf-sandbox topics — reliable and uses existing
GH_TOKEN auth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:16:07 +02:00
Nubenetes Bot 5b00ed4824 merge: back-merge master v2.6.5 into develop 2026-06-19 09:51:32 +02:00
Nubenetes Bot 7e85ec6bdd release: v2.6.5 — Fix MD023 linter errors in digest pages 2026-06-19 09:51:27 +02:00
Inaki dc0e090318 Merge pull request #356 from nubenetes/feat/fix-digest-linter
fix: MD023 lint — replace indented headings with bold in digest tabs
2026-06-19 09:51:08 +02:00
Nubenetes BotandClaude Sonnet 4.6 6f34ac8569 fix: replace indented ## headings with bold text in digest pages (MD023 lint fix)
- Change `    ## Category` to `    **Category**` inside MkDocs Material
  tabs to avoid MD023 linter errors (headings must start at col 0)
- Sanitize pipe characters in "why" text to prevent broken markdown tables
- Regenerated tech-digest.md (1328 lines, 3 tabs: 333/443/552 lines)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:50:53 +02:00
Nubenetes Bot 5d7f58c7fa merge: back-merge master v2.6.4 into develop 2026-06-19 09:45:22 +02:00
Nubenetes Bot 52e2560fa0 release: v2.6.4 — Fix digest periods differentiation 2026-06-19 09:45:18 +02:00
Inaki 41207ca0f0 Merge pull request #355 from nubenetes/feat/fix-digest-periods
fix: differentiate digest periods and fix NoneType stars
2026-06-19 09:44:59 +02:00
Nubenetes BotandClaude Sonnet 4.6 b5b3dc3ce2 fix: differentiate digest periods (10/15/20 items), fix year fallback, fix NoneType stars
- Variable items per period: 3 months=10, 6 months=15, 12 months=20
  so each tab shows progressively more content
- Use year field as fallback in _is_within_period when discovered_at
  doesn't discriminate (backfilled entries)
- Fix NoneType comparison in _fallback_items for entries with null stars
- Regenerated digest: 3m=220, 6m=330, 12m=440 items across 22 categories
- tech-digest.md now 1353 lines with differentiated tabs (339/449/559 lines)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:44:44 +02:00
Nubenetes Bot f221647646 merge: back-merge master v2.6.3 into develop 2026-06-19 09:33:18 +02:00
5 changed files with 4755 additions and 1486 deletions
+3696 -726
View File
File diff suppressed because it is too large Load Diff
+38 -61
View File
@@ -13,10 +13,10 @@ from src.logger import log_event
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CNCF_LANDSCAPE_URL = "https://landscape.cncf.io/api/items"
CNCF_LANDSCAPE_URL = "https://landscape.cncf.io/api/items" # Legacy, now SPA — fallback to GitHub topic search
GITHUB_API_BASE = "https://api.github.com"
GITHUB_RATE_DELAY = 0.75 # seconds between GitHub API calls to stay under 5000/hr
MAX_REPOS_DEFAULT = 500
GITHUB_RATE_DELAY = 0.5 # seconds between GitHub API calls (5000/hr limit = ~1.4/s safe)
MAX_REPOS_DEFAULT = 200 # cap per run — 200 × 0.5s = ~100s, well within CI timeout
ACTIVITY_STALENESS_DAYS = 30
# Community health thresholds
@@ -74,38 +74,37 @@ def _is_activity_stale(entry: Dict) -> bool:
# ---------------------------------------------------------------------------
async def fetch_cncf_landscape() -> Dict[str, str]:
"""Fetch CNCF project graduation status.
"""Fetch CNCF project graduation status via GitHub topic search.
Returns dict mapping repo_url (normalized) -> maturity
("sandbox" | "incubating" | "graduated" | "archived").
The legacy landscape.cncf.io/api/items endpoint is now a SPA and no
longer returns JSON. Instead, we search GitHub for repos with CNCF
maturity topics (cncf-sandbox, cncf-incubating, cncf-graduated).
Returns dict mapping repo_url (normalized) -> maturity.
"""
result: Dict[str, str] = {}
headers = _github_headers()
maturity_queries = {
"graduated": "topic:cncf-graduated",
"incubating": "topic:cncf-incubating",
"sandbox": "topic:cncf-sandbox",
}
async with httpx.AsyncClient(timeout=30.0) as client:
try:
resp = await client.get(CNCF_LANDSCAPE_URL)
resp.raise_for_status()
items = resp.json()
except Exception as e:
log_event(f"[WARN] Failed to fetch CNCF landscape: {str(e)[:200]}")
return result
if not isinstance(items, list):
log_event("[WARN] CNCF landscape response is not a list; skipping")
return result
for item in items:
repo_url = item.get("repo_url") or ""
project = item.get("project")
if not repo_url or not project:
continue
maturity = project if isinstance(project, str) else project.get("maturity", "")
if not maturity:
continue
maturity = maturity.lower()
if maturity not in ("sandbox", "incubating", "graduated", "archived"):
continue
normalized = _normalize_repo_url(repo_url)
result[normalized] = maturity
for maturity, query in maturity_queries.items():
try:
url = f"{GITHUB_API_BASE}/search/repositories?q={query}&per_page=100"
resp = await client.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
for repo in data.get("items", []):
repo_url = repo.get("html_url", "")
if repo_url:
result[_normalize_repo_url(repo_url)] = maturity
log_event(f" [CNCF] {maturity}: {len(data.get('items', []))} repos found")
except Exception as e:
log_event(f"[WARN] CNCF {maturity} search failed: {str(e)[:100]}")
await asyncio.sleep(GITHUB_RATE_DELAY)
log_event(f"[CNCF] Fetched {len(result)} projects from CNCF landscape")
return result
@@ -156,15 +155,15 @@ async def _fetch_repo_activity(
client: httpx.AsyncClient,
owner_repo: str,
) -> Tuple[int, int]:
"""Fetch open issue count and recent open PR count for a single repo.
"""Fetch open issue + PR count for a single repo in one API call.
Returns (open_issues_count, open_prs_count).
GitHub's open_issues_count includes PRs, so a single /repos endpoint
call is sufficient. Returns (open_issues_count, 0) — the caller uses
the combined metric for health classification.
"""
headers = _github_headers()
open_issues = 0
open_prs = 0
# Fetch repo-level stats (open_issues_count includes PRs on GitHub)
try:
resp = await client.get(
f"{GITHUB_API_BASE}/repos/{owner_repo}",
@@ -174,37 +173,15 @@ async def _fetch_repo_activity(
if resp.status_code == 200:
data = resp.json()
open_issues = data.get("open_issues_count", 0)
elif resp.status_code == 429:
# Rate limited — back off
await asyncio.sleep(5.0)
except Exception as e:
log_event(f"[WARN] GitHub repo fetch failed for {owner_repo}: {str(e)[:120]}")
await asyncio.sleep(GITHUB_RATE_DELAY)
# Fetch open PRs (page 1 only — we use total_count from search or headers)
try:
resp = await client.get(
f"{GITHUB_API_BASE}/repos/{owner_repo}/pulls",
headers=headers,
params={"state": "open", "sort": "created", "per_page": 1},
timeout=15.0,
)
if resp.status_code == 200:
# The total count of open PRs is not directly in the body for the
# list endpoint, but we can parse the Link header for the last page.
# As a simpler approach, the body is a list; if it has items the repo
# has open PRs. We use the repo-level open_issues_count as the
# combined metric (GitHub counts PRs as issues).
pr_data = resp.json()
if isinstance(pr_data, list) and len(pr_data) > 0:
# Parse Link header for total pages
link_header = resp.headers.get("Link", "")
last_match = re.search(r'page=(\d+)>;\s*rel="last"', link_header)
open_prs = int(last_match.group(1)) if last_match else len(pr_data)
except Exception as e:
log_event(f"[WARN] GitHub PRs fetch failed for {owner_repo}: {str(e)[:120]}")
await asyncio.sleep(GITHUB_RATE_DELAY)
return open_issues, open_prs
return open_issues, 0
def _classify_health(total_activity: int) -> str:
+26 -17
View File
@@ -145,6 +145,12 @@ class NewsDigestEngine:
"12_months": 365,
}
ITEMS_PER_PERIOD: Dict[str, int] = {
"3_months": 10,
"6_months": 15,
"12_months": 20,
}
# ------------------------------------------------------------------ #
def __init__(self) -> None:
@@ -185,17 +191,20 @@ class NewsDigestEngine:
@staticmethod
def _is_within_period(entry: dict, cutoff_iso: str) -> bool:
"""Return *True* when the entry's ``discovered_at`` is on or after
the ISO-formatted *cutoff_iso* string. ISO 8601 strings sort
lexicographically so a simple ``>=`` comparison is sufficient.
"""
"""Check if entry falls within the time period using discovered_at,
with year field as fallback for backfilled entries."""
discovered = entry.get("discovered_at", "")
if not discovered:
return False
try:
return discovered >= cutoff_iso
except Exception:
return False
if discovered:
try:
if discovered >= cutoff_iso:
return True
except Exception:
pass
year = entry.get("year", "")
if year and isinstance(year, str) and year.isdigit():
cutoff_year = cutoff_iso[:4] if len(cutoff_iso) >= 4 else "2020"
return year >= cutoff_year
return False
# ------------------------------------------------------------------ #
# Prompt builder #
@@ -253,8 +262,8 @@ class NewsDigestEngine:
"url": e["url"],
"title": e.get("title", "Unknown"),
"date": e.get("discovered_at", "")[:10],
"stars": e.get("stars", 0),
"impact": "high" if e.get("stars", 0) >= 4 else "medium",
"stars": e.get("stars") or 0,
"impact": "high" if (e.get("stars") or 0) >= 4 else "medium",
"why": (e.get("ai_summary", "") or "")[:200],
"category": cat_name,
}
@@ -316,14 +325,14 @@ class NewsDigestEngine:
reverse=True,
)
max_items = self.ITEMS_PER_PERIOD.get(period_name, 10)
if len(entries) < 3:
# Too few entries include all without AI ranking
digest[period_name][cat_name] = self._fallback_items(
entries, cat_name
entries, cat_name, limit=max_items
)
continue
# Ask Gemini to rank
try:
prompt = self._build_ranking_prompt(
cat_name, entries, period_name
@@ -351,7 +360,7 @@ class NewsDigestEngine:
}
)
digest[period_name][cat_name] = ranked[:10]
digest[period_name][cat_name] = ranked[:max_items]
log_event(
f" [Digest] {period_name}/{cat_name}: "
f"{len(ranked)} items ranked"
@@ -364,7 +373,7 @@ class NewsDigestEngine:
"using star-based fallback"
)
digest[period_name][cat_name] = self._fallback_items(
entries, cat_name
entries, cat_name, limit=max_items
)
# Respect Gemini rate limits
+3 -2
View File
@@ -964,13 +964,14 @@ class V2VisionEngine:
items = period_data.get(cat, [])
if not items:
continue
md += f" ## {cat}\n\n"
md += f" **{cat}**\n\n"
md += " | Date | Resource | Impact | Why It Matters |\n"
md += " | :--- | :--- | :---: | :--- |\n"
for item in items:
impact_badge = {"critical": "🔴", "high": "🟡", "medium": "🔵"}.get(item.get("impact", "medium"), "🔵")
t = nuclear_strip(item.get("title", "Unknown"))
md += f' | {item.get("date", "")} | [{t}]({item.get("url", "#")}) | {impact_badge} {item.get("impact", "medium")} | {item.get("why", "")} |\n'
why = (item.get("why", "") or "").replace("|", "-").replace("\n", " ")
md += f' | {item.get("date", "")} | [{t}]({item.get("url", "#")}) | {impact_badge} {item.get("impact", "medium")} | {why} |\n'
md += "\n"
md += "\n"
return md
+992 -680
View File
File diff suppressed because it is too large Load Diff