Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 60 additions & 15 deletions cloudflare-gastown/src/dos/town/review-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,18 @@ const MrBeadRow = z.object({
agent_role: z.string().nullable(),
// rig name
rig_name: z.string().nullable(),
// failure event metadata (correlated subquery for failed MR beads)
failure_event_metadata: z
.string()
.nullable()
.transform((v): Record<string, unknown> | null => {
if (!v) return null;
try {
return JSON.parse(v) as Record<string, unknown>;
} catch {
return null;
}
}),
});

/** Zod schema for an enriched activity log event row. */
Expand Down Expand Up @@ -830,6 +842,10 @@ const ActivityLogRow = z.object({
agent_role: z.string().nullable(),
// rig info
rig_name: z.string().nullable(),
// source bead (resolved via bead_dependencies tracks join)
source_bead_id: z.string().nullable(),
source_bead_title: z.string().nullable(),
source_bead_status: z.string().nullable(),
// review metadata
rm_branch: z.string().nullable(),
rm_target_branch: z.string().nullable(),
Expand Down Expand Up @@ -989,7 +1005,13 @@ export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): Me
am.${agent_metadata.columns.bead_id} AS agent_id,
agent_bead.${beads.columns.title} AS agent_name,
am.${agent_metadata.columns.role} AS agent_role,
rig.name AS rig_name
rig.name AS rig_name,
(SELECT ${bead_events.metadata}
FROM ${bead_events}
WHERE ${bead_events.bead_id} = ${beads.bead_id}
AND ${bead_events.event_type} IN ('review_completed', 'pr_creation_failed')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: This can hide the actual failure reason for PR creation failures

pr_creation_failed events currently log { reason: 'invalid_url' } without a message, while completeReviewWithResult() logs the human-readable failure text on the preceding review_completed event. Because this subquery picks the newest of either event type, invalid-URL failures end up with failure_event_metadata.message === undefined, so the new UI renders no reason for that class of failure.

ORDER BY ${bead_events.created_at} DESC
LIMIT 1) AS failure_event_metadata
FROM ${beads}
INNER JOIN ${review_metadata}
ON ${beads.bead_id} = ${review_metadata.bead_id}
Expand Down Expand Up @@ -1066,6 +1088,9 @@ export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): Me
agent_bead.${beads.columns.title} AS agent_name,
am.${agent_metadata.columns.role} AS agent_role,
rig.name AS rig_name,
src.${beads.columns.bead_id} AS source_bead_id,
src.${beads.columns.title} AS source_bead_title,
src.${beads.columns.status} AS source_bead_status,
rm.${review_metadata.columns.branch} AS rm_branch,
rm.${review_metadata.columns.target_branch} AS rm_target_branch,
rm.${review_metadata.columns.merge_commit} AS rm_merge_commit,
Expand All @@ -1083,6 +1108,11 @@ export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): Me
ON am.${agent_metadata.columns.bead_id} = ${bead_events.agent_id}
LEFT JOIN ${beads} AS agent_bead
ON agent_bead.${beads.columns.bead_id} = ${bead_events.agent_id}
LEFT JOIN ${bead_dependencies} AS dep
ON dep.${bead_dependencies.columns.bead_id} = b.${beads.columns.bead_id}
AND dep.${bead_dependencies.columns.dependency_type} = 'tracks'
LEFT JOIN ${beads} AS src
ON src.${beads.columns.bead_id} = dep.${bead_dependencies.columns.depends_on_bead_id}
LEFT JOIN ${review_metadata} AS rm
ON rm.${review_metadata.columns.bead_id} = ${bead_events.bead_id}
LEFT JOIN ${convoy_metadata} AS cm
Expand Down Expand Up @@ -1160,14 +1190,38 @@ function mrBeadRowToItem(row: z.output<typeof MrBeadRow>): MergeQueueItem {
: null,
rigName: row.rig_name,
staleSince: null,
failureReason: null,
failureReason:
row.status === 'failed' && row.failure_event_metadata
? typeof row.failure_event_metadata.message === 'string'
? row.failure_event_metadata.message
: null
: null,
};
}

