Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

demo-activity.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

demo-activity.tsBlame510 lines · 1 contributor
52ad8b1Claude1/**
2 * Block L3 — Demo activity helpers.
3 *
4 * Read-only feed helpers used by the public `/demo` landing page and its
5 * companion `/api/v2/demo/*` JSON endpoints. All helpers are scoped to
6 * repositories owned by the seeded `demo` user (`DEMO_USERNAME` from
7 * `src/lib/demo-seed.ts`).
8 *
9 * Defensive on every public function:
10 * - Never throws. On any DB hiccup or unexpected shape, returns `[]` (or 0).
11 * - Results are cached in-process for 30 seconds via `LRUCache` from
12 * `src/lib/cache.ts`. Cache key includes the helper name and limit so
13 * two callers asking for different page sizes don't poison each other.
14 *
15 * Intentionally pure-where-possible: only DB reads, no writes, no spawns,
16 * no side effects.
17 */
18
19import { and, desc, eq, gte, inArray, like, sql } from "drizzle-orm";
20import { db } from "../db";
21import {
22 auditLog,
23 issueLabels,
24 issues,
25 labels,
26 prComments,
27 pullRequests,
28 repositories,
29 users,
30} from "../db/schema";
31import { LRUCache } from "./cache";
32import { DEMO_USERNAME } from "./demo-seed";
33import { AI_BUILD_MARKER } from "./ai-build-tasks";
34
35const DEFAULT_LIMIT = 5;
36const DEFAULT_FEED_LIMIT = 20;
37const DEFAULT_SINCE_HOURS = 24;
38
39const CACHE_TTL_MS = 30 * 1000;
40const demoActivityCache = new LRUCache<unknown>(64, CACHE_TTL_MS);
41
42export interface QueuedAiBuildIssue {
43 repo: string;
44 number: number;
45 title: string;
46 createdAt: Date;
47}
48
49export interface RecentAutoMerge {
50 repo: string;
51 number: number;
52 title: string;
53 mergedAt: Date;
54}
55
56export interface RecentAiReview {
57 repo: string;
58 prNumber: number;
59 commentSnippet: string;
60 createdAt: Date;
61}
62
63export type DemoActivityKind =
64 | "auto_merge.merged"
65 | "ai_build.dispatched"
66 | "ai_review.posted";
67
68export interface DemoActivityEntry {
69 kind: DemoActivityKind;
70 repo: string;
71 ref: { type: "issue" | "pr"; number: number };
72 at: Date;
73}
74
75interface DemoRepoRow {
76 id: string;
77 name: string;
78}
79
80/**
81 * Look up the demo user + their repos. Returns `null` on any DB failure or
82 * if the demo user doesn't exist yet (boot-time race or no seed run).
83 */
84async function loadDemoRepos(): Promise<{
85 userId: string;
86 repos: DemoRepoRow[];
87} | null> {
88 try {
89 const [demo] = await db
90 .select({ id: users.id })
91 .from(users)
92 .where(eq(users.username, DEMO_USERNAME))
93 .limit(1);
94 if (!demo) return null;
95
96 const repos = await db
97 .select({ id: repositories.id, name: repositories.name })
98 .from(repositories)
99 .where(eq(repositories.ownerId, demo.id));
100
101 return { userId: demo.id, repos };
102 } catch {
103 return null;
104 }
105}
106
107function cacheKey(name: string, ...parts: (string | number)[]): string {
108 return [name, ...parts.map((p) => String(p))].join("|");
109}
110
111async function memo<T>(
112 key: string,
113 factory: () => Promise<T>,
114 fallback: T
115): Promise<T> {
116 const existing = demoActivityCache.get(key) as T | undefined;
117 if (existing !== undefined) return existing;
118 try {
119 const value = await factory();
120 demoActivityCache.set(key, value as unknown);
121 return value;
122 } catch {
123 return fallback;
124 }
125}
126
127/**
128 * Open issues across demo repos labelled `ai:build` that haven't yet been
129 * dispatched (no comment carrying the `AI_BUILD_MARKER`). Newest first.
130 */
131export async function listQueuedAiBuildIssues(
132 limit: number = DEFAULT_LIMIT
133): Promise<QueuedAiBuildIssue[]> {
134 const lim = Math.max(1, Math.min(50, limit | 0 || DEFAULT_LIMIT));
135 return memo(
136 cacheKey("queued", lim),
137 async () => {
138 const ctx = await loadDemoRepos();
139 if (!ctx || ctx.repos.length === 0) return [];
140 const repoIds = ctx.repos.map((r) => r.id);
141 const nameById = new Map(ctx.repos.map((r) => [r.id, r.name] as const));
142
143 // Find issues labelled "ai:build" (case-insensitive). The label is
144 // per-repo, so we filter via the join across the demo repo set.
145 const rows = await db
146 .select({
147 id: issues.id,
148 repositoryId: issues.repositoryId,
149 number: issues.number,
150 title: issues.title,
151 body: issues.body,
152 createdAt: issues.createdAt,
153 })
154 .from(issues)
155 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
156 .innerJoin(labels, eq(labels.id, issueLabels.labelId))
157 .where(
158 and(
159 inArray(issues.repositoryId, repoIds),
160 eq(issues.state, "open"),
161 sql`lower(${labels.name}) = 'ai:build'`
162 )
163 )
164 .orderBy(desc(issues.createdAt))
165 .limit(lim * 4); // over-fetch to allow for marker filtering
166
167 // Filter out issues whose body already carries the marker (the
168 // marker can also be in a comment but the conservative "body or any
169 // comment" check needs another roundtrip; doing the body check here
170 // mirrors the dispatch sentinel and is sufficient for the demo).
171 const filtered: QueuedAiBuildIssue[] = [];
172 for (const r of rows) {
173 if (r.body && r.body.includes(AI_BUILD_MARKER)) continue;
174 const repo = nameById.get(r.repositoryId);
175 if (!repo) continue;
176 filtered.push({
177 repo,
178 number: r.number,
179 title: r.title,
180 createdAt: r.createdAt,
181 });
182 if (filtered.length >= lim) break;
183 }
184 return filtered;
185 },
186 []
187 );
188}
189
190/**
191 * Recent `auto_merge.merged` audit rows scoped to demo repos. Pulls the PR
192 * title via the targetId (which is the pull_request UUID).
193 */
194export async function listRecentAutoMerges(
195 limit: number = DEFAULT_LIMIT,
196 sinceHours: number = DEFAULT_SINCE_HOURS
197): Promise<RecentAutoMerge[]> {
198 const lim = Math.max(1, Math.min(50, limit | 0 || DEFAULT_LIMIT));
199 const hrs = Math.max(1, Math.min(720, sinceHours | 0 || DEFAULT_SINCE_HOURS));
200 return memo(
201 cacheKey("merges", lim, hrs),
202 async () => {
203 const ctx = await loadDemoRepos();
204 if (!ctx || ctx.repos.length === 0) return [];
205 const repoIds = ctx.repos.map((r) => r.id);
206 const nameById = new Map(ctx.repos.map((r) => [r.id, r.name] as const));
207 const since = new Date(Date.now() - hrs * 60 * 60 * 1000);
208
209 const rows = await db
210 .select({
211 repositoryId: auditLog.repositoryId,
212 targetId: auditLog.targetId,
213 createdAt: auditLog.createdAt,
214 })
215 .from(auditLog)
216 .where(
217 and(
218 eq(auditLog.action, "auto_merge.merged"),
219 inArray(auditLog.repositoryId, repoIds),
220 gte(auditLog.createdAt, since)
221 )
222 )
223 .orderBy(desc(auditLog.createdAt))
224 .limit(lim);
225
226 if (rows.length === 0) return [];
227
228 const prIds = rows
229 .map((r) => r.targetId)
230 .filter((id): id is string => !!id);
231 const prRows = prIds.length
232 ? await db
233 .select({
234 id: pullRequests.id,
235 number: pullRequests.number,
236 title: pullRequests.title,
237 })
238 .from(pullRequests)
239 .where(inArray(pullRequests.id, prIds))
240 : [];
241 const prById = new Map(prRows.map((p) => [p.id, p] as const));
242
243 const result: RecentAutoMerge[] = [];
244 for (const r of rows) {
245 const repo = r.repositoryId ? nameById.get(r.repositoryId) : undefined;
246 const pr = r.targetId ? prById.get(r.targetId) : undefined;
247 if (!repo || !pr) continue;
248 result.push({
249 repo,
250 number: pr.number,
251 title: pr.title,
252 mergedAt: r.createdAt,
253 });
254 }
255 return result;
256 },
257 []
258 );
259}
260
261/**
262 * Recent AI-review PR comments (is_ai_review=true) on demo repos. Returns
263 * a short snippet of the comment body for the tile.
264 */
265export async function listRecentAiReviews(
266 limit: number = DEFAULT_LIMIT,
267 sinceHours: number = DEFAULT_SINCE_HOURS
268): Promise<RecentAiReview[]> {
269 const lim = Math.max(1, Math.min(50, limit | 0 || DEFAULT_LIMIT));
270 const hrs = Math.max(1, Math.min(720, sinceHours | 0 || DEFAULT_SINCE_HOURS));
271 return memo(
272 cacheKey("reviews", lim, hrs),
273 async () => {
274 const ctx = await loadDemoRepos();
275 if (!ctx || ctx.repos.length === 0) return [];
276 const repoIds = ctx.repos.map((r) => r.id);
277 const nameById = new Map(ctx.repos.map((r) => [r.id, r.name] as const));
278 const since = new Date(Date.now() - hrs * 60 * 60 * 1000);
279
280 const rows = await db
281 .select({
282 repositoryId: pullRequests.repositoryId,
283 prNumber: pullRequests.number,
284 body: prComments.body,
285 createdAt: prComments.createdAt,
286 })
287 .from(prComments)
288 .innerJoin(
289 pullRequests,
290 eq(pullRequests.id, prComments.pullRequestId)
291 )
292 .where(
293 and(
294 eq(prComments.isAiReview, true),
295 inArray(pullRequests.repositoryId, repoIds),
296 gte(prComments.createdAt, since)
297 )
298 )
299 .orderBy(desc(prComments.createdAt))
300 .limit(lim);
301
302 const result: RecentAiReview[] = [];
303 for (const r of rows) {
304 const repo = nameById.get(r.repositoryId);
305 if (!repo) continue;
306 const stripped = (r.body ?? "")
307 .replace(/<!--[\s\S]*?-->/g, "")
308 .replace(/\s+/g, " ")
309 .trim();
310 const snippet =
311 stripped.length > 120
312 ? stripped.slice(0, 117).trimEnd() + "..."
313 : stripped;
314 result.push({
315 repo,
316 prNumber: r.prNumber,
317 commentSnippet: snippet,
318 createdAt: r.createdAt,
319 });
320 }
321 return result;
322 },
323 []
324 );
325}
326
327/**
328 * Count AI reviews posted in the last `sinceHours` hours across demo repos.
329 * Pure summary — used by the small counter tile.
330 */
331export async function countAiReviewsSince(
332 sinceHours: number = DEFAULT_SINCE_HOURS
333): Promise<number> {
334 const hrs = Math.max(1, Math.min(720, sinceHours | 0 || DEFAULT_SINCE_HOURS));
335 return memo(
336 cacheKey("review-count", hrs),
337 async () => {
338 const ctx = await loadDemoRepos();
339 if (!ctx || ctx.repos.length === 0) return 0;
340 const repoIds = ctx.repos.map((r) => r.id);
341 const since = new Date(Date.now() - hrs * 60 * 60 * 1000);
342
343 const rows = await db
344 .select({ n: sql<number>`count(*)::int` })
345 .from(prComments)
346 .innerJoin(
347 pullRequests,
348 eq(pullRequests.id, prComments.pullRequestId)
349 )
350 .where(
351 and(
352 eq(prComments.isAiReview, true),
353 inArray(pullRequests.repositoryId, repoIds),
354 gte(prComments.createdAt, since)
355 )
356 );
357 return Number(rows[0]?.n ?? 0);
358 },
359 0
360 );
361}
362
363/**
364 * Combined activity feed: auto_merge.merged + ai_build.dispatched audit
365 * rows interleaved with recent AI-review PR comments (synthesised as
366 * `ai_review.posted` entries since there's no dedicated audit action).
367 * Most-recent first, capped at `limit`.
368 */
369export async function listDemoActivityFeed(
370 limit: number = DEFAULT_FEED_LIMIT
371): Promise<DemoActivityEntry[]> {
372 const lim = Math.max(1, Math.min(100, limit | 0 || DEFAULT_FEED_LIMIT));
373 return memo(
374 cacheKey("feed", lim),
375 async () => {
376 const ctx = await loadDemoRepos();
377 if (!ctx || ctx.repos.length === 0) return [];
378 const repoIds = ctx.repos.map((r) => r.id);
379 const nameById = new Map(ctx.repos.map((r) => [r.id, r.name] as const));
380
381 // Audit rows: auto_merge.merged + ai_build.dispatched.
382 const auditRows = await db
383 .select({
384 action: auditLog.action,
385 repositoryId: auditLog.repositoryId,
386 targetType: auditLog.targetType,
387 targetId: auditLog.targetId,
388 createdAt: auditLog.createdAt,
389 })
390 .from(auditLog)
391 .where(
392 and(
393 inArray(auditLog.action, [
394 "auto_merge.merged",
395 "ai_build.dispatched",
396 ]),
397 inArray(auditLog.repositoryId, repoIds)
398 )
399 )
400 .orderBy(desc(auditLog.createdAt))
401 .limit(lim);
402
403 // PR comments flagged as AI reviews — used to synthesise
404 // `ai_review.posted` entries.
405 const aiReviewRows = await db
406 .select({
407 repositoryId: pullRequests.repositoryId,
408 prNumber: pullRequests.number,
409 createdAt: prComments.createdAt,
410 })
411 .from(prComments)
412 .innerJoin(
413 pullRequests,
414 eq(pullRequests.id, prComments.pullRequestId)
415 )
416 .where(
417 and(
418 eq(prComments.isAiReview, true),
419 inArray(pullRequests.repositoryId, repoIds)
420 )
421 )
422 .orderBy(desc(prComments.createdAt))
423 .limit(lim);
424
425 const entries: DemoActivityEntry[] = [];
426
427 // Resolve PR/issue number from targetId for auto_merge.merged /
428 // ai_build.dispatched rows. Cheap: collect ids, hit the table once.
429 const prTargetIds = auditRows
430 .filter((r) => r.action === "auto_merge.merged" && !!r.targetId)
431 .map((r) => r.targetId as string);
432 const issueTargetIds = auditRows
433 .filter((r) => r.action === "ai_build.dispatched" && !!r.targetId)
434 .map((r) => r.targetId as string);
435
436 const prById = prTargetIds.length
437 ? new Map(
438 (
439 await db
440 .select({
441 id: pullRequests.id,
442 number: pullRequests.number,
443 })
444 .from(pullRequests)
445 .where(inArray(pullRequests.id, prTargetIds))
446 ).map((p) => [p.id, p.number] as const)
447 )
448 : new Map<string, number>();
449
450 const issueById = issueTargetIds.length
451 ? new Map(
452 (
453 await db
454 .select({
455 id: issues.id,
456 number: issues.number,
457 })
458 .from(issues)
459 .where(inArray(issues.id, issueTargetIds))
460 ).map((i) => [i.id, i.number] as const)
461 )
462 : new Map<string, number>();
463
464 for (const r of auditRows) {
465 const repo = r.repositoryId ? nameById.get(r.repositoryId) : undefined;
466 if (!repo) continue;
467 if (r.action === "auto_merge.merged") {
468 const n = r.targetId ? prById.get(r.targetId) : undefined;
469 if (n === undefined) continue;
470 entries.push({
471 kind: "auto_merge.merged",
472 repo,
473 ref: { type: "pr", number: n },
474 at: r.createdAt,
475 });
476 } else if (r.action === "ai_build.dispatched") {
477 const n = r.targetId ? issueById.get(r.targetId) : undefined;
478 if (n === undefined) continue;
479 entries.push({
480 kind: "ai_build.dispatched",
481 repo,
482 ref: { type: "issue", number: n },
483 at: r.createdAt,
484 });
485 }
486 }
487
488 for (const r of aiReviewRows) {
489 const repo = nameById.get(r.repositoryId);
490 if (!repo) continue;
491 entries.push({
492 kind: "ai_review.posted",
493 repo,
494 ref: { type: "pr", number: r.prNumber },
495 at: r.createdAt,
496 });
497 }
498
499 entries.sort((a, b) => b.at.getTime() - a.at.getTime());
500 return entries.slice(0, lim);
501 },
502 []
503 );
504}
505
506/** Test-only export — exposed for unit tests, not part of the public surface. */
507export const __test = {
508 loadDemoRepos,
509 demoActivityCache,
510};