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