返回 CodeWhale
roadmap-feed.ts
根目录 / web / lib / roadmap-feed.ts
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
154 lines TYPESCRIPT