diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 0cf7c974a7..0914525ca0 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -1722,6 +1722,14 @@ export class TownDO extends DurableObject { return reviewQueue.advanceMoleculeStep(this.sql, agentId, summary); } + async getMergeQueueData(params: { + rigId?: string; + limit?: number; + since?: string; + }): Promise { + return reviewQueue.getMergeQueueData(this.sql, params); + } + // ══════════════════════════════════════════════════════════════════ // Atomic Sling (create bead + agent + hook) // ══════════════════════════════════════════════════════════════════ diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index 5b746cb6c4..6455618030 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -12,6 +12,7 @@ import { review_metadata } from '../../db/tables/review-metadata.table'; import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; import { agent_metadata } from '../../db/tables/agent-metadata.table'; import { convoy_metadata } from '../../db/tables/convoy-metadata.table'; +import { bead_events } from '../../db/tables/bead-events.table'; import { query } from '../../util/query.util'; import { logBeadEvent, @@ -744,6 +745,536 @@ export function agentCompleted( return result; } +// ── Merge Queue Data ──────────────────────────────────────────────── + +/** + * 24 hours in milliseconds — MR beads in_review longer than this are "stale". + */ +const STALE_PR_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +/** Zod schema for a single enriched MR bead row from the needsAttention query. */ +const MrBeadRow = z.object({ + bead_id: z.string(), + status: z.string(), + title: z.string(), + body: z.string().nullable(), + rig_id: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + metadata: z.string().transform((v): Record => { + try { + return JSON.parse(v) as Record; + } catch { + return {}; + } + }), + // review_metadata columns + branch: z.string(), + target_branch: z.string(), + merge_commit: z.string().nullable(), + pr_url: z.string().nullable(), + retry_count: z.number(), + // source bead (via bead_dependencies tracks) + source_bead_id: z.string().nullable(), + source_bead_title: z.string().nullable(), + source_bead_status: z.string().nullable(), + source_bead_body: z.string().nullable(), + // convoy info (via metadata.convoy_id → convoy_metadata) + convoy_id: z.string().nullable(), + convoy_title: z.string().nullable(), + convoy_total_beads: z.number().nullable(), + convoy_closed_beads: z.number().nullable(), + convoy_feature_branch: z.string().nullable(), + convoy_merge_mode: z.string().nullable(), + // agent info (via metadata.source_agent_id → agent_metadata) + agent_id: z.string().nullable(), + agent_name: z.string().nullable(), + 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 | null => { + if (!v) return null; + try { + return JSON.parse(v) as Record; + } catch { + return null; + } + }), +}); + +/** Zod schema for an enriched activity log event row. */ +const ActivityLogRow = z.object({ + bead_event_id: z.string(), + bead_id: z.string(), + agent_id: z.string().nullable(), + event_type: z.string(), + old_value: z.string().nullable(), + new_value: z.string().nullable(), + event_metadata: z.string().transform((v): Record => { + try { + return JSON.parse(v) as Record; + } catch { + return {}; + } + }), + event_created_at: z.string(), + // associated bead info + bead_title: z.string().nullable(), + bead_type: z.string().nullable(), + bead_status: z.string().nullable(), + bead_rig_id: z.string().nullable(), + bead_metadata: z + .string() + .nullable() + .transform((v): Record => { + try { + return v ? (JSON.parse(v) as Record) : {}; + } catch { + return {}; + } + }), + // agent info + agent_name: z.string().nullable(), + 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(), + rm_merge_commit: z.string().nullable(), + rm_pr_url: z.string().nullable(), + // convoy info + convoy_id: z.string().nullable(), + convoy_title: z.string().nullable(), + convoy_total_beads: z.number().nullable(), + convoy_closed_beads: z.number().nullable(), + convoy_feature_branch: z.string().nullable(), + convoy_merge_mode: z.string().nullable(), +}); + +export type MergeQueueParams = { + rigId?: string; + limit?: number; + since?: string; +}; + +export type MergeQueueData = { + needsAttention: { + openPRs: MergeQueueItem[]; + failedReviews: MergeQueueItem[]; + stalePRs: MergeQueueItem[]; + }; + activityLog: ActivityLogEntry[]; +}; + +export type MergeQueueItem = { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; +}; + +export type ActivityLogEntry = { + event: { + bead_event_id: string; + bead_id: string; + agent_id: string | null; + event_type: string; + old_value: string | null; + new_value: string | null; + metadata: Record; + created_at: string; + }; + mrBead: { + bead_id: string; + title: string; + type: string; + status: string; + rig_id: string | null; + metadata: Record; + } | null; + sourceBead: { + bead_id: string; + title: string; + status: string; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + reviewMetadata: { + pr_url: string | null; + branch: string | null; + target_branch: string | null; + merge_commit: string | null; + } | null; +}; + +/** + * Query all data the Merge Queue page needs: MR beads needing attention + * (open PRs, failed reviews, stale PRs) and a recent activity log. + */ +export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): MergeQueueData { + const rigId = params.rigId ?? null; + + // ── 1. Query MR beads with full joins ─────────────────────────────── + // Statuses: in_progress = "in review" (PR created, awaiting merge), + // open = pending review, failed = review failed + // We fetch all non-closed MR beads for the needs-attention section. + const mrRows = [ + ...query( + sql, + /* sql */ ` + SELECT + ${beads.bead_id}, + ${beads.status}, + ${beads.title}, + ${beads.body}, + ${beads.rig_id}, + ${beads.created_at}, + ${beads.updated_at}, + ${beads.metadata}, + ${review_metadata.branch}, + ${review_metadata.target_branch}, + ${review_metadata.merge_commit}, + ${review_metadata.pr_url}, + ${review_metadata.retry_count}, + 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, + src.${beads.columns.body} AS source_bead_body, + cm.${convoy_metadata.columns.bead_id} AS convoy_id, + convoy_bead.${beads.columns.title} AS convoy_title, + cm.${convoy_metadata.columns.total_beads} AS convoy_total_beads, + cm.${convoy_metadata.columns.closed_beads} AS convoy_closed_beads, + cm.${convoy_metadata.columns.feature_branch} AS convoy_feature_branch, + cm.${convoy_metadata.columns.merge_mode} AS convoy_merge_mode, + 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, + (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') + 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} + LEFT JOIN ${bead_dependencies} AS dep + ON dep.${bead_dependencies.columns.bead_id} = ${beads.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 ${convoy_metadata} AS cm + ON cm.${convoy_metadata.columns.bead_id} = json_extract(${beads.metadata}, '$.convoy_id') + LEFT JOIN ${beads} AS convoy_bead + ON convoy_bead.${beads.columns.bead_id} = cm.${convoy_metadata.columns.bead_id} + LEFT JOIN ${agent_metadata} AS am + ON am.${agent_metadata.columns.bead_id} = json_extract(${beads.metadata}, '$.source_agent_id') + LEFT JOIN ${beads} AS agent_bead + ON agent_bead.${beads.columns.bead_id} = am.${agent_metadata.columns.bead_id} + LEFT JOIN rigs AS rig + ON rig.id = ${beads.rig_id} + WHERE ${beads.type} = 'merge_request' + AND ${beads.status} IN ('open', 'in_progress', 'in_review', 'failed') + AND (? IS NULL OR ${beads.rig_id} = ?) + ORDER BY ${beads.created_at} DESC + `, + [rigId, rigId] + ), + ]; + + const parsedMrRows = MrBeadRow.array().parse(mrRows); + const staleThreshold = new Date(Date.now() - STALE_PR_THRESHOLD_MS).toISOString(); + + const openPRs: MergeQueueItem[] = []; + const failedReviews: MergeQueueItem[] = []; + const stalePRs: MergeQueueItem[] = []; + + for (const row of parsedMrRows) { + const item = mrBeadRowToItem(row); + + if (row.status === 'failed') { + failedReviews.push(item); + } else if (row.pr_url && row.status === 'in_progress') { + // in_progress with pr_url = PR created, awaiting human merge + if (row.updated_at < staleThreshold) { + item.staleSince = row.updated_at; + stalePRs.push(item); + } else { + openPRs.push(item); + } + } + // open/in_review without pr_url are pending queue items, not shown in needs-attention + } + + // ── 2. Query activity log events ──────────────────────────────────── + const limit = params.limit ?? 50; + const since = params.since ?? null; + + const eventRows = [ + ...query( + sql, + /* sql */ ` + SELECT + ${bead_events.bead_event_id}, + ${bead_events.bead_id}, + ${bead_events.agent_id}, + ${bead_events.event_type}, + ${bead_events.old_value}, + ${bead_events.new_value}, + ${bead_events.metadata} AS event_metadata, + ${bead_events.created_at} AS event_created_at, + b.${beads.columns.title} AS bead_title, + b.${beads.columns.type} AS bead_type, + b.${beads.columns.status} AS bead_status, + b.${beads.columns.rig_id} AS bead_rig_id, + b.${beads.columns.metadata} AS bead_metadata, + 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, + rm.${review_metadata.columns.pr_url} AS rm_pr_url, + cm.${convoy_metadata.columns.bead_id} AS convoy_id, + convoy_bead.${beads.columns.title} AS convoy_title, + cm.${convoy_metadata.columns.total_beads} AS convoy_total_beads, + cm.${convoy_metadata.columns.closed_beads} AS convoy_closed_beads, + cm.${convoy_metadata.columns.feature_branch} AS convoy_feature_branch, + cm.${convoy_metadata.columns.merge_mode} AS convoy_merge_mode + FROM ${bead_events} + INNER JOIN ${beads} AS b + ON b.${beads.columns.bead_id} = ${bead_events.bead_id} + LEFT JOIN ${agent_metadata} AS am + 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 + ON cm.${convoy_metadata.columns.bead_id} = json_extract(b.${beads.columns.metadata}, '$.convoy_id') + LEFT JOIN ${beads} AS convoy_bead + ON convoy_bead.${beads.columns.bead_id} = cm.${convoy_metadata.columns.bead_id} + LEFT JOIN rigs AS rig + ON rig.id = b.${beads.columns.rig_id} + WHERE ${bead_events.event_type} IN ( + 'review_submitted', 'review_completed', 'pr_created', + 'pr_creation_failed', 'rework_requested', 'status_changed' + ) + AND (? IS NULL OR b.${beads.columns.rig_id} = ?) + AND (? IS NULL OR ${bead_events.created_at} > ?) + ORDER BY ${bead_events.created_at} DESC + LIMIT ? + `, + [rigId, rigId, since, since, limit] + ), + ]; + + const parsedEventRows = ActivityLogRow.array().parse(eventRows); + + const activityLog: ActivityLogEntry[] = parsedEventRows.map(eventRowToEntry); + + return { + needsAttention: { openPRs, failedReviews, stalePRs }, + activityLog, + }; +} + +function mrBeadRowToItem(row: z.output): MergeQueueItem { + return { + mrBead: { + bead_id: row.bead_id, + status: row.status, + title: row.title, + body: row.body, + rig_id: row.rig_id, + created_at: row.created_at, + updated_at: row.updated_at, + metadata: row.metadata, + }, + reviewMetadata: { + branch: row.branch, + target_branch: row.target_branch, + merge_commit: row.merge_commit, + pr_url: row.pr_url, + retry_count: row.retry_count, + }, + sourceBead: row.source_bead_id + ? { + bead_id: row.source_bead_id, + title: row.source_bead_title ?? '', + status: row.source_bead_status ?? '', + body: row.source_bead_body ?? null, + } + : null, + convoy: row.convoy_id + ? { + convoy_id: row.convoy_id, + title: row.convoy_title ?? '', + total_beads: row.convoy_total_beads ?? 0, + closed_beads: row.convoy_closed_beads ?? 0, + feature_branch: row.convoy_feature_branch, + merge_mode: row.convoy_merge_mode, + } + : null, + agent: row.agent_id + ? { + agent_id: row.agent_id, + name: row.agent_name ?? '', + role: row.agent_role ?? '', + } + : null, + rigName: row.rig_name, + staleSince: 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): ActivityLogEntry { + // 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: { + bead_event_id: row.bead_event_id, + bead_id: row.bead_id, + agent_id: row.agent_id, + event_type: row.event_type, + old_value: row.old_value, + new_value: row.new_value, + metadata: row.event_metadata, + created_at: row.event_created_at, + }, + mrBead: row.bead_title + ? { + bead_id: row.bead_id, + title: row.bead_title, + type: row.bead_type ?? 'merge_request', + status: row.bead_status ?? '', + rig_id: row.bead_rig_id, + metadata: row.bead_metadata, + } + : null, + sourceBead: resolvedSourceBead, + convoy: row.convoy_id + ? { + convoy_id: row.convoy_id, + title: row.convoy_title ?? '', + total_beads: row.convoy_total_beads ?? 0, + closed_beads: row.convoy_closed_beads ?? 0, + feature_branch: row.convoy_feature_branch, + merge_mode: row.convoy_merge_mode, + } + : null, + agent: row.agent_id + ? { + agent_id: row.agent_id, + name: row.agent_name ?? '', + role: row.agent_role ?? '', + } + : null, + rigName: row.rig_name, + reviewMetadata: + row.rm_branch !== null + ? { + pr_url: row.rm_pr_url, + branch: row.rm_branch, + target_branch: row.rm_target_branch, + merge_commit: row.rm_merge_commit, + } + : null, + }; +} + // ── Molecules ─────────────────────────────────────────────────────── /** diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 6d88f34422..c76afd0930 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -33,6 +33,7 @@ import { RpcConvoyDetailOutput, RpcAlarmStatusOutput, RpcOrgTownOutput, + RpcMergeQueueDataOutput, } from './schemas'; import type { TRPCContext } from './init'; @@ -898,6 +899,26 @@ export const gastownRouter = router({ }); }), + getMergeQueueData: gastownProcedure + .input( + z.object({ + townId: z.string().uuid(), + rigId: z.string().uuid().optional(), + limit: z.number().int().positive().max(500).default(50), + since: z.string().optional(), + }) + ) + .output(RpcMergeQueueDataOutput) + .query(async ({ ctx, input }) => { + await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.getMergeQueueData({ + rigId: input.rigId, + limit: input.limit, + since: input.since, + }); + }), + listConvoys: gastownProcedure .input( z.object({ diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index 65067ec784..85dfa963da 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -226,6 +226,114 @@ const AlarmStatusOutput = z.object({ export const RpcAlarmStatusOutput = rpcSafe(AlarmStatusOutput); export const RpcRigDetailOutput = rpcSafe(RigDetailOutput); +// ── Merge Queue ────────────────────────────────────────────────────── + +const MergeQueueBeadOutput = z.object({ + bead_id: z.string(), + status: z.string(), + title: z.string(), + body: z.string().nullable(), + rig_id: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + metadata: z.record(z.string(), z.unknown()), +}); + +const ReviewMetadataOutput = z.object({ + branch: z.string(), + target_branch: z.string(), + merge_commit: z.string().nullable(), + pr_url: z.string().nullable(), + retry_count: z.number(), +}); + +const SourceBeadOutput = z.object({ + bead_id: z.string(), + title: z.string(), + status: z.string(), + body: z.string().nullable(), +}); + +const ConvoyRefOutput = z.object({ + convoy_id: z.string(), + title: z.string(), + total_beads: z.number(), + closed_beads: z.number(), + feature_branch: z.string().nullable(), + merge_mode: z.string().nullable(), +}); + +const AgentRefOutput = z.object({ + agent_id: z.string(), + name: z.string(), + role: z.string(), +}); + +const MergeQueueItemOutput = z.object({ + mrBead: MergeQueueBeadOutput, + reviewMetadata: ReviewMetadataOutput, + sourceBead: SourceBeadOutput.nullable(), + convoy: ConvoyRefOutput.nullable(), + agent: AgentRefOutput.nullable(), + rigName: z.string().nullable(), + staleSince: z.string().nullable(), + failureReason: z.string().nullable(), +}); + +const ActivityLogEventOutput = z.object({ + bead_event_id: z.string(), + bead_id: z.string(), + agent_id: z.string().nullable(), + event_type: z.string(), + old_value: z.string().nullable(), + new_value: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()), + created_at: z.string(), +}); + +const ActivityLogMrBeadOutput = z.object({ + bead_id: z.string(), + title: z.string(), + type: z.string(), + status: z.string(), + rig_id: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()), +}); + +const ActivityLogReviewMetadataOutput = z.object({ + pr_url: z.string().nullable(), + branch: z.string().nullable(), + target_branch: z.string().nullable(), + merge_commit: z.string().nullable(), +}); + +const ActivityLogSourceBeadOutput = z.object({ + bead_id: z.string(), + title: z.string(), + status: z.string(), +}); + +const ActivityLogEntryOutput = z.object({ + event: ActivityLogEventOutput, + mrBead: ActivityLogMrBeadOutput.nullable(), + sourceBead: ActivityLogSourceBeadOutput.nullable(), + convoy: ConvoyRefOutput.nullable(), + agent: AgentRefOutput.nullable(), + rigName: z.string().nullable(), + reviewMetadata: ActivityLogReviewMetadataOutput.nullable(), +}); + +export const MergeQueueDataOutput = z.object({ + needsAttention: z.object({ + openPRs: z.array(MergeQueueItemOutput), + failedReviews: z.array(MergeQueueItemOutput), + stalePRs: z.array(MergeQueueItemOutput), + }), + activityLog: z.array(ActivityLogEntryOutput), +}); + +export const RpcMergeQueueDataOutput = rpcSafe(MergeQueueDataOutput); + // OrgTown (from GastownOrgDO) export const OrgTownOutput = z.object({ id: z.string(), diff --git a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx index 0b515a3481..ff9186841d 100644 --- a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx @@ -1,73 +1,137 @@ 'use client'; +import { useState } from 'react'; 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, Activity, Server } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { NeedsAttention } from './NeedsAttention'; +import { RefineryActivityLog } from './RefineryActivityLog'; + +const ALL_RIGS = '__all__'; export function MergesPageClient({ townId }: { townId: string }) { const trpc = useGastownTRPC(); + const [selectedRigId, setSelectedRigId] = useState(ALL_RIGS); + + const rigIdParam = selectedRigId === ALL_RIGS ? undefined : selectedRigId; + + const rigsQuery = useQuery(trpc.gastown.listRigs.queryOptions({ townId })); + const rigs = rigsQuery.data ?? []; - const eventsQuery = useQuery({ - ...trpc.gastown.getTownEvents.queryOptions({ townId, limit: 200 }), + const mergeQueueQuery = useQuery({ + ...trpc.gastown.getMergeQueueData.queryOptions({ + townId, + rigId: rigIdParam, + limit: 200, + }), 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 (
+ {/* Page header */}

