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