返回 CodeWhale
community-agent-tasks.ts
根目录 / web / lib / community-agent-tasks.ts
1 import { fetchFeed, fetchRepoStats } from "@/lib/github";
2 import { curate } from "@/lib/deepseek";
3 import { putDispatchWithKv } from "@/lib/kv";
4 import {
5 agentChat,
6 TRIAGE_PROMPT,
7 PR_REVIEW_PROMPT,
8 STALE_PROMPT,
9 DUPES_PROMPT,
10 DIGEST_PROMPT,
11 saveDraft,
12 hasFreshDraft,
13 logUsage,
14 type AgentDraft,
15 type DeepSeekEnv,
16 } from "@/lib/community-agent";
17
18 export interface AgentEnv {
19 CURATED_KV?: {
20 get(k: string): Promise<string | null>;
21 put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>;
22 list(o?: { prefix?: string; limit?: number }): Promise<{ keys: { name: string }[] }>;
23 delete(key: string): Promise<void>;
24 };
25 DEEPSEEK_API_KEY?: string;
26 DEEPSEEK_BASE_URL?: string;
27 DEEPSEEK_MODEL?: string;
28 GITHUB_TOKEN?: string;
29 CRON_SECRET?: string;
30 GITHUB_REPO?: string;
31 MAINTAINER_TOKEN?: string;
32 MAINTAINER_GITHUB_PAT?: string;
33 }
34
35 const CRON_STATUS_TTL = 60 * 60 * 24 * 14;
36
37 function dsEnv(env: AgentEnv): DeepSeekEnv {
38 return {
39 baseUrl: env.DEEPSEEK_BASE_URL ?? process.env.DEEPSEEK_BASE_URL,
40 model: env.DEEPSEEK_MODEL ?? process.env.DEEPSEEK_MODEL,
41 };
42 }
43
44 export async function runCurate(env: AgentEnv): Promise<Record<string, unknown>> {
45 if (!env.DEEPSEEK_API_KEY) {
46 return { skipped: true, reason: "DEEPSEEK_API_KEY not set" };
47 }
48 try {
49 const [stats, feed] = await Promise.all([
50 fetchRepoStats(env.GITHUB_TOKEN),
51 fetchFeed(env.GITHUB_TOKEN, 30),
52 ]);
53 const dispatch = await curate(env.DEEPSEEK_API_KEY, stats, feed, dsEnv(env));
54 await putDispatchWithKv(env.CURATED_KV, dispatch);
55 await env.CURATED_KV?.put(
56 "cron:curate:last",
57 JSON.stringify({
58 ok: true,
59 generatedAt: dispatch.generatedAt,
60 headline: dispatch.headline,
61 }),
62 { expirationTtl: CRON_STATUS_TTL }
63 );
64 return { ok: true, headline: dispatch.headline, stored: env.CURATED_KV ? "kv" : "memory" };
65 } catch (e) {
66 const error = String(e);
67 await env.CURATED_KV?.put(
68 "cron:curate:last",
69 JSON.stringify({
70 ok: false,
71 generatedAt: new Date().toISOString(),
72 error,
73 }),
74 { expirationTtl: CRON_STATUS_TTL }
75 );
76 return { ok: false, error };
77 }
78 }
79
80 export async function runTriage(env: AgentEnv): Promise<Record<string, unknown>> {
81 const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
82 try {
83 const res = await fetch(
84 `https://api.github.com/repos/${repo}/issues?state=open&sort=created&direction=desc&per_page=30`,
85 {
86 headers: {
87 Accept: "application/vnd.github+json",
88 "X-GitHub-Api-Version": "2022-11-28",
89 "User-Agent": "codewhale-web",
90 ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}),
91 },
92 }
93 );
94 const issues = (await res.json()) as { number: number; title: string; body?: string; updated_at: string; html_url: string; pull_request?: unknown; labels: { name: string }[] }[];
95 const newIssues = issues.filter((i) => !i.pull_request).slice(0, 10);
96
97 let processed = 0;
98 let skipped = 0;
99
100 for (const issue of newIssues) {
101 if (await hasFreshDraft(env.CURATED_KV, "triage", String(issue.number), issue.updated_at)) {
102 skipped++;
103 continue;
104 }
105
106 const payload = {
107 number: issue.number,
108 title: issue.title,
109 body: (issue.body ?? "").slice(0, 3000),
110 labels: issue.labels.map((l) => l.name),
111 url: issue.html_url,
112 };
113
114 try {
115 const { content, usage } = await agentChat(
116 [{ role: "system", content: TRIAGE_PROMPT }, { role: "user", content: JSON.stringify(payload) }],
117 env.DEEPSEEK_API_KEY!,
118 true,
119 dsEnv(env)
120 );
121 const parsed = JSON.parse(content) as { bodyEn: string; bodyZh: string };
122 const draft: AgentDraft = {
123 id: String(issue.number),
124 type: "triage",
125 targetNumber: issue.number,
126 targetUrl: issue.html_url,
127 bodyEn: parsed.bodyEn,
128 bodyZh: parsed.bodyZh,
129 generatedAt: new Date().toISOString(),
130 posted: false,
131 };
132 await saveDraft(env.CURATED_KV, draft);
133 await logUsage(env.CURATED_KV, usage.input, usage.output);
134 processed++;
135 } catch {
136 skipped++;
137 }
138 }
139
140 return { ok: true, processed, skipped };
141 } catch (e) {
142 return { ok: false, error: String(e) };
143 }
144 }
145
146 export async function runPrReview(env: AgentEnv): Promise<Record<string, unknown>> {
147 const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
148 try {
149 const res = await fetch(
150 `https://api.github.com/repos/${repo}/pulls?state=open&sort=created&direction=desc&per_page=20`,
151 {
152 headers: {
153 Accept: "application/vnd.github+json",
154 "X-GitHub-Api-Version": "2022-11-28",
155 "User-Agent": "codewhale-web",
156 ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}),
157 },
158 }
159 );
160 const prs = (await res.json()) as { number: number; title: string; body?: string; updated_at: string; html_url: string; changed_files?: number; additions?: number; deletions?: number; user: { login: string } }[];
161
162 let processed = 0;
163 let skipped = 0;
164
165 for (const pr of prs.slice(0, 10)) {
166 if (await hasFreshDraft(env.CURATED_KV, "pr-review", String(pr.number), pr.updated_at)) {
167 skipped++;
168 continue;
169 }
170
171 // Fetch diff stats if not included
172 let diffStats = { changed_files: pr.changed_files ?? 0, additions: pr.additions ?? 0, deletions: pr.deletions ?? 0 };
173 if (!pr.changed_files) {
174 try {
175 const diffRes = await fetch(`https://api.github.com/repos/${repo}/pulls/${pr.number}`, {
176 headers: {
177 Accept: "application/vnd.github+json",
178 "X-GitHub-Api-Version": "2022-11-28",
179 "User-Agent": "codewhale-web",
180 ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}),
181 },
182 });
183 const diffData = (await diffRes.json()) as { changed_files?: number; additions?: number; deletions?: number };
184 diffStats = { changed_files: diffData.changed_files ?? 0, additions: diffData.additions ?? 0, deletions: diffData.deletions ?? 0 };
185 } catch { /* use defaults */ }
186 }
187
188 const payload = {
189 number: pr.number,
190 title: pr.title,
191 body: (pr.body ?? "").slice(0, 3000),
192 author: pr.user.login,
193 url: pr.html_url,
194 ...diffStats,
195 };
196
197 try {
198 const { content, usage } = await agentChat(
199 [{ role: "system", content: PR_REVIEW_PROMPT }, { role: "user", content: JSON.stringify(payload) }],
200 env.DEEPSEEK_API_KEY!,
201 true,
202 dsEnv(env)
203 );
204 const parsed = JSON.parse(content) as { bodyEn: string; bodyZh: string };
205 const draft: AgentDraft = {
206 id: String(pr.number),
207 type: "pr-review",
208 targetNumber: pr.number,
209 targetUrl: pr.html_url,
210 bodyEn: parsed.bodyEn,
211 bodyZh: parsed.bodyZh,
212 generatedAt: new Date().toISOString(),
213 posted: false,
214 };
215 await saveDraft(env.CURATED_KV, draft);
216 await logUsage(env.CURATED_KV, usage.input, usage.output);
217 processed++;
218 } catch {
219 skipped++;
220 }
221 }
222
223 return { ok: true, processed, skipped };
224 } catch (e) {
225 return { ok: false, error: String(e) };
226 }
227 }
228
229 export async function runStale(env: AgentEnv): Promise<Record<string, unknown>> {
230 const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
231 const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
232 try {
233 const res = await fetch(
234 `https://api.github.com/search/issues?q=${encodeURIComponent(`repo:${repo} is:issue is:open updated:<${thirtyDaysAgo}`)}&sort=updated&per_page=20`,
235 {
236 headers: {
237 Accept: "application/vnd.github+json",
238 "X-GitHub-Api-Version": "2022-11-28",
239 "User-Agent": "codewhale-web",
240 ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}),
241 },
242 }
243 );
244 const data = (await res.json()) as { items?: { number: number; title: string; body?: string; updated_at: string; html_url: string }[] };
245 const issues = data.items ?? [];
246
247 let processed = 0;
248 let skipped = 0;
249
250 for (const issue of issues.slice(0, 10)) {
251 if (await hasFreshDraft(env.CURATED_KV, "stale", String(issue.number), issue.updated_at)) {
252 skipped++;
253 continue;
254 }
255
256 const payload = {
257 number: issue.number,
258 title: issue.title,
259 body: (issue.body ?? "").slice(0, 2000),
260 url: issue.html_url,
261 lastUpdated: issue.updated_at,
262 };
263
264 try {
265 const { content, usage } = await agentChat(
266 [{ role: "system", content: STALE_PROMPT }, { role: "user", content: JSON.stringify(payload) }],
267 env.DEEPSEEK_API_KEY!,
268 true,
269 dsEnv(env)
270 );
271 const parsed = JSON.parse(content) as { bodyEn: string; bodyZh: string };
272 const draft: AgentDraft = {
273 id: String(issue.number),
274 type: "stale",
275 targetNumber: issue.number,
276 targetUrl: issue.html_url,
277 bodyEn: parsed.bodyEn,
278 bodyZh: parsed.bodyZh,
279 generatedAt: new Date().toISOString(),
280 posted: false,
281 };
282 await saveDraft(env.CURATED_KV, draft);
283 await logUsage(env.CURATED_KV, usage.input, usage.output);
284 processed++;
285 } catch {
286 skipped++;
287 }
288 }
289
290 return { ok: true, processed, skipped };
291 } catch (e) {
292 return { ok: false, error: String(e) };
293 }
294 }
295
296 export async function runDupes(env: AgentEnv): Promise<Record<string, unknown>> {
297 const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
298 try {
299 const res = await fetch(
300 `https://api.github.com/repos/${repo}/issues?state=open&per_page=100`,
301 {
302 headers: {
303 Accept: "application/vnd.github+json",
304 "X-GitHub-Api-Version": "2022-11-28",
305 "User-Agent": "codewhale-web",
306 ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}),
307 },
308 }
309 );
310 const issues = (await res.json()) as { number: number; title: string; body?: string; updated_at: string; html_url: string; pull_request?: unknown }[];
311 const openIssues = issues
312 .filter((i) => !i.pull_request)
313 .map((i) => ({
314 number: i.number,
315 title: i.title,
316 body: (i.body ?? "").slice(0, 500),
317 url: i.html_url,
318 }));
319
320 if (openIssues.length < 3) {
321 return { ok: true, skipped: true, reason: "too few issues to compare" };
322 }
323
324 const { content, usage } = await agentChat(
325 [{ role: "system", content: DUPES_PROMPT }, { role: "user", content: JSON.stringify({ issues: openIssues }) }],
326 env.DEEPSEEK_API_KEY!,
327 true,
328 dsEnv(env)
329 );
330
331 const parsed = JSON.parse(content) as { suggestions?: { targetNumber: number; duplicateNumber: number; reason: string; bodyEn: string; bodyZh: string }[] };
332 const suggestions = parsed.suggestions ?? [];
333
334 let processed = 0;
335 for (const s of suggestions) {
336 const draft: AgentDraft = {
337 id: String(s.duplicateNumber),
338 type: "dupes",
339 targetNumber: s.duplicateNumber,
340 bodyEn: s.bodyEn,
341 bodyZh: s.bodyZh,
342 generatedAt: new Date().toISOString(),
343 posted: false,
344 };
345 await saveDraft(env.CURATED_KV, draft);
346 processed++;
347 }
348
349 await logUsage(env.CURATED_KV, usage.input, usage.output);
350 return { ok: true, processed };
351 } catch (e) {
352 return { ok: false, error: String(e) };
353 }
354 }
355
356 export async function runDigest(env: AgentEnv): Promise<Record<string, unknown>> {
357 const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
358 const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
359
360 try {
361 const [issuesRes, pullsRes, stats] = await Promise.all([
362 fetch(
363 `https://api.github.com/repos/${repo}/issues?state=all&since=${weekAgo}&per_page=50&sort=updated&direction=desc`,
364 {
365 headers: {
366 Accept: "application/vnd.github+json",
367 "X-GitHub-Api-Version": "2022-11-28",
368 "User-Agent": "codewhale-web",
369 ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}),
370 },
371 }
372 ),
373 fetch(
374 `https://api.github.com/repos/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=50`,
375 {
376 headers: {
377 Accept: "application/vnd.github+json",
378 "X-GitHub-Api-Version": "2022-11-28",
379 "User-Agent": "codewhale-web",
380 ...(env.GITHUB_TOKEN ? { Authorization: `Bearer ${env.GITHUB_TOKEN}` } : {}),
381 },
382 }
383 ),
384 fetchRepoStats(env.GITHUB_TOKEN),
385 ]);
386
387 const issues = (await issuesRes.json()) as { number: number; title: string; state: string; pull_request?: unknown; created_at: string; user: { login: string } }[];
388 const pulls = (await pullsRes.json()) as { number: number; title: string; state: string; merged_at?: string; created_at: string; user: { login: string } }[];
389
390 const weekIssues = issues.filter((i) => !i.pull_request && new Date(i.created_at) > new Date(weekAgo));
391 const weekPRs = pulls.filter((p) => new Date(p.created_at) > new Date(weekAgo));
392 const mergedPRs = pulls.filter((p) => p.merged_at && new Date(p.merged_at) > new Date(weekAgo));
393
394 const contributors = new Set([
395 ...weekIssues.map((i) => i.user.login),
396 ...weekPRs.map((p) => p.user.login),
397 ]);
398
399 const payload = {
400 period: `${weekAgo.slice(0, 10)} — ${new Date().toISOString().slice(0, 10)}`,
401 stats: { stars: stats.stars, forks: stats.forks },
402 newIssues: weekIssues.map((i) => ({ number: i.number, title: i.title, author: i.user.login })),
403 newPRs: weekPRs.map((p) => ({ number: p.number, title: p.title, author: p.user.login })),
404 mergedPRs: mergedPRs.map((p) => ({ number: p.number, title: p.title })),
405 contributors: [...contributors],
406 };
407
408 const { content, usage } = await agentChat(
409 [{ role: "system", content: DIGEST_PROMPT }, { role: "user", content: JSON.stringify(payload) }],
410 env.DEEPSEEK_API_KEY!,
411 true,
412 dsEnv(env)
413 );
414
415 const parsed = JSON.parse(content) as { titleEn: string; titleZh: string; summaryEn: string; summaryZh: string; sections: { heading: string; items: string[] }[] };
416
417 // Compute week ID
418 const now = new Date();
419 const startOfYear = new Date(now.getFullYear(), 0, 1);
420 const weekNum = Math.ceil(((now.getTime() - startOfYear.getTime()) / 86400000 + startOfYear.getDay() + 1) / 7);
421 const weekId = `${now.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
422
423 const draft: AgentDraft = {
424 id: weekId,
425 type: "digest",
426 bodyEn: `# ${parsed.titleEn}\n\n${parsed.summaryEn}\n\n${parsed.sections.map((s) => `## ${s.heading}\n${s.items.map((i) => `- ${i}`).join("\n")}`).join("\n\n")}`,
427 bodyZh: `# ${parsed.titleZh}\n\n${parsed.summaryZh}\n\n${parsed.sections.map((s) => `## ${s.heading}\n${s.items.map((i) => `- ${i}`).join("\n")}`).join("\n\n")}`,
428 generatedAt: new Date().toISOString(),
429 posted: false,
430 };
431
432 await saveDraft(env.CURATED_KV, draft);
433
434 // Also save the structured digest for the weekly page
435 await env.CURATED_KV?.put(
436 `digest:weekly-${weekId}`,
437 JSON.stringify({ ...parsed, weekId, generatedAt: draft.generatedAt }),
438 { expirationTtl: 60 * 60 * 24 * 90 }
439 );
440
441 await logUsage(env.CURATED_KV, usage.input, usage.output);
442 return { ok: true, weekId };
443 } catch (e) {
444 return { ok: false, error: String(e) };
445 }
446 }
447
447 lines TYPESCRIPT