Merge Queue

- {mergeEvents.length} + {totalAttention > 0 && ( + + {totalAttention} + + )} +
+ + {/* Rig filter */} +
+ +
+ {/* Content */}
- {mergeEvents.length === 0 && ( -
- -

No merge activity yet.

-

- Review submissions and merge completions will appear here. -

-
- )} +
+ {/* Loading state */} + {mergeQueueQuery.isLoading && ( +
+ +

Loading merge queue…

+
+ )} + + {/* Error state */} + {mergeQueueQuery.isError && ( +
+ +

Failed to load merge queue data.

+

{mergeQueueQuery.error.message}

+
+ )} - {mergeEvents - .slice() - .reverse() - .map(event => { - const isCompleted = event.event_type === 'review_completed'; - return ( -
- {isCompleted ? ( - - ) : ( - + {/* Needs Your Attention section */} + {needsAttention && ( +
+
+ +

+ Needs Your Attention +

+ {totalAttention > 0 && ( + + {totalAttention} + )} -
-
- {isCompleted ? 'Review completed' : 'Submitted for review'} - {event.new_value ? `: ${event.new_value}` : ''} -
-
- {event.rig_name && {event.rig_name}} - - {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} - -
-
- ); - })} + +
+ )} + + {/* Refinery Activity Log section */} + {mergeQueueQuery.data && ( +
+
+ +

+ Refinery Activity Log +

+
+ +
+ )} +
); diff --git a/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx b/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx new file mode 100644 index 0000000000..a7fc670317 --- /dev/null +++ b/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx @@ -0,0 +1,539 @@ +'use client'; + +import { useState, useMemo, Fragment } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; +import { useGastownTRPC } from '@/lib/gastown/trpc'; +import type { GastownOutputs } from '@/lib/gastown/trpc'; +import { useDrawerStack } from '@/components/gastown/DrawerStack'; +import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; +import { motion, AnimatePresence } from 'motion/react'; +import { + AlertTriangle, + ExternalLink, + Eye, + GitBranch, + GitMerge, + RefreshCw, + XCircle, + CheckCircle2, + Clock, +} from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +// ── Types ──────────────────────────────────────────────────────────── + +type MergeQueueData = GastownOutputs['gastown']['getMergeQueueData']; +type MergeQueueItem = MergeQueueData['needsAttention']['openPRs'][number]; + +type ConvoyGroup = { + convoy: NonNullable; + items: MergeQueueItem[]; +}; + +type ConfirmAction = { + beadId: string; + title: string; + action: 'fail' | 'retry'; +}; + +// ── Status badges ──────────────────────────────────────────────────── + +const CATEGORY_STYLES = { + openPR: { + border: 'border-violet-500/30', + bg: 'bg-violet-500/10', + text: 'text-violet-300', + label: 'PR Open', + }, + failed: { + border: 'border-red-500/30', + bg: 'bg-red-500/10', + text: 'text-red-300', + label: 'Failed', + }, + stale: { + border: 'border-amber-500/30', + bg: 'bg-amber-500/10', + text: 'text-amber-300', + label: 'Stale', + }, +} as const; + +type Category = keyof typeof CATEGORY_STYLES; + +// ── Convoy grouping ────────────────────────────────────────────────── + +function groupByConvoy(items: MergeQueueItem[]): { + convoyGroups: ConvoyGroup[]; + standalone: MergeQueueItem[]; +} { + const convoyMap = new Map(); + const standalone: MergeQueueItem[] = []; + + for (const item of items) { + if (item.convoy) { + const existing = convoyMap.get(item.convoy.convoy_id); + if (existing) { + existing.items.push(item); + } else { + convoyMap.set(item.convoy.convoy_id, { + convoy: item.convoy, + items: [item], + }); + } + } else { + standalone.push(item); + } + } + + return { + convoyGroups: [...convoyMap.values()], + standalone, + }; +} + +// ── Main component ─────────────────────────────────────────────────── + +export function NeedsAttention({ + data, + townId, +}: { + data: MergeQueueData['needsAttention']; + townId: string; +}) { + const session = useSession(); + const isAdmin = session?.data?.isAdmin ?? false; + const totalCount = data.openPRs.length + data.failedReviews.length + data.stalePRs.length; + + // Tag each item with its category for rendering + const allItems = useMemo(() => { + const tagged: Array<{ item: MergeQueueItem; category: Category }> = []; + for (const item of data.openPRs) tagged.push({ item, category: 'openPR' }); + for (const item of data.failedReviews) tagged.push({ item, category: 'failed' }); + for (const item of data.stalePRs) tagged.push({ item, category: 'stale' }); + return tagged; + }, [data]); + + // Group by convoy + const { convoyGroups, standalone } = useMemo(() => { + const allItemsFlat = allItems.map(t => t.item); + return groupByConvoy(allItemsFlat); + }, [allItems]); + + // Category lookup for rendering + const categoryByBeadId = useMemo(() => { + const map = new Map(); + for (const { item, category } of allItems) { + map.set(item.mrBead.bead_id, category); + } + return map; + }, [allItems]); + + if (totalCount === 0) { + return ( +
+ +

All clear — nothing needs your attention

+
+ ); + } + + return ( +
+ {/* Convoy groups */} + + {convoyGroups.map(group => ( + + + + ))} + + + {/* Standalone items (no convoy) */} + + {standalone.map((item, i) => ( + + + + ))} + +
+ ); +} + +// ── Convoy group card ──────────────────────────────────────────────── + +function ConvoyGroupCard({ + group, + categoryByBeadId, + townId, + isAdmin, +}: { + group: ConvoyGroup; + categoryByBeadId: Map; + townId: string; + isAdmin: boolean; +}) { + const { open: openDrawer } = useDrawerStack(); + const { convoy, items } = group; + const progress = + convoy.total_beads > 0 ? `${convoy.closed_beads}/${convoy.total_beads} beads reviewed` : ''; + + return ( +
+ {/* Convoy header */} + + + {/* Progress bar */} + {convoy.total_beads > 0 && ( +
+ +
+ )} + + {/* Items within convoy */} +
+ {items.map((item, i) => ( + + {i > 0 &&
} + + + ))} +
+
+ ); +} + +// ── Standalone attention item card ─────────────────────────────────── + +function AttentionItemCard({ + item, + category, + townId, + isAdmin, +}: { + item: MergeQueueItem; + category: Category; + townId: string; + isAdmin: boolean; +}) { + return ( +
+ +
+ ); +} + +// ── Shared row component (used inside convoy group and standalone) ─── + +function AttentionItemRow({ + item, + category, + townId, + isAdmin, +}: { + item: MergeQueueItem; + category: Category; + townId: string; + isAdmin: boolean; +}) { + const { open: openDrawer } = useDrawerStack(); + const trpc = useGastownTRPC(); + const queryClient = useQueryClient(); + const [confirmAction, setConfirmAction] = useState(null); + + const style = CATEGORY_STYLES[category]; + const sourceBeadTitle = item.sourceBead?.title ?? item.mrBead.title.replace(/^Review: /, ''); + const rigId = item.mrBead.rig_id ?? ''; + + const invalidateMergeQueue = () => { + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.getMergeQueueData.queryKey({ townId }), + }); + }; + + // Retry review: reset the MR bead status back to 'open' so the refinery re-queues it. + // updateBead requires a rigId — use the MR bead's rig_id. + const retryMutation = useMutation( + trpc.gastown.updateBead.mutationOptions({ + onSuccess: () => { + setConfirmAction(null); + invalidateMergeQueue(); + toast.success('Review retry requested'); + }, + onError: (err: { message: string }) => { + toast.error(`Failed to retry: ${err.message}`); + }, + }) + ); + + // Fail bead mutation: use adminForceFailBead + const failMutation = useMutation( + trpc.gastown.adminForceFailBead.mutationOptions({ + onSuccess: () => { + setConfirmAction(null); + invalidateMergeQueue(); + toast.success('Bead marked as failed'); + }, + onError: (err: { message: string }) => { + toast.error(`Failed to update bead: ${err.message}`); + }, + }) + ); + + const isPending = retryMutation.isPending || failMutation.isPending; + + const handleConfirm = () => { + if (!confirmAction) return; + if (confirmAction.action === 'retry') { + retryMutation.mutate({ + rigId, + beadId: confirmAction.beadId, + status: 'open', + }); + } else { + failMutation.mutate({ + townId, + beadId: confirmAction.beadId, + }); + } + }; + + return ( + <> +
+ {/* Category indicator */} +
+ {category === 'openPR' && } + {category === 'failed' && } + {category === 'stale' && } +
+ + {/* Content */} +
+ {/* Title row */} +
+ + {style.label} + + +
+ + {/* Metadata row */} +
+ {item.rigName && {item.rigName}} + {item.agent && {item.agent.name}} + + {formatDistanceToNow(new Date(item.mrBead.created_at), { + addSuffix: true, + })} + + {item.reviewMetadata.retry_count > 0 && ( + + {item.reviewMetadata.retry_count}{' '} + {item.reviewMetadata.retry_count === 1 ? 'retry' : 'retries'} + + )} + {category === 'stale' && item.staleSince && ( + + stale since{' '} + {formatDistanceToNow(new Date(item.staleSince), { + addSuffix: true, + })} + + )} + {category === 'failed' && item.failureReason && ( + {item.failureReason} + )} +
+
+ + {/* Actions */} +
+ {item.reviewMetadata.pr_url && ( + + + + )} + {category === 'failed' && ( + + )} + + {isAdmin && ( + + )} +
+
+ + {/* Confirmation dialog for retry or fail */} + setConfirmAction(null)}> + + + + {confirmAction?.action === 'retry' ? 'Retry Review' : 'Fail Bead'} + + + {confirmAction?.action === 'retry' ? ( + <> + Re-queue {confirmAction.title} for review? + This resets the MR bead so the refinery picks it up again. + + ) : ( + <> + Mark {confirmAction?.title} as failed? This + stops the review process for this bead. + + )} + + + + + + + + + + ); +} diff --git a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx new file mode 100644 index 0000000000..15a3951c58 --- /dev/null +++ b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx @@ -0,0 +1,556 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { + GitMerge, + GitPullRequest, + GitBranch, + AlertTriangle, + RotateCcw, + Send, + XCircle, + Activity, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; +import { useDrawerStack } from '@/components/gastown/DrawerStack'; +import type { GastownOutputs } from '@/lib/gastown/trpc'; + +type MergeQueueData = GastownOutputs['gastown']['getMergeQueueData']; +type ActivityLogEntry = MergeQueueData['activityLog'][number]; + +type ActionType = + | 'merged' + | 'failed' + | 'pr_created' + | 'pr_creation_failed' + | 'rework_requested' + | 'review_submitted' + | 'status_changed'; + +const ACTION_CONFIG: Record< + ActionType, + { + icon: typeof GitMerge; + dotColor: string; + lineColor: string; + } +> = { + merged: { + icon: GitMerge, + dotColor: 'bg-emerald-400', + lineColor: 'border-emerald-500/30', + }, + failed: { + icon: XCircle, + dotColor: 'bg-red-400', + lineColor: 'border-red-500/30', + }, + pr_created: { + icon: GitPullRequest, + dotColor: 'bg-sky-400', + lineColor: 'border-sky-500/30', + }, + pr_creation_failed: { + icon: AlertTriangle, + dotColor: 'bg-red-400', + lineColor: 'border-red-500/30', + }, + rework_requested: { + icon: RotateCcw, + dotColor: 'bg-amber-400', + lineColor: 'border-amber-500/30', + }, + review_submitted: { + icon: Send, + dotColor: 'bg-indigo-400', + lineColor: 'border-indigo-500/30', + }, + status_changed: { + icon: Activity, + dotColor: 'bg-white/40', + lineColor: 'border-white/10', + }, +}; + +function isActionType(value: string): value is ActionType { + return value in ACTION_CONFIG; +} + +function resolveActionType(entry: ActivityLogEntry): ActionType { + const eventType = entry.event.event_type; + if (eventType === 'review_completed') { + return entry.event.new_value === 'merged' ? 'merged' : 'failed'; + } + if (isActionType(eventType)) { + return eventType; + } + return 'status_changed'; +} + +function extractPrNumber(prUrl: string | null): string | null { + if (!prUrl) return null; + const match = /\/pull\/(\d+)/.exec(prUrl); + return match ? match[1] : null; +} + +function extractMessage(entry: ActivityLogEntry): string | null { + const meta = entry.event.metadata; + if (typeof meta.message === 'string') return meta.message; + if (typeof meta.feedback === 'string') return meta.feedback; + if (typeof meta.reason === 'string') return meta.reason; + return null; +} + +function buildDescription(entry: ActivityLogEntry): { + prefix: string; + beadTitle: string; + suffix: string; +} { + const action = resolveActionType(entry); + const agentName = entry.agent?.name ?? 'an agent'; + const beadTitle = entry.sourceBead?.title ?? entry.mrBead?.title ?? 'untitled bead'; + const targetBranch = entry.reviewMetadata?.target_branch; + + const branchSuffix = targetBranch + ? targetBranch === 'main' + ? ' into main' + : ` into ${targetBranch}` + : ''; + + const convoySuffix = entry.convoy && branchSuffix ? ` (convoy: ${entry.convoy.title})` : ''; + + switch (action) { + case 'merged': + return { + prefix: `Refinery merged ${agentName}\u2019s `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: `${branchSuffix}${convoySuffix}`, + }; + case 'failed': + return { + prefix: `Refinery review failed for ${agentName}\u2019s `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: '', + }; + case 'pr_created': { + const prUrl = entry.reviewMetadata?.pr_url ?? null; + const prNum = extractPrNumber(prUrl); + const prLabel = prNum ? `PR #${prNum}` : 'a PR'; + return { + prefix: `Refinery created ${prLabel} for ${agentName}\u2019s `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: '', + }; + } + case 'pr_creation_failed': + return { + prefix: `Refinery failed to create PR for ${agentName}\u2019s `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: '', + }; + case 'rework_requested': + return { + prefix: `Refinery requested changes from ${agentName} on `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: '', + }; + case 'review_submitted': + return { + prefix: `${agentName} submitted `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: ' for review', + }; + case 'status_changed': + return { + prefix: `Status changed on `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: entry.event.new_value ? ` \u2192 ${entry.event.new_value}` : '', + }; + } +} + +// ── Convoy grouping ────────────────────────────────────────────────── + +type ConvoyInfo = NonNullable; + +type ConvoyActivityGroup = { + convoy: ConvoyInfo; + entries: ActivityLogEntry[]; + latestTimestamp: string; +}; + +function groupActivityByConvoy(entries: ActivityLogEntry[]): { + convoyGroups: ConvoyActivityGroup[]; + standalone: ActivityLogEntry[]; +} { + const convoyMap = new Map(); + const standalone: ActivityLogEntry[] = []; + + for (const entry of entries) { + if (entry.convoy) { + const existing = convoyMap.get(entry.convoy.convoy_id); + if (existing) { + existing.entries.push(entry); + if (entry.event.created_at > existing.latestTimestamp) { + existing.latestTimestamp = entry.event.created_at; + } + } else { + convoyMap.set(entry.convoy.convoy_id, { + convoy: entry.convoy, + entries: [entry], + latestTimestamp: entry.event.created_at, + }); + } + } else { + standalone.push(entry); + } + } + + // Sort convoy groups by most recent activity + const convoyGroups = [...convoyMap.values()].sort((a, b) => + b.latestTimestamp.localeCompare(a.latestTimestamp) + ); + + return { convoyGroups, standalone }; +} + +// ── Main component ─────────────────────────────────────────────────── + +const PAGE_SIZE = 20; + +export function RefineryActivityLog({ + activityLog, + isLoading, + townId, +}: { + activityLog: ActivityLogEntry[] | undefined; + isLoading: boolean; + townId: string; +}) { + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const entries = activityLog ?? []; + + // Groups are sorted by most recent activity (latestTimestamp descending) + const { convoyGroups, standalone } = useMemo(() => groupActivityByConvoy(entries), [entries]); + + // Merge convoy groups and standalone entries into a single list sorted by + // most-recent timestamp, then paginate over that unified list. This ensures + // the most recently active items (whether convoy or standalone) appear first, + // rather than showing all convoy groups before any standalone entries. + type DisplayItem = + | { kind: 'convoy'; group: ConvoyActivityGroup; sortKey: string } + | { kind: 'standalone'; entry: ActivityLogEntry; sortKey: string }; + + const { visibleItems, totalEntryCount, visibleEntryCount, hasMore } = useMemo(() => { + const items: DisplayItem[] = [ + ...convoyGroups.map( + (group): DisplayItem => ({ + kind: 'convoy', + group, + sortKey: group.latestTimestamp, + }) + ), + ...standalone.map( + (entry): DisplayItem => ({ + kind: 'standalone', + entry, + sortKey: entry.event.created_at, + }) + ), + ]; + + // Sort by most recent first + items.sort((a, b) => b.sortKey.localeCompare(a.sortKey)); + + // Paginate: each convoy group costs its entry count, each standalone costs 1 + const visible: DisplayItem[] = []; + let budget = visibleCount; + let visibleEntries = 0; + + for (const item of items) { + if (budget <= 0) break; + const cost = item.kind === 'convoy' ? item.group.entries.length : 1; + visible.push(item); + budget -= cost; + visibleEntries += cost; + } + + const total = convoyGroups.reduce((sum, g) => sum + g.entries.length, 0) + standalone.length; + + return { + visibleItems: visible, + totalEntryCount: total, + visibleEntryCount: visibleEntries, + hasMore: visibleEntries < total, + }; + }, [convoyGroups, standalone, visibleCount]); + + // All hooks are above — early returns below + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + +
+
+
+
+
+
+
+
+
+ + ))} +
+ ); + } + + if (entries.length === 0) { + return ( + + +

No refinery activity yet

+

+ Merge reviews, PR creations, and rework requests will appear here. +

+
+ ); + } + + return ( +
+
+ {/* Convoy groups and standalone entries interleaved by recency */} + + {visibleItems.map((item, idx) => + item.kind === 'convoy' ? ( + + ) : ( + + ) + )} + +
+ + {hasMore && ( + + )} +
+ ); +} + +// ── Convoy activity group card ─────────────────────────────────────── + +function ConvoyActivityGroupCard({ + convoy, + entries, + townId, +}: { + convoy: ConvoyInfo; + entries: ActivityLogEntry[]; + townId: string; +}) { + const { open: openDrawer } = useDrawerStack(); + const progress = + convoy.total_beads > 0 ? `${convoy.closed_beads}/${convoy.total_beads} beads reviewed` : ''; + + return ( + + {/* Convoy header */} + + + {/* Progress bar */} + {convoy.total_beads > 0 && ( +
+ +
+ )} + + {/* Timeline entries within convoy */} +
+ + {entries.map((entry, i) => ( + + ))} + +
+
+ ); +} + +// ── Timeline entry ─────────────────────────────────────────────────── + +function TimelineEntry({ + entry, + isLast, + delay, +}: { + entry: ActivityLogEntry; + isLast: boolean; + delay: number; +}) { + const { open } = useDrawerStack(); + const action = resolveActionType(entry); + const config = ACTION_CONFIG[action]; + const Icon = config.icon; + const description = buildDescription(entry); + const message = extractMessage(entry); + const commitSha = entry.reviewMetadata?.merge_commit ?? null; + const prUrl = entry.reviewMetadata?.pr_url ?? null; + const prNumber = extractPrNumber(prUrl); + const rigName = entry.rigName; + const rigId = entry.mrBead?.rig_id; + + function handleBeadClick() { + const beadId = entry.sourceBead?.bead_id ?? entry.mrBead?.bead_id; + if (beadId && rigId) { + open({ type: 'bead', beadId, rigId }); + } + } + + return ( + + {/* Timeline indicator */} +
+
+ {!isLast && ( +
+ )} +
+ + {/* Content */} +
+ {/* Rig name + timestamp header */} +
+ {rigName && {rigName}} + {rigName && ·} + + {formatDistanceToNow(new Date(entry.event.created_at), { + addSuffix: true, + })} + + +
+ + {/* Main description */} +

