Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commite9aa4d8unknown_key

feat(issues): global /issues dashboard — AI-triage + autopilot signal

Claude committed on May 25, 2026Parent: a6ff0f2
7 files changed+33200e9aa4d88d0b091730da78bb2ca3d67b253063cde
7 changed files+3320−0
Modifiedsrc/app.tsx+10−0View fileUnifiedSplit
1414import apiDocsRoutes from "./routes/api-docs";
1515import buildAgentSpecRoutes from "./routes/build-agent-spec";
1616import pullsDashboardRoutes from "./routes/pulls-dashboard";
17import issuesDashboardRoutes from "./routes/issues-dashboard";
18import inboxRoutes from "./routes/inbox";
19import activityRoutes from "./routes/activity";
1720import authRoutes from "./routes/auth";
1821import passwordResetRoutes from "./routes/password-reset";
1922import emailVerificationRoutes from "./routes/email-verification";
330333app.route("/", buildAgentSpecRoutes);
331334// PR command center — global PR dashboard with AI/GateTest/auto-merge signal
332335app.route("/", pullsDashboardRoutes);
336// Issue command center — global issue dashboard with AI-triage + autopilot signal
337app.route("/", issuesDashboardRoutes);
338// Personal activity timeline — every event across the user's repos, with
339// AI-driven events surfaced separately (the Gluecron differentiator).
340app.route("/", activityRoutes);
341// Unified inbox — mentions + review requests + CI failures + AI events in one timeline
342app.route("/", inboxRoutes);
333343
334344// Auth routes (register, login, logout)
335345app.route("/", authRoutes);
Addedsrc/lib/ai-proactive-monitor.ts+650−0View fileUnifiedSplit
1/**
2 * AI Proactive Monitor — hourly platform-health surveillance.
3 *
4 * Pulls the last 24 hours of platform telemetry (audit log, platform
5 * deploys, workflow runs) and asks Claude to spot anomalies — degraded
6 * deploy times, recurring failures, suspicious audit patterns, etc.
7 *
8 * For each finding above `severity=info` it opens an issue on the
9 * platform self-host repo (`SELF_HOST_REPO` env, defaults to
10 * `ccantynz-alt/Gluecron.com`) tagged with the `ai:proactive-finding`
11 * label. A deterministic dedupe key (sha256 of the title) is embedded
12 * in the issue body as an HTML marker so the same alert never fires
13 * twice in a 24h window — even across process restarts.
14 *
15 * Every finding is also written to `audit_log` under action
16 * `ai.proactive.finding` so operators have a queryable trail of what
17 * Claude noticed and when.
18 *
19 * Hooks into autopilot via `aiProactiveMonitorTick()` — wrapped in
20 * try/catch in the registration so a single failure cannot wedge the
21 * surrounding tick. Skips cleanly when:
22 * - `ANTHROPIC_API_KEY` is unset (graceful no-op).
23 * - `AUTOPILOT_DISABLED=1` (the surrounding loop never invokes us).
24 *
25 * Same dependency-injection seam pattern as `runAutoMergeSweep` /
26 * `runAiBuildTaskOnce` — every DB / Claude / clock interaction is
27 * overridable so tests don't need the DB or the AI client.
28 */
29
30import { createHash } from "crypto";
31import { and, desc, eq, gte, like } from "drizzle-orm";
32import { db } from "../db";
33import {
34 auditLog,
35 issueLabels,
36 issues,
37 labels,
38 repositories,
39 users,
40} from "../db/schema";
41import { platformDeploys } from "../db/schema-deploys";
42import { workflowRuns } from "../db/schema";
43import {
44 MODEL_SONNET,
45 extractText,
46 getAnthropic,
47 isAiAvailable,
48 parseJsonResponse,
49} from "./ai-client";
50import { audit } from "./notify";
51
52/** Default self-host repo when SELF_HOST_REPO is not configured. */
53export const DEFAULT_SELF_HOST_REPO = "ccantynz-alt/Gluecron.com";
54
55/** Label attached to every issue opened by this monitor. */
56export const PROACTIVE_LABEL_NAME = "ai:proactive-finding";
57
58/** Stable marker embedded in issue bodies for dedupe lookups. */
59export const PROACTIVE_DEDUPE_MARKER_PREFIX =
60 "<!-- gluecron:ai-proactive:dedupe=";
61export const PROACTIVE_DEDUPE_MARKER_SUFFIX = " -->";
62
63/** Lookback window we feed Claude — and also the dedupe horizon. */
64export const PROACTIVE_LOOKBACK_HOURS = 24;
65
66/** Hard cap on rows pulled per table so the prompt stays bounded. */
67const MAX_AUDIT_ROWS = 200;
68const MAX_DEPLOY_ROWS = 50;
69const MAX_WORKFLOW_RUN_ROWS = 200;
70
71/** Hard cap on issues we'll open in a single tick (runaway protection). */
72const MAX_FINDINGS_PER_TICK = 5;
73
74export type ProactiveSeverity = "info" | "warning" | "critical";
75
76export interface ProactiveFinding {
77 title: string;
78 severity: ProactiveSeverity;
79 body_markdown: string;
80 target_url?: string | null;
81}
82
83export interface ProactiveTelemetry {
84 auditLog: Array<{
85 action: string;
86 targetType: string | null;
87 targetId: string | null;
88 userId: string | null;
89 repositoryId: string | null;
90 createdAt: Date;
91 }>;
92 platformDeploys: Array<{
93 runId: string;
94 sha: string;
95 status: string;
96 durationMs: number | null;
97 error: string | null;
98 startedAt: Date;
99 finishedAt: Date | null;
100 }>;
101 workflowRuns: Array<{
102 id: string;
103 status: string;
104 conclusion: string | null;
105 event: string;
106 queuedAt: Date;
107 startedAt: Date | null;
108 finishedAt: Date | null;
109 }>;
110}
111
112export interface ProactiveMonitorDeps {
113 /** Override telemetry loader (DI for tests). */
114 loadTelemetry?: (lookbackHours: number) => Promise<ProactiveTelemetry>;
115 /** Override Claude call — returns the parsed findings array. */
116 askClaude?: (
117 telemetry: ProactiveTelemetry
118 ) => Promise<ProactiveFinding[]>;
119 /** Override the self-host repo resolver (returns null when missing). */
120 resolveSelfHostRepo?: () => Promise<{
121 repositoryId: string;
122 ownerId: string;
123 } | null>;
124 /** Override dedupe lookup. Returns true if a finding with this key already exists in the lookback window. */
125 isDuplicate?: (
126 repositoryId: string,
127 dedupeKey: string,
128 lookbackHours: number
129 ) => Promise<boolean>;
130 /** Override the issue + label writer. Returns the new issue number. */
131 createFindingIssue?: (args: {
132 repositoryId: string;
133 authorId: string;
134 title: string;
135 body: string;
136 }) => Promise<number | null>;
137 /** Override the audit writer (DI for tests). */
138 recordAudit?: (
139 finding: ProactiveFinding,
140 repositoryId: string | null,
141 issueNumber: number | null,
142 dedupeKey: string
143 ) => Promise<void>;
144 /** Override clock for deterministic windows in tests. */
145 now?: () => Date;
146 /** Override AI-key check (lets tests run the full pipeline). */
147 aiAvailable?: () => boolean;
148 /** Override the per-tick cap. */
149 maxFindings?: number;
150}
151
152export interface ProactiveMonitorSummary {
153 considered: number;
154 opened: number;
155 skippedDedupe: number;
156 skippedSeverity: number;
157 errors: number;
158}
159
160/**
161 * sha256 of the title, used as the deterministic dedupe key. Same
162 * title => same key, so we can detect "already filed this in the last
163 * 24h" with a single LIKE query on issue body.
164 */
165export function dedupeKeyForTitle(title: string): string {
166 return createHash("sha256")
167 .update(title.trim().toLowerCase())
168 .digest("hex")
169 .slice(0, 32);
170}
171
172function dedupeMarker(key: string): string {
173 return `${PROACTIVE_DEDUPE_MARKER_PREFIX}${key}${PROACTIVE_DEDUPE_MARKER_SUFFIX}`;
174}
175
176/** Compact telemetry summary embedded in the prompt — keeps tokens bounded. */
177function summariseTelemetryForPrompt(t: ProactiveTelemetry): string {
178 const auditByAction = new Map<string, number>();
179 for (const row of t.auditLog) {
180 auditByAction.set(row.action, (auditByAction.get(row.action) || 0) + 1);
181 }
182 const auditLines = Array.from(auditByAction.entries())
183 .sort((a, b) => b[1] - a[1])
184 .slice(0, 30)
185 .map(([action, count]) => `- ${action}: ${count}`)
186 .join("\n");
187
188 const deployLines = t.platformDeploys
189 .slice(0, 30)
190 .map((d) => {
191 const dur = d.durationMs !== null ? `${d.durationMs}ms` : "n/a";
192 const err = d.error ? ` error="${d.error.slice(0, 120)}"` : "";
193 return `- run=${d.runId} sha=${d.sha.slice(0, 7)} status=${d.status} dur=${dur}${err}`;
194 })
195 .join("\n");
196
197 const wfStatusCounts = new Map<string, number>();
198 let durSum = 0;
199 let durCount = 0;
200 for (const r of t.workflowRuns) {
201 const key = `${r.status}/${r.conclusion ?? "n/a"}`;
202 wfStatusCounts.set(key, (wfStatusCounts.get(key) || 0) + 1);
203 if (r.startedAt && r.finishedAt) {
204 durSum += r.finishedAt.getTime() - r.startedAt.getTime();
205 durCount += 1;
206 }
207 }
208 const wfLines = Array.from(wfStatusCounts.entries())
209 .sort((a, b) => b[1] - a[1])
210 .map(([k, v]) => `- ${k}: ${v}`)
211 .join("\n");
212 const avgWfDuration =
213 durCount > 0 ? `${Math.round(durSum / durCount)}ms (n=${durCount})` : "n/a";
214
215 return [
216 `## Audit log (${t.auditLog.length} rows, top actions)`,
217 auditLines || "(none)",
218 "",
219 `## Platform deploys (${t.platformDeploys.length} rows, most recent first)`,
220 deployLines || "(none)",
221 "",
222 `## Workflow runs (${t.workflowRuns.length} rows, avg duration ${avgWfDuration})`,
223 wfLines || "(none)",
224 ].join("\n");
225}
226
227async function defaultLoadTelemetry(
228 lookbackHours: number
229): Promise<ProactiveTelemetry> {
230 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
231 const empty: ProactiveTelemetry = {
232 auditLog: [],
233 platformDeploys: [],
234 workflowRuns: [],
235 };
236 try {
237 const [audits, deploys, runs] = await Promise.all([
238 db
239 .select({
240 action: auditLog.action,
241 targetType: auditLog.targetType,
242 targetId: auditLog.targetId,
243 userId: auditLog.userId,
244 repositoryId: auditLog.repositoryId,
245 createdAt: auditLog.createdAt,
246 })
247 .from(auditLog)
248 .where(gte(auditLog.createdAt, cutoff))
249 .orderBy(desc(auditLog.createdAt))
250 .limit(MAX_AUDIT_ROWS)
251 .catch(() => [] as ProactiveTelemetry["auditLog"]),
252 db
253 .select({
254 runId: platformDeploys.runId,
255 sha: platformDeploys.sha,
256 status: platformDeploys.status,
257 durationMs: platformDeploys.durationMs,
258 error: platformDeploys.error,
259 startedAt: platformDeploys.startedAt,
260 finishedAt: platformDeploys.finishedAt,
261 })
262 .from(platformDeploys)
263 .where(gte(platformDeploys.startedAt, cutoff))
264 .orderBy(desc(platformDeploys.startedAt))
265 .limit(MAX_DEPLOY_ROWS)
266 .catch(() => [] as ProactiveTelemetry["platformDeploys"]),
267 db
268 .select({
269 id: workflowRuns.id,
270 status: workflowRuns.status,
271 conclusion: workflowRuns.conclusion,
272 event: workflowRuns.event,
273 queuedAt: workflowRuns.queuedAt,
274 startedAt: workflowRuns.startedAt,
275 finishedAt: workflowRuns.finishedAt,
276 })
277 .from(workflowRuns)
278 .where(gte(workflowRuns.queuedAt, cutoff))
279 .orderBy(desc(workflowRuns.queuedAt))
280 .limit(MAX_WORKFLOW_RUN_ROWS)
281 .catch(() => [] as ProactiveTelemetry["workflowRuns"]),
282 ]);
283 return { auditLog: audits, platformDeploys: deploys, workflowRuns: runs };
284 } catch (err) {
285 console.error("[ai-proactive] telemetry load failed:", err);
286 return empty;
287 }
288}
289
290async function defaultAskClaude(
291 telemetry: ProactiveTelemetry
292): Promise<ProactiveFinding[]> {
293 try {
294 const client = getAnthropic();
295 const message = await client.messages.create({
296 model: MODEL_SONNET,
297 max_tokens: 2048,
298 messages: [
299 {
300 role: "user",
301 content: `You are monitoring Gluecron's own platform health. Here is the last ${PROACTIVE_LOOKBACK_HOURS}h of telemetry. Spot anomalies — degraded deploy times, recurring failures, suspicious audit patterns, memory-leak suspects in long-running workers, abnormal workflow run durations, repeated permission denials, etc.
302
303For each finding, return an entry in a JSON array of the form:
304
305{"findings": [
306 {
307 "title": "<one-line problem summary, prefixed with the affected subsystem>",
308 "severity": "info" | "warning" | "critical",
309 "body_markdown": "<2-6 paragraphs of markdown: what you saw, why it might matter, suggested next step>",
310 "target_url": "<optional admin URL the operator should visit, or null>"
311 }
312]}
313
314Return ONLY the JSON. If nothing looks anomalous, return {"findings": []}. Do not invent findings — silence is the correct answer for a healthy platform.
315
316Telemetry follows:
317
318${summariseTelemetryForPrompt(telemetry)}`,
319 },
320 ],
321 });
322 const parsed = parseJsonResponse<{ findings: ProactiveFinding[] }>(
323 extractText(message)
324 );
325 if (!parsed || !Array.isArray(parsed.findings)) return [];
326 return parsed.findings.filter(
327 (f) =>
328 typeof f.title === "string" &&
329 f.title.trim().length > 0 &&
330 typeof f.body_markdown === "string" &&
331 (f.severity === "info" ||
332 f.severity === "warning" ||
333 f.severity === "critical")
334 );
335 } catch (err) {
336 console.error("[ai-proactive] Claude call failed:", err);
337 return [];
338 }
339}
340
341async function defaultResolveSelfHostRepo(): Promise<{
342 repositoryId: string;
343 ownerId: string;
344} | null> {
345 const fullName = process.env.SELF_HOST_REPO || DEFAULT_SELF_HOST_REPO;
346 const [ownerName, repoName] = fullName.includes("/")
347 ? fullName.split("/")
348 : [fullName, "Gluecron.com"];
349 try {
350 const [row] = await db
351 .select({
352 repositoryId: repositories.id,
353 ownerId: repositories.ownerId,
354 })
355 .from(repositories)
356 .innerJoin(users, eq(users.id, repositories.ownerId))
357 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
358 .limit(1);
359 return row || null;
360 } catch (err) {
361 console.error("[ai-proactive] self-host repo resolve failed:", err);
362 return null;
363 }
364}
365
366async function defaultIsDuplicate(
367 repositoryId: string,
368 dedupeKey: string,
369 lookbackHours: number
370): Promise<boolean> {
371 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
372 try {
373 const [row] = await db
374 .select({ id: issues.id })
375 .from(issues)
376 .where(
377 and(
378 eq(issues.repositoryId, repositoryId),
379 gte(issues.createdAt, cutoff),
380 like(issues.body, `%${dedupeMarker(dedupeKey)}%`)
381 )
382 )
383 .limit(1);
384 return !!row;
385 } catch (err) {
386 console.error("[ai-proactive] dedupe lookup failed:", err);
387 // Fail-closed on dedupe: pretend it's a duplicate so we don't double-fire.
388 return true;
389 }
390}
391
392/** Best-effort label upsert — returns the label id or null. */
393async function ensureProactiveLabel(
394 repositoryId: string
395): Promise<string | null> {
396 try {
397 const [existing] = await db
398 .select({ id: labels.id })
399 .from(labels)
400 .where(
401 and(
402 eq(labels.repositoryId, repositoryId),
403 eq(labels.name, PROACTIVE_LABEL_NAME)
404 )
405 )
406 .limit(1);
407 if (existing) return existing.id;
408 const [inserted] = await db
409 .insert(labels)
410 .values({
411 repositoryId,
412 name: PROACTIVE_LABEL_NAME,
413 color: "#a371f7",
414 description: "Auto-filed by the AI proactive monitor.",
415 })
416 .returning({ id: labels.id });
417 return inserted?.id ?? null;
418 } catch (err) {
419 console.error("[ai-proactive] label ensure failed:", err);
420 return null;
421 }
422}
423
424async function defaultCreateFindingIssue(args: {
425 repositoryId: string;
426 authorId: string;
427 title: string;
428 body: string;
429}): Promise<number | null> {
430 try {
431 const [inserted] = await db
432 .insert(issues)
433 .values({
434 repositoryId: args.repositoryId,
435 authorId: args.authorId,
436 title: args.title,
437 body: args.body,
438 state: "open",
439 })
440 .returning({ id: issues.id, number: issues.number });
441 if (!inserted) return null;
442 const labelId = await ensureProactiveLabel(args.repositoryId);
443 if (labelId) {
444 await db
445 .insert(issueLabels)
446 .values({ issueId: inserted.id, labelId })
447 .catch(() => {
448 /* duplicate label link — ignore */
449 });
450 }
451 return inserted.number ?? null;
452 } catch (err) {
453 console.error("[ai-proactive] issue insert failed:", err);
454 return null;
455 }
456}
457
458async function defaultRecordAudit(
459 finding: ProactiveFinding,
460 repositoryId: string | null,
461 issueNumber: number | null,
462 dedupeKey: string
463): Promise<void> {
464 await audit({
465 repositoryId: repositoryId ?? undefined,
466 action: "ai.proactive.finding",
467 targetType: issueNumber !== null ? "issue" : undefined,
468 targetId: issueNumber !== null ? String(issueNumber) : undefined,
469 metadata: {
470 title: finding.title,
471 severity: finding.severity,
472 dedupeKey,
473 targetUrl: finding.target_url ?? null,
474 },
475 });
476}
477
478/**
479 * One iteration of the proactive monitor. Never throws. Returns a
480 * summary suitable for the autopilot tick log.
481 *
482 * Pipeline:
483 * 1. Skip if AI is unavailable (no ANTHROPIC_API_KEY).
484 * 2. Resolve the self-host repo (env override + sensible default).
485 * 3. Load 24h of telemetry (audit + platform deploys + workflow runs).
486 * 4. Ask Claude for findings as structured JSON.
487 * 5. For each finding above severity=info:
488 * - Skip if a duplicate (sha256 of title) was filed in the last 24h.
489 * - Open an issue tagged `ai:proactive-finding` with the dedupe marker.
490 * - Record an `ai.proactive.finding` audit row.
491 */
492export async function aiProactiveMonitorTick(
493 deps: ProactiveMonitorDeps = {}
494): Promise<ProactiveMonitorSummary> {
495 const aiAvailable = deps.aiAvailable ?? isAiAvailable;
496 const summary: ProactiveMonitorSummary = {
497 considered: 0,
498 opened: 0,
499 skippedDedupe: 0,
500 skippedSeverity: 0,
501 errors: 0,
502 };
503
504 if (!aiAvailable()) {
505 return summary;
506 }
507
508 const loadTelemetry = deps.loadTelemetry ?? defaultLoadTelemetry;
509 const askClaude = deps.askClaude ?? defaultAskClaude;
510 const resolveSelfHostRepo =
511 deps.resolveSelfHostRepo ?? defaultResolveSelfHostRepo;
512 const isDuplicate = deps.isDuplicate ?? defaultIsDuplicate;
513 const createFindingIssue =
514 deps.createFindingIssue ?? defaultCreateFindingIssue;
515 const recordAudit = deps.recordAudit ?? defaultRecordAudit;
516 const maxFindings = deps.maxFindings ?? MAX_FINDINGS_PER_TICK;
517
518 let repo: { repositoryId: string; ownerId: string } | null = null;
519 try {
520 repo = await resolveSelfHostRepo();
521 } catch (err) {
522 console.error("[ai-proactive] resolveSelfHostRepo threw:", err);
523 summary.errors += 1;
524 return summary;
525 }
526 if (!repo) {
527 console.warn(
528 "[ai-proactive] self-host repo not found; skipping tick (set SELF_HOST_REPO to the owner/name of the platform repo)"
529 );
530 return summary;
531 }
532
533 let telemetry: ProactiveTelemetry;
534 try {
535 telemetry = await loadTelemetry(PROACTIVE_LOOKBACK_HOURS);
536 } catch (err) {
537 console.error("[ai-proactive] loadTelemetry threw:", err);
538 summary.errors += 1;
539 return summary;
540 }
541
542 let findings: ProactiveFinding[] = [];
543 try {
544 findings = await askClaude(telemetry);
545 } catch (err) {
546 console.error("[ai-proactive] askClaude threw:", err);
547 summary.errors += 1;
548 return summary;
549 }
550
551 for (const finding of findings.slice(0, maxFindings)) {
552 summary.considered += 1;
553 try {
554 if (finding.severity === "info") {
555 summary.skippedSeverity += 1;
556 continue;
557 }
558 const dedupeKey = dedupeKeyForTitle(finding.title);
559 const dup = await isDuplicate(
560 repo.repositoryId,
561 dedupeKey,
562 PROACTIVE_LOOKBACK_HOURS
563 );
564 if (dup) {
565 summary.skippedDedupe += 1;
566 continue;
567 }
568 const body = renderFindingBody(finding, dedupeKey);
569 const issueNumber = await createFindingIssue({
570 repositoryId: repo.repositoryId,
571 authorId: repo.ownerId,
572 title: finding.title.slice(0, 200),
573 body,
574 });
575 if (issueNumber !== null) {
576 summary.opened += 1;
577 } else {
578 summary.errors += 1;
579 }
580 // Audit fires regardless of issue-insert success so we still have a
581 // trail of what Claude flagged.
582 await recordAudit(finding, repo.repositoryId, issueNumber, dedupeKey);
583 } catch (err) {
584 summary.errors += 1;
585 console.error(
586 `[ai-proactive] per-finding failure for "${finding.title}":`,
587 err
588 );
589 }
590 }
591
592 if (findings.length > maxFindings) {
593 // Count the overflow under `skippedSeverity` since we never even
594 // looked at them — keeps the summary surface flat (no need for a
595 // separate "overflow" counter for an autopilot log line).
596 summary.skippedSeverity += findings.length - maxFindings;
597 }
598
599 console.log(
600 `[ai-proactive] tick considered=${summary.considered} opened=${summary.opened} dedup=${summary.skippedDedupe} skipped=${summary.skippedSeverity} errors=${summary.errors}`
601 );
602 return summary;
603}
604
605/**
606 * Pure helper — renders the issue body markdown for a single finding,
607 * including the dedupe marker. Exported so tests can pin the format
608 * without an Anthropic call.
609 */
610export function renderFindingBody(
611 finding: ProactiveFinding,
612 dedupeKey: string
613): string {
614 const sevBadge =
615 finding.severity === "critical"
616 ? "**Severity:** :rotating_light: critical"
617 : finding.severity === "warning"
618 ? "**Severity:** :warning: warning"
619 : "**Severity:** :information_source: info";
620 const target = finding.target_url
621 ? `**Suggested admin URL:** ${finding.target_url}`
622 : "";
623 return [
624 dedupeMarker(dedupeKey),
625 "_Filed automatically by the GlueCron AI proactive monitor._",
626 "",
627 sevBadge,
628 target,
629 "",
630 finding.body_markdown.trim(),
631 "",
632 "---",
633 `_Dedupe key: \`${dedupeKey}\`. The same finding will not be re-filed for ${PROACTIVE_LOOKBACK_HOURS}h._`,
634 ]
635 .filter((line) => line !== "")
636 .join("\n");
637}
638
639/** Test-only export of internals. */
640export const __test = {
641 summariseTelemetryForPrompt,
642 defaultLoadTelemetry,
643 defaultResolveSelfHostRepo,
644 defaultIsDuplicate,
645 defaultCreateFindingIssue,
646 defaultRecordAudit,
647 dedupeMarker,
648 ensureProactiveLabel,
649 MAX_FINDINGS_PER_TICK,
650};
Modifiedsrc/lib/autopilot.ts+29−0View fileUnifiedSplit
5454 latestStatusByCheck,
5555 type SyntheticCheckResult,
5656} from "./synthetic-monitor";
57import { aiProactiveMonitorTick } from "./ai-proactive-monitor";
5758
5859export interface AutopilotTaskResult {
5960 name: string;
9697const PR_RISK_RESCORE_MAX_PER_TICK = 20;
9798/** M3 — recency window for the pr-risk-rescore sweep. */
9899const PR_RISK_RESCORE_LOOKBACK_HOURS = 1;
100/** Proactive monitor cadence — Claude scans platform telemetry hourly. */
101const PROACTIVE_MONITOR_INTERVAL_MS = 60 * 60 * 1000;
102let _lastProactiveMonitorAt = 0;
99103
100104/**
101105 * Default task set. Each task is a thin wrapper around an existing locked
233237 }
234238 },
235239 },
240 {
241 // Proactive AI monitor — hourly. Claude reads 24h of audit_log +
242 // platformDeploys + workflowRuns and opens issues on anomalies
243 // (degraded deploy times, recurring failures, suspicious audit
244 // patterns). Skips when ANTHROPIC_API_KEY is unset (the lib
245 // itself short-circuits, but the cadence gate avoids redundant
246 // work on every 5-min tick too).
247 name: "ai-proactive-monitor",
248 run: async () => {
249 if (!process.env.ANTHROPIC_API_KEY) return;
250 const now = Date.now();
251 if (now - _lastProactiveMonitorAt < PROACTIVE_MONITOR_INTERVAL_MS) {
252 return;
253 }
254 _lastProactiveMonitorAt = now;
255 try {
256 const summary = await aiProactiveMonitorTick();
257 console.log(
258 `[autopilot] ai-proactive-monitor: opened=${summary.opened} considered=${summary.considered} dedup=${summary.skippedDedupe}`
259 );
260 } catch (err) {
261 console.error("[autopilot] ai-proactive-monitor: threw:", err);
262 }
263 },
264 },
236265 {
237266 // BLOCK S4 — Synthetic monitor.
238267 //
Addedsrc/routes/activity.tsx+766−0View fileUnifiedSplit
1/**
2 * `/activity` — the user's personal timeline.
3 *
4 * One reverse-chronological feed of everything that touched the user's
5 * repos or was authored by them. The differentiator vs. github.com/
6 * <user> is the first-class AI lane: when Claude reviews a PR, triages
7 * an issue, or auto-merges, those events get pulled out of the noise
8 * with an AI-EVENT badge and a dedicated tab.
9 *
10 * Filters:
11 * - all — everything we have
12 * - ai — only `ai:*` actions (the differentiator)
13 * - code — push / merge / branch / commit events
14 * - social — stars / follows / forks
15 *
16 * Pagination: `?before=<ISO-timestamp>` walks back in pages of 100.
17 *
18 * Scoped CSS under `.act-*`.
19 */
20
21import { Hono } from "hono";
22import { and, desc, eq, inArray, lt, or } from "drizzle-orm";
23import { db } from "../db";
24import { activityFeed, repositories } from "../db/schema";
25import { Layout } from "../views/layout";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28
29const activity = new Hono<AuthEnv>();
30activity.use("*", softAuth);
31
32const styles = `
33 .act-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
34
35 .act-hero {
36 position: relative;
37 margin-bottom: var(--space-5);
38 padding: var(--space-5) var(--space-6);
39 background: var(--bg-elevated);
40 border: 1px solid var(--border);
41 border-radius: 16px;
42 overflow: hidden;
43 }
44 .act-hero::before {
45 content: '';
46 position: absolute;
47 top: 0; left: 0; right: 0;
48 height: 2px;
49 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
50 opacity: 0.7;
51 pointer-events: none;
52 }
53 .act-orb {
54 position: absolute;
55 inset: -30% -10% auto auto;
56 width: 380px; height: 380px;
57 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
58 filter: blur(80px);
59 opacity: 0.7;
60 pointer-events: none;
61 }
62 .act-hero-inner { position: relative; z-index: 1; max-width: 780px; }
63 .act-eyebrow {
64 font-size: 12.5px;
65 color: var(--text-muted);
66 margin-bottom: 8px;
67 letter-spacing: 0.04em;
68 text-transform: uppercase;
69 font-weight: 600;
70 }
71 .act-title {
72 font-family: var(--font-display);
73 font-size: clamp(28px, 4vw, 40px);
74 font-weight: 800;
75 letter-spacing: -0.028em;
76 line-height: 1.05;
77 margin: 0 0 10px;
78 color: var(--text-strong);
79 }
80 .act-title-grad {
81 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
82 -webkit-background-clip: text;
83 background-clip: text;
84 -webkit-text-fill-color: transparent;
85 color: transparent;
86 }
87 .act-sub {
88 font-size: 15px;
89 color: var(--text-muted);
90 margin: 0;
91 line-height: 1.5;
92 }
93
94 .act-stats {
95 display: flex;
96 gap: 24px;
97 margin-top: 16px;
98 flex-wrap: wrap;
99 align-items: flex-end;
100 }
101 .act-stat { display: flex; flex-direction: column; gap: 2px; }
102 .act-stat-n {
103 font-family: var(--font-display);
104 font-size: 22px;
105 font-weight: 700;
106 color: var(--text-strong);
107 font-variant-numeric: tabular-nums;
108 }
109 .act-stat-l {
110 font-size: 11.5px;
111 color: var(--text-muted);
112 letter-spacing: 0.02em;
113 text-transform: uppercase;
114 }
115 .act-stat.ai .act-stat-n {
116 background-image: linear-gradient(135deg, #b69dff 0%, #36c5d6 100%);
117 -webkit-background-clip: text;
118 background-clip: text;
119 -webkit-text-fill-color: transparent;
120 color: transparent;
121 }
122
123 .act-spark {
124 display: inline-flex;
125 align-items: flex-end;
126 gap: 3px;
127 height: 28px;
128 margin-left: auto;
129 padding: 4px 8px;
130 border-radius: 6px;
131 background: rgba(255,255,255,0.02);
132 border: 1px solid var(--border);
133 }
134 .act-spark-bar {
135 width: 6px;
136 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
137 border-radius: 1px 1px 0 0;
138 opacity: 0.85;
139 min-height: 2px;
140 }
141 .act-spark-label {
142 font-size: 11px;
143 color: var(--text-muted);
144 margin-left: 8px;
145 letter-spacing: 0.04em;
146 text-transform: uppercase;
147 }
148
149 .act-tabs {
150 display: inline-flex;
151 background: var(--bg-elevated);
152 border: 1px solid var(--border);
153 border-radius: 9999px;
154 padding: 4px;
155 gap: 2px;
156 margin-bottom: var(--space-4);
157 flex-wrap: wrap;
158 }
159 .act-tab {
160 display: inline-flex;
161 align-items: center;
162 gap: 6px;
163 padding: 7px 14px;
164 border-radius: 9999px;
165 font-size: 13.5px;
166 font-weight: 500;
167 color: var(--text-muted);
168 text-decoration: none;
169 transition: color 120ms ease, background 120ms ease;
170 }
171 .act-tab:hover { color: var(--text-strong); text-decoration: none; }
172 .act-tab.is-active {
173 background: rgba(140,109,255,0.14);
174 color: var(--text-strong);
175 }
176 .act-tab-count {
177 font-variant-numeric: tabular-nums;
178 font-size: 11.5px;
179 background: rgba(255,255,255,0.04);
180 padding: 1px 7px;
181 border-radius: 9999px;
182 }
183 .act-tab.is-active .act-tab-count {
184 background: rgba(140,109,255,0.22);
185 color: var(--text);
186 }
187
188 .act-list {
189 list-style: none;
190 margin: 0;
191 padding: 0;
192 border: 1px solid var(--border);
193 border-radius: 12px;
194 overflow: hidden;
195 background: var(--bg-elevated);
196 }
197 .act-row {
198 display: flex;
199 align-items: flex-start;
200 gap: 14px;
201 padding: 14px 18px;
202 border-bottom: 1px solid var(--border);
203 transition: background 120ms ease;
204 text-decoration: none;
205 color: inherit;
206 }
207 .act-row:last-child { border-bottom: none; }
208 .act-row:hover { background: rgba(140,109,255,0.04); text-decoration: none; }
209 .act-row.is-ai {
210 background: linear-gradient(90deg, rgba(140,109,255,0.05) 0%, transparent 60%);
211 }
212 .act-row.is-ai:hover {
213 background: linear-gradient(90deg, rgba(140,109,255,0.10) 0%, rgba(140,109,255,0.03) 60%);
214 }
215
216 .act-row-icon {
217 width: 28px; height: 28px;
218 flex-shrink: 0;
219 border-radius: 8px;
220 display: inline-flex;
221 align-items: center;
222 justify-content: center;
223 font-size: 12px;
224 font-weight: 700;
225 letter-spacing: 0.04em;
226 text-transform: uppercase;
227 background: rgba(255,255,255,0.04);
228 color: var(--text-muted);
229 box-shadow: inset 0 0 0 1px var(--border);
230 }
231 .act-row-icon.kind-ai { background: rgba(140,109,255,0.18); color: #b69dff; box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40); }
232 .act-row-icon.kind-code { background: rgba(54,197,214,0.14); color: #6fd6e6; box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32); }
233 .act-row-icon.kind-pr { background: rgba(52,211,153,0.13); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
234 .act-row-icon.kind-issue { background: rgba(251,191,36,0.12); color: #fde68a; box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30); }
235 .act-row-icon.kind-social { background: rgba(244,114,182,0.12); color: #f9a8d4; box-shadow: inset 0 0 0 1px rgba(244,114,182,0.30); }
236 .act-row-icon.kind-merge { background: rgba(140,109,255,0.16); color: #b69dff; box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32); }
237
238 .act-row-main { flex: 1; min-width: 0; }
239 .act-row-title {
240 font-family: var(--font-display);
241 font-size: 15px;
242 font-weight: 600;
243 line-height: 1.35;
244 letter-spacing: -0.012em;
245 margin: 0 0 4px;
246 display: flex;
247 flex-wrap: wrap;
248 align-items: center;
249 gap: 8px;
250 color: var(--text-strong);
251 }
252 .act-verb {
253 text-transform: lowercase;
254 font-weight: 600;
255 }
256 .act-verb.kind-ai { color: #b69dff; }
257 .act-verb.kind-code { color: #6fd6e6; }
258 .act-verb.kind-pr { color: #6ee7b7; }
259 .act-verb.kind-issue { color: #fde68a; }
260 .act-verb.kind-social { color: #f9a8d4; }
261 .act-verb.kind-merge { color: #b69dff; }
262
263 .act-badge {
264 display: inline-flex;
265 align-items: center;
266 gap: 4px;
267 padding: 2px 8px;
268 font-size: 10.5px;
269 font-weight: 700;
270 border-radius: 9999px;
271 letter-spacing: 0.06em;
272 text-transform: uppercase;
273 font-variant-numeric: tabular-nums;
274 }
275 .act-badge.ai-event {
276 color: #b69dff;
277 background: rgba(140,109,255,0.18);
278 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
279 }
280 .act-badge.ai-event .dot {
281 width: 6px; height: 6px;
282 border-radius: 9999px;
283 background: currentColor;
284 box-shadow: 0 0 8px currentColor;
285 }
286
287 .act-row-meta {
288 font-size: 12.5px;
289 color: var(--text-muted);
290 font-variant-numeric: tabular-nums;
291 display: flex;
292 gap: 10px;
293 flex-wrap: wrap;
294 align-items: center;
295 }
296 .act-row-meta .sep { opacity: 0.45; }
297 .act-row-repo {
298 font-family: var(--font-mono);
299 font-size: 12px;
300 color: var(--text);
301 }
302 .act-row-time {
303 font-variant-numeric: tabular-nums;
304 color: var(--text-muted);
305 }
306
307 .act-pager {
308 display: flex;
309 justify-content: center;
310 margin-top: var(--space-4);
311 }
312
313 .act-empty {
314 padding: 60px 20px;
315 text-align: center;
316 border: 1px dashed var(--border-strong);
317 border-radius: 14px;
318 background: var(--bg-elevated);
319 position: relative;
320 overflow: hidden;
321 }
322 .act-empty::before {
323 content: '';
324 position: absolute;
325 inset: -20% -10% auto auto;
326 width: 320px; height: 320px;
327 background: radial-gradient(circle, rgba(140,109,255,0.10), transparent 70%);
328 filter: blur(60px);
329 pointer-events: none;
330 }
331 .act-empty-title {
332 font-family: var(--font-display);
333 font-size: 22px;
334 font-weight: 700;
335 color: var(--text-strong);
336 margin: 0 0 6px;
337 position: relative;
338 }
339 .act-empty-sub {
340 color: var(--text-muted);
341 font-size: 14px;
342 margin: 0 0 18px;
343 position: relative;
344 }
345 .act-empty .btn { position: relative; }
346
347 @media (max-width: 720px) {
348 .act-hero { padding: 24px 20px; }
349 .act-row { padding: 12px 14px; }
350 .act-stats { gap: 16px; }
351 .act-spark { margin-left: 0; }
352 }
353`;
354
355type ActFilter = "all" | "ai" | "code" | "social";
356const VALID_FILTERS: ActFilter[] = ["all", "ai", "code", "social"];
357
358type ActKind = "ai" | "code" | "pr" | "issue" | "social" | "merge" | "other";
359
360interface Classified {
361 kind: ActKind;
362 isAi: boolean;
363 verb: string;
364 category: "ai" | "code" | "social" | "other";
365}
366
367/**
368 * Map a raw `action` string to a kind + display verb + filter category.
369 * The schema doesn't enforce a controlled vocabulary so we pattern-match
370 * defensively. `ai:` prefix wins regardless of the underlying action.
371 */
372function classify(action: string): Classified {
373 const a = (action || "").toLowerCase();
374 const isAi = a.startsWith("ai:") || a.includes(":ai") || a.includes(".ai.");
375 // The verb shown to the user — strip prefixes / normalise separators.
376 const verb = a.replace(/^ai:/, "").replace(/[._]/g, "-");
377
378 if (isAi) {
379 return { kind: "ai", isAi: true, verb, category: "ai" };
380 }
381 if (
382 a.includes("push") ||
383 a.includes("commit") ||
384 a.includes("branch") ||
385 a.includes("tag") ||
386 a.includes("merge") ||
387 a.includes("deploy")
388 ) {
389 const kind: ActKind = a.includes("merge") ? "merge" : "code";
390 return { kind, isAi: false, verb, category: "code" };
391 }
392 if (a.includes("pr") || a.includes("pull")) {
393 return { kind: "pr", isAi: false, verb, category: "code" };
394 }
395 if (a.includes("issue")) {
396 return { kind: "issue", isAi: false, verb, category: "other" };
397 }
398 if (
399 a.includes("star") ||
400 a.includes("fork") ||
401 a.includes("follow") ||
402 a.includes("watch")
403 ) {
404 return { kind: "social", isAi: false, verb, category: "social" };
405 }
406 return { kind: "other", isAi: false, verb, category: "other" };
407}
408
409function iconLabel(kind: ActKind): string {
410 switch (kind) {
411 case "ai":
412 return "AI";
413 case "code":
414 return "</>";
415 case "pr":
416 return "PR";
417 case "issue":
418 return "!";
419 case "social":
420 return "★";
421 case "merge":
422 return "M";
423 default:
424 return "·";
425 }
426}
427
428function relTime(d: Date): string {
429 const diff = Date.now() - d.getTime();
430 const s = Math.floor(diff / 1000);
431 if (s < 60) return `${s}s ago`;
432 const m = Math.floor(s / 60);
433 if (m < 60) return `${m}m ago`;
434 const h = Math.floor(m / 60);
435 if (h < 24) return `${h}h ago`;
436 const days = Math.floor(h / 24);
437 if (days < 30) return `${days}d ago`;
438 const months = Math.floor(days / 30);
439 if (months < 12) return `${months}mo ago`;
440 return `${Math.floor(months / 12)}y ago`;
441}
442
443/**
444 * Best-effort source URL for a row. Falls back to the repo page when the
445 * target type/id isn't enough to deep-link.
446 */
447function sourceHref(
448 ownerUsername: string,
449 repoName: string,
450 targetType: string | null,
451 targetId: string | null
452): string {
453 const base = `/${ownerUsername}/${repoName}`;
454 if (!targetId) return base;
455 switch (targetType) {
456 case "issue":
457 return `${base}/issues/${targetId}`;
458 case "pr":
459 case "pull_request":
460 return `${base}/pulls/${targetId}`;
461 case "commit":
462 return `${base}/commit/${targetId}`;
463 default:
464 return base;
465 }
466}
467
468activity.get("/activity", requireAuth, async (c) => {
469 const user = c.get("user")!;
470 const rawFilter = c.req.query("filter") || "all";
471 const filter: ActFilter = (VALID_FILTERS as string[]).includes(rawFilter)
472 ? (rawFilter as ActFilter)
473 : "all";
474
475 const beforeRaw = c.req.query("before");
476 let before: Date | null = null;
477 if (beforeRaw) {
478 const t = Date.parse(beforeRaw);
479 if (!Number.isNaN(t)) before = new Date(t);
480 }
481
482 // Build the candidate set: anything authored by the user OR scoped to
483 // a repo the user owns. Collaborator repos aren't pulled in here — the
484 // dashboard does that and it'd blow the timeline up with noise.
485 let ownedRepoIds: string[] = [];
486 try {
487 const owned = await db
488 .select({ id: repositories.id })
489 .from(repositories)
490 .where(eq(repositories.ownerId, user.id));
491 ownedRepoIds = owned.map((r) => r.id);
492 } catch (err) {
493 console.error("[activity] owned repo lookup failed:", err);
494 }
495
496 type Row = typeof activityFeed.$inferSelect;
497 let rows: Row[] = [];
498 try {
499 const scopeClause =
500 ownedRepoIds.length > 0
501 ? or(
502 eq(activityFeed.userId, user.id),
503 inArray(activityFeed.repositoryId, ownedRepoIds)
504 )!
505 : eq(activityFeed.userId, user.id);
506 const whereClause = before
507 ? and(scopeClause, lt(activityFeed.createdAt, before))!
508 : scopeClause;
509 rows = await db
510 .select()
511 .from(activityFeed)
512 .where(whereClause)
513 .orderBy(desc(activityFeed.createdAt))
514 .limit(100);
515 } catch (err) {
516 console.error("[activity] feed query failed:", err);
517 }
518
519 // Resolve repo names / owner usernames in one batched lookup so each
520 // row card can render the `owner/repo` mono pill + a deep link.
521 const repoIds = Array.from(new Set(rows.map((r) => r.repositoryId)));
522 const repoInfo = new Map<
523 string,
524 { name: string; ownerUsername: string }
525 >();
526 if (repoIds.length > 0) {
527 try {
528 const { users } = await import("../db/schema");
529 const repoRows = await db
530 .select({
531 id: repositories.id,
532 name: repositories.name,
533 ownerUsername: users.username,
534 })
535 .from(repositories)
536 .innerJoin(users, eq(users.id, repositories.ownerId))
537 .where(inArray(repositories.id, repoIds));
538 for (const r of repoRows) {
539 repoInfo.set(r.id, {
540 name: r.name,
541 ownerUsername: r.ownerUsername,
542 });
543 }
544 } catch (err) {
545 console.error("[activity] repo info lookup failed:", err);
546 }
547 }
548
549 // Classify once, then partition for tab counters + the active filter.
550 const classified = rows.map((r) => ({ row: r, info: classify(r.action) }));
551 const counts = {
552 all: classified.length,
553 ai: classified.filter((x) => x.info.category === "ai").length,
554 code: classified.filter((x) => x.info.category === "code").length,
555 social: classified.filter((x) => x.info.category === "social").length,
556 };
557
558 const visible = classified.filter((x) => {
559 if (filter === "all") return true;
560 return x.info.category === filter;
561 });
562
563 // "This week" mini sparkline — 7 buckets, 24h each, ending now. Cheap
564 // (operates on the already-fetched 100 rows; no extra query).
565 const weekBuckets = new Array<number>(7).fill(0);
566 const now = Date.now();
567 for (const x of classified) {
568 const ageMs = now - x.row.createdAt.getTime();
569 const day = Math.floor(ageMs / (24 * 3600 * 1000));
570 if (day >= 0 && day < 7) {
571 // Bucket 0 = oldest day in the week, 6 = today.
572 weekBuckets[6 - day] = (weekBuckets[6 - day] ?? 0) + 1;
573 }
574 }
575 const weekMax = Math.max(1, ...weekBuckets);
576 const weekTotal = weekBuckets.reduce((a, b) => a + b, 0);
577
578 const oldestVisible =
579 visible.length === 100 ? visible[visible.length - 1]!.row.createdAt : null;
580 const nextHref = oldestVisible
581 ? `/activity?filter=${filter}&before=${encodeURIComponent(
582 oldestVisible.toISOString()
583 )}`
584 : null;
585
586 const emptyCopy =
587 filter === "ai"
588 ? {
589 title: "No AI events yet.",
590 sub: "When Claude reviews a PR, triages an issue, or auto-merges in one of your repos, it'll land here with an AI-EVENT badge.",
591 }
592 : filter === "code"
593 ? {
594 title: "No code events yet.",
595 sub: "Pushes, merges, branches, and tags from your repos will appear here in real time.",
596 }
597 : filter === "social"
598 ? {
599 title: "No social activity yet.",
600 sub: "Stars, follows, and forks across your repos will surface here.",
601 }
602 : {
603 title: "Your timeline is quiet.",
604 sub: "Push a branch, open a PR, or star a repo — everything Gluecron sees lands here, ordered newest first.",
605 };
606
607 return c.html(
608 <Layout title="Activity · Gluecron" user={user}>
609 <div class="act-wrap">
610 <section class="act-hero">
611 <div class="act-orb" aria-hidden="true" />
612 <div class="act-hero-inner">
613 <div class="act-eyebrow">
614 Personal timeline · live ·{" "}
615 <span style="color:var(--accent);font-weight:600">
616 {user.username}
617 </span>
618 </div>
619 <h1 class="act-title">
620 <span class="act-title-grad">Your activity.</span>
621 </h1>
622 <p class="act-sub">
623 Everything that's happened across your repos — pushes, PRs,
624 issues, stars — plus the things Claude did on your behalf.
625 AI events surface separately so you always see the work that
626 shipped while you were away.
627 </p>
628 <div class="act-stats">
629 <div class="act-stat">
630 <div class="act-stat-n">{counts.all}</div>
631 <div class="act-stat-l">Total</div>
632 </div>
633 <div class="act-stat ai">
634 <div class="act-stat-n">{counts.ai}</div>
635 <div class="act-stat-l">AI events</div>
636 </div>
637 <div class="act-stat">
638 <div class="act-stat-n">{counts.code}</div>
639 <div class="act-stat-l">Code</div>
640 </div>
641 <div class="act-stat">
642 <div class="act-stat-n">{counts.social}</div>
643 <div class="act-stat-l">Social</div>
644 </div>
645 {weekTotal > 0 && (
646 <div class="act-spark" aria-label="Events per day, last 7 days">
647 {weekBuckets.map((n) => (
648 <div
649 class="act-spark-bar"
650 style={`height: ${Math.max(2, Math.round((n / weekMax) * 24))}px`}
651 title={`${n} event${n === 1 ? "" : "s"}`}
652 />
653 ))}
654 <span class="act-spark-label">7d · {weekTotal}</span>
655 </div>
656 )}
657 </div>
658 </div>
659 </section>
660
661 <nav class="act-tabs" aria-label="Activity filters">
662 <a
663 href="/activity?filter=all"
664 class={"act-tab " + (filter === "all" ? "is-active" : "")}
665 >
666 All <span class="act-tab-count">{counts.all}</span>
667 </a>
668 <a
669 href="/activity?filter=ai"
670 class={"act-tab " + (filter === "ai" ? "is-active" : "")}
671 >
672 AI <span class="act-tab-count">{counts.ai}</span>
673 </a>
674 <a
675 href="/activity?filter=code"
676 class={"act-tab " + (filter === "code" ? "is-active" : "")}
677 >
678 Code <span class="act-tab-count">{counts.code}</span>
679 </a>
680 <a
681 href="/activity?filter=social"
682 class={"act-tab " + (filter === "social" ? "is-active" : "")}
683 >
684 Social <span class="act-tab-count">{counts.social}</span>
685 </a>
686 </nav>
687
688 {visible.length === 0 ? (
689 <div class="act-empty">
690 <h2 class="act-empty-title">{emptyCopy.title}</h2>
691 <p class="act-empty-sub">{emptyCopy.sub}</p>
692 <a href="/explore" class="btn btn-primary">
693 Explore repos
694 </a>
695 </div>
696 ) : (
697 <>
698 <ul class="act-list">
699 {visible.map((x) => {
700 const info = repoInfo.get(x.row.repositoryId);
701 const repoName = info?.name || "unknown";
702 const ownerUsername = info?.ownerUsername || "unknown";
703 const href = sourceHref(
704 ownerUsername,
705 repoName,
706 x.row.targetType,
707 x.row.targetId
708 );
709 const kindClass = `kind-${x.info.kind}`;
710 return (
711 <li>
712 <a
713 href={href}
714 class={"act-row " + (x.info.isAi ? "is-ai" : "")}
715 >
716 <span
717 class={"act-row-icon " + kindClass}
718 aria-hidden="true"
719 >
720 {iconLabel(x.info.kind)}
721 </span>
722 <div class="act-row-main">
723 <h3 class="act-row-title">
724 <span class={"act-verb " + kindClass}>
725 {x.info.verb || "event"}
726 </span>
727 {x.info.isAi && (
728 <span
729 class="act-badge ai-event"
730 title="Claude did this for you"
731 >
732 <span class="dot" aria-hidden="true" /> AI-EVENT
733 </span>
734 )}
735 </h3>
736 <div class="act-row-meta">
737 <span class="act-row-repo">
738 {ownerUsername}/{repoName}
739 </span>
740 <span class="sep">·</span>
741 <span class="act-row-time">
742 {relTime(x.row.createdAt)}
743 </span>
744 </div>
745 </div>
746 </a>
747 </li>
748 );
749 })}
750 </ul>
751 {nextHref && (
752 <div class="act-pager">
753 <a href={nextHref} class="btn">
754 Older
755 </a>
756 </div>
757 )}
758 </>
759 )}
760 </div>
761 <style dangerouslySetInnerHTML={{ __html: styles }} />
762 </Layout>
763 );
764});
765
766export default activity;
Addedsrc/routes/inbox.tsx+1071−0View fileUnifiedSplit
Large file (1,071 lines). Load full file
Addedsrc/routes/issues-dashboard.tsx+777−0View fileUnifiedSplit
1/**
2 * `/issues` — Global issues dashboard.
3 *
4 * Mirrors `/pulls`: hero with gradient hairline + orb, four tabular-nums
5 * stat counters, pill tab strip, and a list of issues. The differentiator
6 * over a GitHub-style "issues I authored" page is Gluecron-native AI
7 * signal on every row:
8 *
9 * - AI-TRIAGED — any label starting with `ai:` (autopilot has
10 * classified it)
11 * - SUGGESTED-FIX — an `issue_comments` row carries the
12 * ISSUE_TRIAGE_MARKER (Claude has posted a
13 * triage / suggested patch comment)
14 * - BUILD-IN-PROGRESS — labelled `ai:build` or `ai:in-progress`
15 * (autopilot is actively working on it)
16 * - COMMENT-COUNT — tabular-nums per row
17 *
18 * Filters:
19 * - mine — issues you opened
20 * - assigned — issues in repos you own / collaborate on
21 * - ai-working — autopilot is currently processing this one
22 * - needs-triage — open issues with NO ai:* label yet
23 *
24 * Scoped CSS under `.idash-*`. Does not touch shared layout / components.
25 */
26
27import { Hono } from "hono";
28import {
29 and,
30 desc,
31 eq,
32 inArray,
33 isNotNull,
34 like,
35 sql,
36} from "drizzle-orm";
37import { db } from "../db";
38import {
39 issues,
40 issueComments,
41 issueLabels,
42 labels,
43 repositories,
44 repoCollaborators,
45 users,
46} from "../db/schema";
47import { Layout } from "../views/layout";
48import { softAuth, requireAuth } from "../middleware/auth";
49import type { AuthEnv } from "../middleware/auth";
50import { ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
51
52const issuesDashboard = new Hono<AuthEnv>();
53issuesDashboard.use("*", softAuth);
54
55const styles = `
56 .idash-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
57
58 .idash-hero {
59 position: relative;
60 margin-bottom: var(--space-5);
61 padding: var(--space-5) var(--space-6);
62 background: var(--bg-elevated);
63 border: 1px solid var(--border);
64 border-radius: 16px;
65 overflow: hidden;
66 }
67 .idash-hero::before {
68 content: '';
69 position: absolute;
70 top: 0; left: 0; right: 0;
71 height: 2px;
72 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
73 opacity: 0.7;
74 pointer-events: none;
75 }
76 .idash-orb {
77 position: absolute;
78 inset: -30% -10% auto auto;
79 width: 380px; height: 380px;
80 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
81 filter: blur(80px);
82 opacity: 0.7;
83 pointer-events: none;
84 }
85 .idash-hero-inner { position: relative; z-index: 1; max-width: 760px; }
86 .idash-eyebrow {
87 font-size: 12.5px;
88 color: var(--text-muted);
89 margin-bottom: 8px;
90 letter-spacing: 0.04em;
91 text-transform: uppercase;
92 font-weight: 600;
93 }
94 .idash-title {
95 font-family: var(--font-display);
96 font-size: clamp(28px, 4vw, 40px);
97 font-weight: 800;
98 letter-spacing: -0.028em;
99 line-height: 1.05;
100 margin: 0 0 10px;
101 color: var(--text-strong);
102 }
103 .idash-title-grad {
104 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
105 -webkit-background-clip: text;
106 background-clip: text;
107 -webkit-text-fill-color: transparent;
108 color: transparent;
109 }
110 .idash-sub {
111 font-size: 15px;
112 color: var(--text-muted);
113 margin: 0;
114 line-height: 1.5;
115 }
116
117 .idash-stats {
118 display: flex;
119 gap: 24px;
120 margin-top: 16px;
121 flex-wrap: wrap;
122 }
123 .idash-stat {
124 display: flex;
125 flex-direction: column;
126 gap: 2px;
127 }
128 .idash-stat-n {
129 font-family: var(--font-display);
130 font-size: 22px;
131 font-weight: 700;
132 color: var(--text-strong);
133 font-variant-numeric: tabular-nums;
134 }
135 .idash-stat-l {
136 font-size: 11.5px;
137 color: var(--text-muted);
138 letter-spacing: 0.02em;
139 text-transform: uppercase;
140 }
141
142 .idash-tabs {
143 display: inline-flex;
144 background: var(--bg-elevated);
145 border: 1px solid var(--border);
146 border-radius: 9999px;
147 padding: 4px;
148 gap: 2px;
149 margin-bottom: var(--space-4);
150 flex-wrap: wrap;
151 }
152 .idash-tab {
153 display: inline-flex;
154 align-items: center;
155 gap: 6px;
156 padding: 7px 14px;
157 border-radius: 9999px;
158 font-size: 13.5px;
159 font-weight: 500;
160 color: var(--text-muted);
161 text-decoration: none;
162 transition: color 120ms ease, background 120ms ease;
163 }
164 .idash-tab:hover { color: var(--text-strong); text-decoration: none; }
165 .idash-tab.is-active {
166 background: rgba(140,109,255,0.14);
167 color: var(--text-strong);
168 }
169 .idash-tab-count {
170 font-variant-numeric: tabular-nums;
171 font-size: 11.5px;
172 background: rgba(255,255,255,0.04);
173 padding: 1px 7px;
174 border-radius: 9999px;
175 }
176 .idash-tab.is-active .idash-tab-count {
177 background: rgba(140,109,255,0.22);
178 color: var(--text);
179 }
180
181 .idash-list {
182 list-style: none;
183 margin: 0;
184 padding: 0;
185 border: 1px solid var(--border);
186 border-radius: 12px;
187 overflow: hidden;
188 background: var(--bg-elevated);
189 }
190 .idash-row {
191 display: flex;
192 align-items: flex-start;
193 gap: 14px;
194 padding: 14px 18px;
195 border-bottom: 1px solid var(--border);
196 transition: background 120ms ease;
197 }
198 .idash-row:last-child { border-bottom: none; }
199 .idash-row:hover { background: rgba(140,109,255,0.04); }
200 .idash-row-icon {
201 width: 18px; height: 18px;
202 flex-shrink: 0;
203 margin-top: 3px;
204 display: inline-flex;
205 align-items: center;
206 justify-content: center;
207 }
208 .idash-row-icon.is-open { color: #34d399; }
209 .idash-row-icon.is-closed { color: #b69dff; }
210 .idash-row-main { flex: 1; min-width: 0; }
211 .idash-row-title {
212 font-family: var(--font-display);
213 font-size: 15.5px;
214 font-weight: 600;
215 line-height: 1.35;
216 letter-spacing: -0.012em;
217 margin: 0 0 6px;
218 display: flex;
219 flex-wrap: wrap;
220 align-items: center;
221 gap: 8px;
222 }
223 .idash-row-title a {
224 color: var(--text-strong);
225 text-decoration: none;
226 }
227 .idash-row-title a:hover { color: var(--accent); }
228 .idash-row-repo {
229 font-family: var(--font-mono);
230 font-size: 12px;
231 color: var(--text-muted);
232 }
233 .idash-row-meta {
234 font-size: 12.5px;
235 color: var(--text-muted);
236 font-variant-numeric: tabular-nums;
237 display: flex;
238 gap: 10px;
239 flex-wrap: wrap;
240 align-items: center;
241 }
242 .idash-row-meta .sep { opacity: 0.45; }
243 .idash-row-comments {
244 display: inline-flex;
245 align-items: center;
246 gap: 4px;
247 font-variant-numeric: tabular-nums;
248 }
249
250 /* Gluecron-native badges: AI triage, suggested fix, in-progress */
251 .idash-badges {
252 display: inline-flex;
253 align-items: center;
254 gap: 6px;
255 flex-wrap: wrap;
256 margin-left: auto;
257 }
258 .idash-badge {
259 display: inline-flex;
260 align-items: center;
261 gap: 4px;
262 padding: 2px 8px;
263 font-size: 11px;
264 font-weight: 600;
265 border-radius: 9999px;
266 letter-spacing: 0.02em;
267 font-variant-numeric: tabular-nums;
268 }
269 .idash-badge .dot {
270 width: 6px; height: 6px;
271 border-radius: 9999px;
272 background: currentColor;
273 }
274 .idash-badge.ai-triaged { color: #93c5fd; background: rgba(96,165,250,0.12); box-shadow: inset 0 0 0 1px rgba(96,165,250,0.32); }
275 .idash-badge.ai-fix { color: #6ee7b7; background: rgba(52,211,153,0.13); box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
276 .idash-badge.ai-progress { color: #b69dff; background: rgba(140,109,255,0.18); box-shadow: inset 0 0 0 1px rgba(140,109,255,0.36); }
277 .idash-badge.ai-progress .dot { animation: idashPulse 1.6s ease-in-out infinite; }
278
279 @keyframes idashPulse {
280 0%, 100% { opacity: 1; transform: scale(1); }
281 50% { opacity: 0.4; transform: scale(0.7); }
282 }
283
284 .idash-state-pill {
285 display: inline-flex;
286 align-items: center;
287 gap: 5px;
288 padding: 2px 9px;
289 font-size: 11px;
290 font-weight: 600;
291 border-radius: 9999px;
292 letter-spacing: 0.02em;
293 text-transform: uppercase;
294 }
295 .idash-state-pill.is-open { background: rgba(52,211,153,0.13); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
296 .idash-state-pill.is-closed { background: rgba(140,109,255,0.16); color: #b69dff; box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32); }
297
298 .idash-empty {
299 padding: 60px 20px;
300 text-align: center;
301 border: 1px dashed var(--border-strong);
302 border-radius: 14px;
303 background: var(--bg-elevated);
304 position: relative;
305 overflow: hidden;
306 }
307 .idash-empty::before {
308 content: '';
309 position: absolute;
310 inset: -20% -10% auto auto;
311 width: 320px; height: 320px;
312 background: radial-gradient(circle, rgba(140,109,255,0.10), transparent 70%);
313 filter: blur(60px);
314 pointer-events: none;
315 }
316 .idash-empty-title {
317 font-family: var(--font-display);
318 font-size: 22px;
319 font-weight: 700;
320 color: var(--text-strong);
321 margin: 0 0 6px;
322 position: relative;
323 }
324 .idash-empty-sub {
325 color: var(--text-muted);
326 font-size: 14px;
327 margin: 0 0 18px;
328 position: relative;
329 }
330 .idash-empty .btn { position: relative; }
331
332 @media (max-width: 720px) {
333 .idash-hero { padding: 24px 20px; }
334 .idash-row { padding: 12px 14px; flex-direction: column; }
335 .idash-badges { margin-left: 0; }
336 }
337`;
338
339type IssueFilter = "mine" | "assigned" | "ai-working" | "needs-triage";
340
341const VALID_FILTERS: IssueFilter[] = [
342 "mine",
343 "assigned",
344 "ai-working",
345 "needs-triage",
346];
347
348// Label-name prefixes that indicate autopilot has touched this issue.
349// `ai:build` and `ai:in-progress` specifically mean "currently working".
350const AI_LABEL_PREFIX = "ai:";
351const AI_WORKING_LABELS = ["ai:build", "ai:in-progress"];
352
353function relTime(d: Date): string {
354 const diff = Date.now() - d.getTime();
355 const s = Math.floor(diff / 1000);
356 if (s < 60) return `${s}s ago`;
357 const m = Math.floor(s / 60);
358 if (m < 60) return `${m}m ago`;
359 const h = Math.floor(m / 60);
360 if (h < 24) return `${h}h ago`;
361 const days = Math.floor(h / 24);
362 if (days < 30) return `${days}d ago`;
363 const months = Math.floor(days / 30);
364 if (months < 12) return `${months}mo ago`;
365 return `${Math.floor(months / 12)}y ago`;
366}
367
368function StateIcon({ state }: { state: string }) {
369 if (state === "closed") {
370 return (
371 <span class="idash-row-icon is-closed" aria-label="Closed">
372 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
373 <path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5z" />
374 <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0z" />
375 </svg>
376 </span>
377 );
378 }
379 return (
380 <span class="idash-row-icon is-open" aria-label="Open">
381 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
382 <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
383 <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0z" />
384 </svg>
385 </span>
386 );
387}
388
389function CommentIcon() {
390 return (
391 <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
392 <path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25z" />
393 </svg>
394 );
395}
396
397interface IssueRow {
398 issue: typeof issues.$inferSelect;
399 repo: typeof repositories.$inferSelect;
400 authorUsername: string;
401 ownerUsername: string;
402 aiTriaged: boolean;
403 suggestedFix: boolean;
404 buildInProgress: boolean;
405 commentCount: number;
406}
407
408/**
409 * Aggregate the Gluecron-native signal for one issue: AI-triaged?
410 * Suggested-fix comment? Build in progress? Comment count? All
411 * best-effort — any individual lookup failing returns the safest
412 * default (false / 0) rather than throwing.
413 */
414async function enrichIssue(issueId: string): Promise<{
415 aiTriaged: boolean;
416 suggestedFix: boolean;
417 buildInProgress: boolean;
418 commentCount: number;
419}> {
420 let aiTriaged = false;
421 let suggestedFix = false;
422 let buildInProgress = false;
423 let commentCount = 0;
424
425 try {
426 // One pass over the issue's labels — pulls names so we can check
427 // both the generic `ai:` prefix (triaged) and the specific
428 // "actively-working" labels in the same trip.
429 const labelRows = await db
430 .select({ name: labels.name })
431 .from(issueLabels)
432 .innerJoin(labels, eq(labels.id, issueLabels.labelId))
433 .where(eq(issueLabels.issueId, issueId));
434 for (const row of labelRows) {
435 const n = row.name.toLowerCase();
436 if (n.startsWith(AI_LABEL_PREFIX)) aiTriaged = true;
437 if (AI_WORKING_LABELS.includes(n)) buildInProgress = true;
438 }
439 } catch {
440 /* skip */
441 }
442
443 try {
444 // ISSUE_TRIAGE_MARKER lives in the rendered AI triage comment body.
445 // Presence == Claude has posted a suggested-fix / triage payload.
446 const [marker] = await db
447 .select({ id: issueComments.id })
448 .from(issueComments)
449 .where(
450 and(
451 eq(issueComments.issueId, issueId),
452 like(issueComments.body, `%${ISSUE_TRIAGE_MARKER}%`)
453 )
454 )
455 .limit(1);
456 if (marker) suggestedFix = true;
457 } catch {
458 /* skip */
459 }
460
461 try {
462 const countRows = await db
463 .select({ c: sql<number>`count(*)::int` })
464 .from(issueComments)
465 .where(eq(issueComments.issueId, issueId));
466 commentCount = Number(countRows[0]?.c ?? 0);
467 } catch {
468 /* skip */
469 }
470
471 return { aiTriaged, suggestedFix, buildInProgress, commentCount };
472}
473
474issuesDashboard.get("/issues", requireAuth, async (c) => {
475 const user = c.get("user")!;
476 const raw = c.req.query("filter") || "mine";
477 const filter: IssueFilter = (VALID_FILTERS as string[]).includes(raw)
478 ? (raw as IssueFilter)
479 : "mine";
480
481 // Resolve the candidate repo set up-front for the "assigned" /
482 // "ai-working" / "needs-triage" tabs. "mine" walks issues.authorId
483 // directly and skips this lookup.
484 let repoIds: string[] = [];
485 if (filter !== "mine") {
486 try {
487 const ownedRepos = await db
488 .select({ id: repositories.id })
489 .from(repositories)
490 .where(eq(repositories.ownerId, user.id));
491 const collabRepos = await db
492 .select({ id: repoCollaborators.repositoryId })
493 .from(repoCollaborators)
494 .where(
495 and(
496 eq(repoCollaborators.userId, user.id),
497 isNotNull(repoCollaborators.acceptedAt)
498 )
499 );
500 repoIds = [
501 ...ownedRepos.map((r) => r.id),
502 ...collabRepos.map((r) => r.id),
503 ];
504 } catch (err) {
505 console.error("[issues-dashboard] repo set lookup failed:", err);
506 }
507 }
508
509 // Broad candidate fetch; Gluecron-native predicates (ai-working,
510 // needs-triage) are applied post-enrichment so we don't push them
511 // into SQL.
512 let candidates: Array<{
513 issue: typeof issues.$inferSelect;
514 repo: typeof repositories.$inferSelect;
515 }> = [];
516 try {
517 if (filter === "mine") {
518 candidates = await db
519 .select({ issue: issues, repo: repositories })
520 .from(issues)
521 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
522 .where(eq(issues.authorId, user.id))
523 .orderBy(desc(issues.updatedAt))
524 .limit(100);
525 } else if (repoIds.length > 0) {
526 candidates = await db
527 .select({ issue: issues, repo: repositories })
528 .from(issues)
529 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
530 .where(
531 and(
532 inArray(issues.repositoryId, repoIds),
533 eq(issues.state, "open")
534 )
535 )
536 .orderBy(desc(issues.updatedAt))
537 .limit(200);
538 }
539 } catch (err) {
540 console.error("[issues-dashboard] candidate query failed:", err);
541 }
542
543 // Resolve author + owner usernames in one batched lookup.
544 const userIds = new Set<string>();
545 for (const cand of candidates) {
546 userIds.add(cand.issue.authorId);
547 userIds.add(cand.repo.ownerId);
548 }
549 const usersById = new Map<string, string>();
550 if (userIds.size > 0) {
551 try {
552 const rows = await db
553 .select({ id: users.id, username: users.username })
554 .from(users)
555 .where(inArray(users.id, Array.from(userIds)));
556 for (const r of rows) usersById.set(r.id, r.username);
557 } catch (err) {
558 console.error("[issues-dashboard] username lookup failed:", err);
559 }
560 }
561
562 // Enrich + filter to the active tab. The two AI-shaped filters
563 // (ai-working, needs-triage) only make sense after we know the
564 // per-issue label set, so the SQL stays generic.
565 const rows: IssueRow[] = [];
566 for (const cand of candidates) {
567 const enriched = await enrichIssue(cand.issue.id);
568 const matches =
569 filter === "mine" ||
570 filter === "assigned" ||
571 (filter === "ai-working" && enriched.buildInProgress) ||
572 (filter === "needs-triage" &&
573 cand.issue.state === "open" &&
574 !enriched.aiTriaged);
575 if (!matches) continue;
576 rows.push({
577 issue: cand.issue,
578 repo: cand.repo,
579 authorUsername: usersById.get(cand.issue.authorId) || "unknown",
580 ownerUsername: usersById.get(cand.repo.ownerId) || "unknown",
581 ...enriched,
582 });
583 }
584
585 // Hero counters — best-effort. SQL-side for the cheap ones; derived
586 // from `rows` for the AI-shaped ones (which depend on enrichment
587 // and are scoped to the candidates we already pulled).
588 const counts = { mine: 0, assigned: 0, aiWorking: 0, needsTriage: 0 };
589 try {
590 const mineRows = await db
591 .select({ id: issues.id })
592 .from(issues)
593 .where(eq(issues.authorId, user.id));
594 counts.mine = mineRows.length;
595 if (repoIds.length > 0) {
596 const assigned = await db
597 .select({ id: issues.id })
598 .from(issues)
599 .where(
600 and(
601 inArray(issues.repositoryId, repoIds),
602 eq(issues.state, "open")
603 )
604 );
605 counts.assigned = assigned.length;
606 }
607 } catch {
608 /* keep 0s */
609 }
610
611 if (filter === "mine" || filter === "assigned") {
612 // For the AI-shaped counts we need enrichment. When the active
613 // tab IS one of those, `rows` is filtered down already — so do a
614 // cheap secondary pass over candidates for the headline.
615 let working = 0;
616 let triage = 0;
617 for (const cand of candidates) {
618 const enriched = await enrichIssue(cand.issue.id);
619 if (enriched.buildInProgress) working++;
620 if (cand.issue.state === "open" && !enriched.aiTriaged) triage++;
621 }
622 counts.aiWorking = working;
623 counts.needsTriage = triage;
624 } else {
625 counts.aiWorking = rows.filter((r) => r.buildInProgress).length;
626 counts.needsTriage = rows.filter(
627 (r) => r.issue.state === "open" && !r.aiTriaged
628 ).length;
629 }
630
631 return c.html(
632 <Layout title="Issues · Gluecron" user={user}>
633 <div class="idash-wrap">
634 <section class="idash-hero">
635 <div class="idash-orb" aria-hidden="true" />
636 <div class="idash-hero-inner">
637 <div class="idash-eyebrow">
638 Issue command center · live ·{" "}
639 <span style="color:var(--accent);font-weight:600">{user.username}</span>
640 </div>
641 <h1 class="idash-title">
642 <span class="idash-title-grad">Your issues.</span>
643 </h1>
644 <p class="idash-sub">
645 Every issue across every repo you touch — annotated with
646 the things only Gluecron knows. AI triage status, suggested
647 fixes, and whether autopilot is currently building one,
648 all live.
649 </p>
650 <div class="idash-stats">
651 <div class="idash-stat">
652 <div class="idash-stat-n">{counts.mine}</div>
653 <div class="idash-stat-l">Authored</div>
654 </div>
655 <div class="idash-stat">
656 <div class="idash-stat-n">{counts.assigned}</div>
657 <div class="idash-stat-l">In your repos</div>
658 </div>
659 <div class="idash-stat">
660 <div class="idash-stat-n">{counts.aiWorking}</div>
661 <div class="idash-stat-l">AI working</div>
662 </div>
663 <div class="idash-stat">
664 <div class="idash-stat-n">{counts.needsTriage}</div>
665 <div class="idash-stat-l">Needs triage</div>
666 </div>
667 </div>
668 </div>
669 </section>
670
671 <nav class="idash-tabs" aria-label="Issue filters">
672 <a href="/issues?filter=mine" class={"idash-tab " + (filter === "mine" ? "is-active" : "")}>
673 Mine <span class="idash-tab-count">{counts.mine}</span>
674 </a>
675 <a href="/issues?filter=assigned" class={"idash-tab " + (filter === "assigned" ? "is-active" : "")}>
676 Assigned <span class="idash-tab-count">{counts.assigned}</span>
677 </a>
678 <a href="/issues?filter=ai-working" class={"idash-tab " + (filter === "ai-working" ? "is-active" : "")}>
679 AI working <span class="idash-tab-count">{counts.aiWorking}</span>
680 </a>
681 <a href="/issues?filter=needs-triage" class={"idash-tab " + (filter === "needs-triage" ? "is-active" : "")}>
682 Needs triage <span class="idash-tab-count">{counts.needsTriage}</span>
683 </a>
684 </nav>
685
686 {rows.length === 0 ? (
687 <div class="idash-empty">
688 <h2 class="idash-empty-title">
689 {filter === "mine"
690 ? "You haven't opened any issues yet."
691 : filter === "assigned"
692 ? "No open issues in your repos."
693 : filter === "ai-working"
694 ? "Autopilot isn't building anything right now."
695 : "Triage queue is clear."}
696 </h2>
697 <p class="idash-empty-sub">
698 {filter === "mine"
699 ? "File one and it'll show up here with its full Gluecron signal — AI triage, suggested fix, build progress."
700 : filter === "assigned"
701 ? "When someone files an issue in a repo you own or collaborate on, it'll appear here already annotated by Claude."
702 : filter === "ai-working"
703 ? "Label any issue `ai:build` and Claude will pick it up on the next autopilot sweep — it'll show up here while the build runs."
704 : "Every open issue has an AI label. Gluecron's autopilot has classified the entire backlog."}
705 </p>
706 {filter !== "mine" && (
707 <a href="/issues?filter=mine" class="btn">View your authored</a>
708 )}
709 {filter === "mine" && (
710 <a href="/explore" class="btn btn-primary">Browse repos</a>
711 )}
712 </div>
713 ) : (
714 <ul class="idash-list">
715 {rows.map((r) => {
716 const stateClass = r.issue.state === "closed" ? "is-closed" : "is-open";
717 return (
718 <li class="idash-row">
719 <StateIcon state={r.issue.state} />
720 <div class="idash-row-main">
721 <h3 class="idash-row-title">
722 <a href={`/${r.ownerUsername}/${r.repo.name}/issues/${r.issue.number}`}>
723 {r.issue.title}
724 </a>
725 <span class={"idash-state-pill " + stateClass}>
726 {r.issue.state}
727 </span>
728 <span class="idash-badges">
729 {r.buildInProgress && (
730 <span class="idash-badge ai-progress" title="Autopilot is building a fix for this issue">
731 <span class="dot" aria-hidden="true" /> Build in progress
732 </span>
733 )}
734 {r.suggestedFix && (
735 <span class="idash-badge ai-fix" title="Claude has posted a suggested fix">
736 <span class="dot" aria-hidden="true" /> Suggested fix
737 </span>
738 )}
739 {r.aiTriaged && (
740 <span class="idash-badge ai-triaged" title="Autopilot has classified this issue">
741 <span class="dot" aria-hidden="true" /> AI-triaged
742 </span>
743 )}
744 </span>
745 </h3>
746 <div class="idash-row-meta">
747 <span class="idash-row-repo">
748 {r.ownerUsername}/{r.repo.name} #{r.issue.number}
749 </span>
750 <span class="sep">·</span>
751 <span>
752 by <strong style="color:var(--text)">{r.authorUsername}</strong>
753 </span>
754 <span class="sep">·</span>
755 <span>updated {relTime(r.issue.updatedAt)}</span>
756 {r.commentCount > 0 && (
757 <>
758 <span class="sep">·</span>
759 <span class="idash-row-comments" title={`${r.commentCount} comment${r.commentCount === 1 ? "" : "s"}`}>
760 <CommentIcon /> {r.commentCount}
761 </span>
762 </>
763 )}
764 </div>
765 </div>
766 </li>
767 );
768 })}
769 </ul>
770 )}
771 </div>
772 <style dangerouslySetInnerHTML={{ __html: styles }} />
773 </Layout>
774 );
775});
776
777export default issuesDashboard;
Modifiedsrc/views/layout.tsx+17−0View fileUnifiedSplit
216216 <a href="/pulls" class="nav-link">
217217 Pulls
218218 </a>
219 <a href="/issues" class="nav-link">
220 Issues
221 </a>
222 <a href="/activity" class="nav-link">
223 Activity
224 </a>
225 <a href="/inbox" class="nav-link" style="position:relative">
226 Inbox
227 {notificationCount && notificationCount > 0 ? (
228 <span
229 aria-label={`${notificationCount} unread`}
230 style="display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin-left:6px;font-size:10.5px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1;color:#fff;background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);border-radius:9999px;box-shadow:0 0 8px rgba(140,109,255,0.45)"
231 >
232 {notificationCount > 99 ? "99+" : notificationCount}
233 </span>
234 ) : null}
235 </a>
219236 <a href="/import" class="nav-link">
220237 Import
221238 </a>
222239