| 1 | /** |
| 2 | * roadmap-feed.ts — fetch the live roadmap from GitHub. |
| 3 | * |
| 4 | * "Shipped" ← last 8 published Releases on Hmbown/CodeWhale |
| 5 | * "Underway" ← open issues with label `roadmap:underway` |
| 6 | * "Considered" ← open issues with label `roadmap:considered` |
| 7 | * "Ruled out" ← issues (open or closed) with label `roadmap:ruled-out` |
| 8 | * |
| 9 | * Cached in CURATED_KV under `roadmap:feed` with a 30-minute TTL so the |
| 10 | * roadmap page renders fast and the GH rate limit never matters. |
| 11 | * |
| 12 | * Categories that come back empty fall through to the page's static items — |
| 13 | * the maintainer can adopt label-driven roadmap incrementally. |
| 14 | */ |
| 15 | const REPO = process.env.GITHUB_REPO ?? "Hmbown/CodeWhale"; |
| 16 | const KV_KEY = "roadmap:feed"; |
| 17 | const KV_TTL = 60 * 30; |
| 18 | |
| 19 | export interface RoadmapItem { |
| 20 | title: string; |
| 21 | note: string; |
| 22 | href?: string; |
| 23 | number?: number; |
| 24 | } |
| 25 | |
| 26 | export interface RoadmapFeed { |
| 27 | generatedAt: string; |
| 28 | shipped: RoadmapItem[]; |
| 29 | underway: RoadmapItem[]; |
| 30 | considered: RoadmapItem[]; |
| 31 | ruledOut: RoadmapItem[]; |
| 32 | } |
| 33 | |
| 34 | interface KVNamespace { |
| 35 | get(k: string): Promise<string | null>; |
| 36 | put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>; |
| 37 | } |
| 38 | |
| 39 | async function gh<T>(url: string, ghToken?: string): Promise<T | null> { |
| 40 | const headers: Record<string, string> = { |
| 41 | Accept: "application/vnd.github+json", |
| 42 | "User-Agent": "codewhale-web-roadmap", |
| 43 | "X-GitHub-Api-Version": "2022-11-28", |
| 44 | }; |
| 45 | if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`; |
| 46 | try { |
| 47 | const r = await fetch(url, { headers }); |
| 48 | if (!r.ok) return null; |
| 49 | return (await r.json()) as T; |
| 50 | } catch { |
| 51 | return null; |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | interface GhRelease { tag_name: string; name: string | null; body: string | null; html_url: string; prerelease: boolean; draft: boolean } |
| 56 | interface GhIssue { number: number; title: string; html_url: string; body: string | null; state: string; pull_request?: unknown } |
| 57 | |
| 58 | const FALLBACK_SHIPPED: RoadmapItem[] = [ |
| 59 | { |
| 60 | title: "v0.8.45", |
| 61 | note: "Moonshot/Kimi provider support, API-key setup guidance, provider-surface sync, and current Windows install/runtime guidance", |
| 62 | href: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.45", |
| 63 | }, |
| 64 | ]; |
| 65 | |
| 66 | function withPinnedShipped(items: RoadmapItem[]): RoadmapItem[] { |
| 67 | // Safety net only: the static fallback entry must never sit ahead of live |
| 68 | // releases — use it solely when the live list is empty. |
| 69 | return items.length > 0 ? items : FALLBACK_SHIPPED; |
| 70 | } |
| 71 | |
| 72 | function summarizeReleaseBody(body: string | null): string { |
| 73 | if (!body) return ""; |
| 74 | // First non-empty line, stripped of markdown headers / bullets / links |
| 75 | const lines = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); |
| 76 | const candidate = lines.find((l) => !l.startsWith("#") && !l.startsWith("---") && l.length > 8); |
| 77 | if (!candidate) return ""; |
| 78 | // Strip bullets, trailing emoji, links, and cap length |
| 79 | const stripped = candidate.replace(/^[*\-•]\s+/, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim(); |
| 80 | return stripped.length > 140 ? stripped.slice(0, 137) + "…" : stripped; |
| 81 | } |
| 82 | |
| 83 | function summarizeIssueBody(body: string | null): string { |
| 84 | if (!body) return ""; |
| 85 | // Issue bodies are often very long; take the first non-empty paragraph (up to ~140 chars) |
| 86 | const para = body.split(/\r?\n\r?\n/).map((p) => p.trim()).find((p) => p.length > 0) ?? ""; |
| 87 | const stripped = para |
| 88 | .replace(/^[#>*\-\s]+/, "") |
| 89 | .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") |
| 90 | .replace(/\s+/g, " ") |
| 91 | .trim(); |
| 92 | return stripped.length > 140 ? stripped.slice(0, 137) + "…" : stripped; |
| 93 | } |
| 94 | |
| 95 | async function fetchByLabel(label: string, ghToken?: string, state: "open" | "closed" | "all" = "open"): Promise<RoadmapItem[]> { |
| 96 | const url = `https://api.github.com/repos/${REPO}/issues?state=${state}&labels=${encodeURIComponent(label)}&per_page=10&sort=updated`; |
| 97 | const issues = await gh<GhIssue[]>(url, ghToken); |
| 98 | if (!issues) return []; |
| 99 | return issues |
| 100 | .filter((i) => !i.pull_request) // skip PRs |
| 101 | .map((i) => ({ |
| 102 | title: i.title, |
| 103 | note: summarizeIssueBody(i.body) || `Issue #${i.number}`, |
| 104 | href: i.html_url, |
| 105 | number: i.number, |
| 106 | })); |
| 107 | } |
| 108 | |
| 109 | export async function fetchRoadmap(ghToken?: string): Promise<RoadmapFeed> { |
| 110 | const [releases, underway, considered, ruledOut] = await Promise.all([ |
| 111 | gh<GhRelease[]>(`https://api.github.com/repos/${REPO}/releases?per_page=8`, ghToken), |
| 112 | fetchByLabel("roadmap:underway", ghToken, "open"), |
| 113 | fetchByLabel("roadmap:considered", ghToken, "open"), |
| 114 | fetchByLabel("roadmap:ruled-out", ghToken, "all"), |
| 115 | ]); |
| 116 | |
| 117 | const shipped: RoadmapItem[] = releases |
| 118 | ? releases |
| 119 | .filter((r) => !r.draft) |
| 120 | .map((r) => ({ |
| 121 | title: r.name?.trim() || r.tag_name, |
| 122 | note: summarizeReleaseBody(r.body) || r.tag_name, |
| 123 | href: r.html_url, |
| 124 | })) |
| 125 | : FALLBACK_SHIPPED; |
| 126 | |
| 127 | return { |
| 128 | generatedAt: new Date().toISOString(), |
| 129 | shipped: withPinnedShipped(shipped), |
| 130 | underway, |
| 131 | considered, |
| 132 | ruledOut, |
| 133 | }; |
| 134 | } |
| 135 | |
| 136 | export async function getCachedRoadmap(kv: KVNamespace | undefined, ghToken: string | undefined): Promise<RoadmapFeed | null> { |
| 137 | try { |
| 138 | if (kv) { |
| 139 | const cached = await kv.get(KV_KEY); |
| 140 | if (cached) { |
| 141 | const parsed = JSON.parse(cached) as RoadmapFeed; |
| 142 | return { ...parsed, shipped: withPinnedShipped(parsed.shipped ?? []) }; |
| 143 | } |
| 144 | } |
| 145 | const fresh = await fetchRoadmap(ghToken); |
| 146 | if (kv) { |
| 147 | await kv.put(KV_KEY, JSON.stringify(fresh), { expirationTtl: KV_TTL }); |
| 148 | } |
| 149 | return fresh; |
| 150 | } catch { |
| 151 | return null; |
| 152 | } |
| 153 | } |
| 154 |