function eventRowToEntry(row: z.output<typeof ActivityLogRow>): ActivityLogEntry {
// Try to find the source bead from the event's bead metadata
const sourceBeadId =
typeof row.bead_metadata?.source_bead_id === 'string' ? row.bead_metadata.source_bead_id : null;
// Source bead resolution:
// - Events on MR beads (pr_created, pr_creation_failed, rework_requested):
// resolved via bead_dependencies LEFT JOIN (source_bead_id/title/status columns)
// - Events on source beads (review_submitted, review_completed):
// the event's bead IS the source bead — use the bead columns directly
const isMrBeadEvent = row.bead_type === 'merge_request';

const resolvedSourceBead = isMrBeadEvent
? row.source_bead_id
? {
bead_id: row.source_bead_id,
title: row.source_bead_title ?? '',
status: row.source_bead_status ?? '',
}
: null
: row.bead_title
? {
bead_id: row.bead_id,
title: row.bead_title,
status: row.bead_status ?? '',
}
: null;

return {
event: {
Expand All @@ -1190,16 +1244,7 @@ function eventRowToEntry(row: z.output<typeof ActivityLogRow>): ActivityLogEntry
metadata: row.bead_metadata,
}
: null,
sourceBead: sourceBeadId
? {
bead_id: sourceBeadId,
title:
typeof row.bead_metadata?.source_bead_title === 'string'
? row.bead_metadata.source_bead_title
: '',
status: '',
}
: null,
sourceBead: resolvedSourceBead,
convoy: row.convoy_id
? {
convoy_id: row.convoy_id,
Expand Down
7 changes: 2 additions & 5 deletions src/app/(app)/gastown/[townId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TerminalBarProvider } from '@/components/gastown/TerminalBarContext';
import { DrawerStackProvider } from '@/components/gastown/DrawerStack';
import { renderDrawerContent } from '@/components/gastown/DrawerStackContent';
import { TerminalBarPadding } from '@/components/gastown/TerminalBarPadding';
import { MayorTerminalBar } from './MayorTerminalBar';

export default function TownLayout({
Expand All @@ -13,11 +14,7 @@ export default function TownLayout({
return (
<TerminalBarProvider>
<DrawerStackProvider renderContent={renderDrawerContent}>
{/* Fullscreen edge-to-edge layout for gastown town pages.
Bottom padding clears the fixed terminal bar. */}
<div className="flex min-h-screen flex-col pb-[340px]">
<div className="flex-1">{children}</div>
</div>
<TerminalBarPadding>{children}</TerminalBarPadding>
<MayorTerminalBar params={params} />
</DrawerStackProvider>
</TerminalBarProvider>
Expand Down
92 changes: 46 additions & 46 deletions src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,72 +2,72 @@

import { useQuery } from '@tanstack/react-query';
import { useGastownTRPC } from '@/lib/gastown/trpc';
import { GitMerge, CheckCircle } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { GitMerge, AlertCircle, Loader2 } from 'lucide-react';
import { NeedsAttention } from './NeedsAttention';

export function MergesPageClient({ townId }: { townId: string }) {
const trpc = useGastownTRPC();

const eventsQuery = useQuery({
...trpc.gastown.getTownEvents.queryOptions({ townId, limit: 200 }),
const mergeQueueQuery = useQuery({
...trpc.gastown.getMergeQueueData.queryOptions({ townId }),
refetchInterval: 5_000,
});

const mergeEvents = (eventsQuery.data ?? []).filter(
e => e.event_type === 'review_submitted' || e.event_type === 'review_completed'
);
const needsAttention = mergeQueueQuery.data?.needsAttention;
const totalAttention = needsAttention
? needsAttention.openPRs.length +
needsAttention.failedReviews.length +
needsAttention.stalePRs.length
: 0;

return (
<div className="flex h-full flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-3">
<div className="flex items-center gap-2">
<GitMerge className="size-4 text-[color:oklch(95%_0.15_108_/_0.6)]" />
<h1 className="text-lg font-semibold tracking-tight text-white/90">Merge Queue</h1>
<span className="ml-1 font-mono text-xs text-white/30">{mergeEvents.length}</span>
{totalAttention > 0 && (
<span className="ml-1 rounded-full bg-amber-500/15 px-2 py-0.5 font-mono text-[10px] font-medium text-amber-400">
{totalAttention}
</span>
)}
</div>
</div>

{/* Content */}
<div className="flex-1 overflow-y-auto">
{mergeEvents.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<GitMerge className="mb-3 size-8 text-white/10" />
<p className="text-sm text-white/30">No merge activity yet.</p>
<p className="mt-1 text-xs text-white/20">
Review submissions and merge completions will appear here.
</p>
</div>
)}
<div className="mx-auto max-w-4xl space-y-6 p-6">
{/* Loading state */}
{mergeQueueQuery.isLoading && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="mb-3 size-6 animate-spin text-white/20" />
<p className="text-sm text-white/30">Loading merge queue…</p>
</div>
)}

{/* Error state */}
{mergeQueueQuery.isError && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertCircle className="mb-3 size-6 text-red-400/40" />
<p className="text-sm text-red-400/60">Failed to load merge queue data.</p>
<p className="mt-1 text-xs text-white/20">{mergeQueueQuery.error.message}</p>
</div>
)}

{mergeEvents
.slice()
.reverse()
.map(event => {
const isCompleted = event.event_type === 'review_completed';
return (
<div
key={event.bead_event_id}
className="flex items-start gap-3 border-b border-white/[0.04] px-6 py-3 transition-colors hover:bg-white/[0.02]"
>
{isCompleted ? (
<CheckCircle className="mt-0.5 size-3.5 shrink-0 text-emerald-400/60" />
) : (
<GitMerge className="mt-0.5 size-3.5 shrink-0 text-indigo-400/60" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm text-white/75">
{isCompleted ? 'Review completed' : 'Submitted for review'}
{event.new_value ? `: ${event.new_value}` : ''}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-white/30">
{event.rig_name && <span>{event.rig_name}</span>}
<span>
{formatDistanceToNow(new Date(event.created_at), { addSuffix: true })}
</span>
</div>
</div>
{/* Needs Your Attention section */}
{needsAttention && (
<section>
<div className="mb-3 flex items-center gap-2">
<AlertCircle className="size-3.5 text-white/30" />
<span className="text-[11px] font-medium uppercase tracking-wide text-white/40">
Needs Your Attention
</span>
</div>
);
})}
<NeedsAttention data={needsAttention} townId={townId} />
</section>
)}
</div>
</div>
</div>
);
Expand Down
Loading
Loading