+ {description.prefix} + + {description.suffix} +

+ + {/* Reason/message line */} + {message && ( +

+ {action === 'rework_requested' ? 'Reason: ' : ''} + {message} +

+ )} + + {/* Metadata line */} +
+ {commitSha && Commit {commitSha.slice(0, 7)}} + {prUrl && prNumber && ( + + PR #{prNumber} + + )} + {prUrl && !prNumber && ( + + View PR + + )} +
+
+ + ); +} diff --git a/src/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index 3f616b1f90..591ea8bef3 100644 --- a/src/lib/gastown/types/router.d.ts +++ b/src/lib/gastown/types/router.d.ts @@ -580,6 +580,187 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }[]; meta: object; }>; + getMergeQueueData: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + rigId?: string | undefined; + limit?: number | undefined; + since?: string | undefined; + }; + output: { + needsAttention: { + openPRs: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + failedReviews: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + stalePRs: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + }; + activityLog: { + event: { + bead_event_id: string; + bead_id: string; + agent_id: string | null; + event_type: string; + old_value: string | null; + new_value: string | null; + metadata: Record; + created_at: string; + }; + mrBead: { + bead_id: string; + title: string; + type: string; + status: string; + rig_id: string | null; + metadata: Record; + } | null; + sourceBead: { + bead_id: string; + title: string; + status: string; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + reviewMetadata: { + pr_url: string | null; + branch: string | null; + target_branch: string | null; + merge_commit: string | null; + } | null; + }[]; + }; + meta: object; + }>; listConvoys: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string; @@ -1605,6 +1786,187 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }[]; meta: object; }>; + getMergeQueueData: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + rigId?: string | undefined; + limit?: number | undefined; + since?: string | undefined; + }; + output: { + needsAttention: { + openPRs: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + failedReviews: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + stalePRs: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + }; + activityLog: { + event: { + bead_event_id: string; + bead_id: string; + agent_id: string | null; + event_type: string; + old_value: string | null; + new_value: string | null; + metadata: Record; + created_at: string; + }; + mrBead: { + bead_id: string; + title: string; + type: string; + status: string; + rig_id: string | null; + metadata: Record; + } | null; + sourceBead: { + bead_id: string; + title: string; + status: string; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + reviewMetadata: { + pr_url: string | null; + branch: string | null; + target_branch: string | null; + merge_commit: string | null; + } | null; + }[]; + }; + meta: object; + }>; listConvoys: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string;