From 96b4d73fbabc2d0dd40bd32f99e7e59cb396a31e Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Mon, 23 Mar 2026 22:43:37 +0000 Subject: [PATCH 1/4] feat: show merged PR status on /coding page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the coding agent Slack tags page to surface which pull requests have been merged, using the new GET /api/admins/coding/pr endpoint. - types/coding-agent.ts — CodingPrStatus, CodingPrStatusResponse types - lib/recoup/fetchCodingPrStatus.ts — HTTP client for the new endpoint - hooks/useCodingPrStatus.ts — React Query hook, returns Set of merged URLs - lib/coding-agent/getTagsByDate.ts — adds merged_pr_count per date - components/Admin/AdminLineChart.tsx — adds thirdLine prop support - components/CodingAgentSlackTags/SlackTagsColumns.tsx — ship 🚢 emoji for merged PRs - components/CodingAgentSlackTags/SlackTagsTable.tsx — accepts mergedPrUrls prop - components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx — wires it all together: merged PRs top stat, "PRs Merged" chart line, ship emoji in table Co-Authored-By: Claude Sonnet 4.6 --- components/Admin/AdminLineChart.tsx | 24 ++- .../CodingAgentSlackTagsPage.tsx | 27 +++- .../CodingAgentSlackTags/SlackTagsColumns.tsx | 147 ++++++++++-------- .../CodingAgentSlackTags/SlackTagsTable.tsx | 11 +- hooks/useCodingPrStatus.ts | 27 ++++ lib/coding-agent/getTagsByDate.ts | 20 ++- lib/recoup/fetchCodingPrStatus.ts | 33 ++++ types/coding-agent.ts | 10 ++ 8 files changed, 211 insertions(+), 88 deletions(-) create mode 100644 hooks/useCodingPrStatus.ts create mode 100644 lib/recoup/fetchCodingPrStatus.ts diff --git a/components/Admin/AdminLineChart.tsx b/components/Admin/AdminLineChart.tsx index 984e811..33147ff 100644 --- a/components/Admin/AdminLineChart.tsx +++ b/components/Admin/AdminLineChart.tsx @@ -10,7 +10,7 @@ import { type ChartConfig, } from "@/components/ui/chart"; -interface SecondLine { +interface ExtraLine { data: Array<{ date: string; count: number }>; label: string; color?: string; @@ -20,7 +20,8 @@ interface AdminLineChartProps { title: string; data: Array<{ date: string; count: number }>; label?: string; - secondLine?: SecondLine; + secondLine?: ExtraLine; + thirdLine?: ExtraLine; } export default function AdminLineChart({ @@ -28,6 +29,7 @@ export default function AdminLineChart({ data, label = "Count", secondLine, + thirdLine, }: AdminLineChartProps) { if (data.length === 0) return null; @@ -36,14 +38,19 @@ export default function AdminLineChart({ ...(secondLine ? { count2: { label: secondLine.label, color: secondLine.color ?? "#6B8E93" } } : {}), + ...(thirdLine + ? { count3: { label: thirdLine.label, color: thirdLine.color ?? "#4A90A4" } } + : {}), } satisfies ChartConfig; - // Merge primary and secondary data by date + // Merge primary, secondary, and tertiary data by date const secondMap = new Map(secondLine?.data.map((d) => [d.date, d.count]) ?? []); + const thirdMap = new Map(thirdLine?.data.map((d) => [d.date, d.count]) ?? []); const mergedData = data.map((d) => ({ date: d.date, count: d.count, ...(secondLine ? { count2: secondMap.get(d.date) ?? 0 } : {}), + ...(thirdLine ? { count3: thirdMap.get(d.date) ?? 0 } : {}), })); return ( @@ -77,7 +84,7 @@ export default function AdminLineChart({ /> } /> - {secondLine && } />} + {(secondLine || thirdLine) && } />} )} + {thirdLine && ( + + )} diff --git a/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx b/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx index cdd09d5..8cdb396 100644 --- a/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx +++ b/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb"; import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink"; import { useSlackTags } from "@/hooks/useSlackTags"; +import { useCodingPrStatus } from "@/hooks/useCodingPrStatus"; import SlackTagsTable from "./SlackTagsTable"; import AdminLineChart from "@/components/Admin/AdminLineChart"; import { getTagsByDate } from "@/lib/coding-agent/getTagsByDate"; @@ -15,6 +16,10 @@ import type { AdminPeriod } from "@/types/admin"; export default function CodingAgentSlackTagsPage() { const [period, setPeriod] = useState("all"); const { data, isLoading, error } = useSlackTags(period); + const { data: mergedPrUrls } = useCodingPrStatus(data?.tags); + + const tagsByDate = data ? getTagsByDate(data.tags, mergedPrUrls) : []; + const totalMergedPrs = mergedPrUrls?.size ?? 0; return (
@@ -47,6 +52,10 @@ export default function CodingAgentSlackTagsPage() { {data.total_pull_requests}{" "} total PRs + + {totalMergedPrs}{" "} + merged PRs + )} @@ -74,17 +83,23 @@ export default function CodingAgentSlackTagsPage() { <> ({ date: d.date, count: d.count }))} + data={tagsByDate.map((d) => ({ date: d.date, count: d.count }))} label="Tags" secondLine={{ - data: getTagsByDate(data.tags).map((d) => ({ - date: d.date, - count: d.pull_request_count, - })), + data: tagsByDate.map((d) => ({ date: d.date, count: d.pull_request_count })), label: "Tags with PRs", }} + thirdLine={ + mergedPrUrls + ? { + data: tagsByDate.map((d) => ({ date: d.date, count: d.merged_pr_count })), + label: "PRs Merged", + color: "#22863a", + } + : undefined + } /> - + )}
diff --git a/components/CodingAgentSlackTags/SlackTagsColumns.tsx b/components/CodingAgentSlackTags/SlackTagsColumns.tsx index a89a7a6..99ff55e 100644 --- a/components/CodingAgentSlackTags/SlackTagsColumns.tsx +++ b/components/CodingAgentSlackTags/SlackTagsColumns.tsx @@ -2,74 +2,83 @@ import { type ColumnDef } from "@tanstack/react-table"; import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader"; import type { SlackTag } from "@/types/coding-agent"; -export const slackTagsColumns: ColumnDef[] = [ - { - id: "user_name", - accessorKey: "user_name", - header: "Tagged By", - cell: ({ row }) => { - const tag = row.original; - return ( -
- {tag.user_avatar && ( - {tag.user_name} - )} - {tag.user_name} -
- ); +export function createSlackTagsColumns(mergedPrUrls?: Set): ColumnDef[] { + return [ + { + id: "user_name", + accessorKey: "user_name", + header: "Tagged By", + cell: ({ row }) => { + const tag = row.original; + return ( +
+ {tag.user_avatar && ( + {tag.user_name} + )} + {tag.user_name} +
+ ); + }, }, - }, - { - id: "prompt", - accessorKey: "prompt", - header: "Prompt", - cell: ({ getValue }) => ( - - {getValue()} - - ), - }, - { - id: "channel_name", - accessorKey: "channel_name", - header: "Channel", - cell: ({ getValue }) => ( - #{getValue()} - ), - }, - { - id: "pull_requests", - accessorKey: "pull_requests", - header: "Pull Requests", - cell: ({ getValue }) => { - const prs = getValue(); - if (!prs?.length) return ; - return ( - - ); + { + id: "prompt", + accessorKey: "prompt", + header: "Prompt", + cell: ({ getValue }) => ( + + {getValue()} + + ), }, - }, - { - id: "timestamp", - accessorKey: "timestamp", - header: ({ column }) => , - cell: ({ getValue }) => new Date(getValue()).toLocaleString(), - sortingFn: "basic", - }, -]; + { + id: "channel_name", + accessorKey: "channel_name", + header: "Channel", + cell: ({ getValue }) => ( + #{getValue()} + ), + }, + { + id: "pull_requests", + accessorKey: "pull_requests", + header: "Pull Requests", + cell: ({ getValue }) => { + const prs = getValue(); + if (!prs?.length) return ; + return ( +
+ {prs.map((url) => { + const isMerged = mergedPrUrls?.has(url); + return ( + + {isMerged ? "🚢 " : ""} + {url.match(/github\.com\/[^/]+\/([^/]+)\/pull\/(\d+)/)?.slice(1).join("#")} + + ); + })} +
+ ); + }, + }, + { + id: "timestamp", + accessorKey: "timestamp", + header: ({ column }) => , + cell: ({ getValue }) => new Date(getValue()).toLocaleString(), + sortingFn: "basic", + }, + ]; +} + +/** @deprecated Use createSlackTagsColumns() instead */ +export const slackTagsColumns = createSlackTagsColumns(); diff --git a/components/CodingAgentSlackTags/SlackTagsTable.tsx b/components/CodingAgentSlackTags/SlackTagsTable.tsx index 49c8ce1..79a73fd 100644 --- a/components/CodingAgentSlackTags/SlackTagsTable.tsx +++ b/components/CodingAgentSlackTags/SlackTagsTable.tsx @@ -16,21 +16,24 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { slackTagsColumns } from "./SlackTagsColumns"; +import { createSlackTagsColumns } from "./SlackTagsColumns"; import type { SlackTag } from "@/types/coding-agent"; interface SlackTagsTableProps { tags: SlackTag[]; + mergedPrUrls?: Set; } -export default function SlackTagsTable({ tags }: SlackTagsTableProps) { +export default function SlackTagsTable({ tags, mergedPrUrls }: SlackTagsTableProps) { const [sorting, setSorting] = useState([ { id: "timestamp", desc: true }, ]); + const columns = createSlackTagsColumns(mergedPrUrls); + const table = useReactTable({ data: tags, - columns: slackTagsColumns, + columns, state: { sorting }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), @@ -66,7 +69,7 @@ export default function SlackTagsTable({ tags }: SlackTagsTableProps) { )) ) : ( - + No results. diff --git a/hooks/useCodingPrStatus.ts b/hooks/useCodingPrStatus.ts new file mode 100644 index 0000000..0dc1bfe --- /dev/null +++ b/hooks/useCodingPrStatus.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { fetchCodingPrStatus } from "@/lib/recoup/fetchCodingPrStatus"; +import type { SlackTag } from "@/types/coding-agent"; + +/** + * Fetches the merged status for all PR URLs found in the given Slack tags. + * Returns a Set of merged PR URLs for fast lookup. + */ +export function useCodingPrStatus(tags: SlackTag[] | undefined) { + const { ready, authenticated, getAccessToken } = usePrivy(); + + const allPrUrls = [...new Set(tags?.flatMap((tag) => tag.pull_requests ?? []) ?? [])]; + + return useQuery({ + queryKey: ["admin", "coding-agent", "pr-status", allPrUrls], + queryFn: async () => { + const token = await getAccessToken(); + if (!token) throw new Error("Not authenticated"); + const res = await fetchCodingPrStatus(token, allPrUrls); + return new Set(res.pull_requests.filter((pr) => pr.merged).map((pr) => pr.url)); + }, + enabled: ready && authenticated && allPrUrls.length > 0, + }); +} diff --git a/lib/coding-agent/getTagsByDate.ts b/lib/coding-agent/getTagsByDate.ts index 70db9ad..2e60b16 100644 --- a/lib/coding-agent/getTagsByDate.ts +++ b/lib/coding-agent/getTagsByDate.ts @@ -4,25 +4,35 @@ export interface TagsByDateEntry { date: string; count: number; pull_request_count: number; + merged_pr_count: number; } /** * Aggregates Slack tags and their associated pull requests by UTC date (YYYY-MM-DD) for charting. * * @param tags - Array of SlackTag objects - * @returns Array of { date, count, pull_request_count } sorted ascending by date + * @param mergedPrUrls - Optional set of merged PR URLs for merged count tracking + * @returns Array of { date, count, pull_request_count, merged_pr_count } sorted ascending by date */ -export function getTagsByDate(tags: SlackTag[]): TagsByDateEntry[] { - const counts: Record = {}; +export function getTagsByDate(tags: SlackTag[], mergedPrUrls?: Set): TagsByDateEntry[] { + const counts: Record = {}; for (const tag of tags) { const date = tag.timestamp.slice(0, 10); // "YYYY-MM-DD" - if (!counts[date]) counts[date] = { count: 0, pull_request_count: 0 }; + if (!counts[date]) counts[date] = { count: 0, pull_request_count: 0, merged_pr_count: 0 }; counts[date].count += 1; counts[date].pull_request_count += (tag.pull_requests?.length ?? 0) > 0 ? 1 : 0; + if (mergedPrUrls) { + counts[date].merged_pr_count += tag.pull_requests?.some((pr) => mergedPrUrls.has(pr)) ? 1 : 0; + } } return Object.entries(counts) - .map(([date, { count, pull_request_count }]) => ({ date, count, pull_request_count })) + .map(([date, { count, pull_request_count, merged_pr_count }]) => ({ + date, + count, + pull_request_count, + merged_pr_count, + })) .sort((a, b) => a.date.localeCompare(b.date)); } diff --git a/lib/recoup/fetchCodingPrStatus.ts b/lib/recoup/fetchCodingPrStatus.ts new file mode 100644 index 0000000..d73abb2 --- /dev/null +++ b/lib/recoup/fetchCodingPrStatus.ts @@ -0,0 +1,33 @@ +import { API_BASE_URL } from "@/lib/consts"; +import type { CodingPrStatusResponse } from "@/types/coding-agent"; + +/** + * Fetches the merged status for an array of GitHub PR URLs from GET /api/admins/coding/pr. + * Authenticates using the caller's Privy access token (admin Bearer auth). + * + * @param accessToken - Privy access token from getAccessToken() + * @param pullRequests - Array of GitHub PR URLs to check + * @returns CodingPrStatusResponse with merged status for each PR + */ +export async function fetchCodingPrStatus( + accessToken: string, + pullRequests: string[], +): Promise { + if (pullRequests.length === 0) { + return { status: "success", pull_requests: [] }; + } + + const url = new URL(`${API_BASE_URL}/api/admins/coding/pr`); + pullRequests.forEach((pr) => url.searchParams.append("pull_requests", pr)); + + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); + } + + return res.json(); +} diff --git a/types/coding-agent.ts b/types/coding-agent.ts index 65ae517..8ba70ab 100644 --- a/types/coding-agent.ts +++ b/types/coding-agent.ts @@ -18,3 +18,13 @@ export interface SlackTagsResponse { tags_with_pull_requests: number; tags: SlackTag[]; } + +export interface CodingPrStatus { + url: string; + merged: boolean; +} + +export interface CodingPrStatusResponse { + status: "success"; + pull_requests: CodingPrStatus[]; +} From a51eacfbb5a75d9889282e8f1e0003a1f121cdf8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 23 Mar 2026 19:27:23 -0500 Subject: [PATCH 2/4] refactor: update CodingPrStatus to use status enum instead of merged boolean Aligns with API change from { merged: boolean } to { status: "open" | "closed" | "merged" }. Co-Authored-By: Claude Opus 4.6 (1M context) --- hooks/useCodingPrStatus.ts | 2 +- types/coding-agent.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hooks/useCodingPrStatus.ts b/hooks/useCodingPrStatus.ts index 0dc1bfe..fc67b69 100644 --- a/hooks/useCodingPrStatus.ts +++ b/hooks/useCodingPrStatus.ts @@ -20,7 +20,7 @@ export function useCodingPrStatus(tags: SlackTag[] | undefined) { const token = await getAccessToken(); if (!token) throw new Error("Not authenticated"); const res = await fetchCodingPrStatus(token, allPrUrls); - return new Set(res.pull_requests.filter((pr) => pr.merged).map((pr) => pr.url)); + return new Set(res.pull_requests.filter((pr) => pr.status === "merged").map((pr) => pr.url)); }, enabled: ready && authenticated && allPrUrls.length > 0, }); diff --git a/types/coding-agent.ts b/types/coding-agent.ts index 8ba70ab..6d3a179 100644 --- a/types/coding-agent.ts +++ b/types/coding-agent.ts @@ -21,7 +21,7 @@ export interface SlackTagsResponse { export interface CodingPrStatus { url: string; - merged: boolean; + status: "open" | "closed" | "merged"; } export interface CodingPrStatusResponse { From 177525d4bf6297e8f9ea9d9ae5dcac5e76db48ab Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 23 Mar 2026 22:16:29 -0500 Subject: [PATCH 3/4] refactor: rename SlackTagsColumns to createSlackTagsColumns, remove deprecated export Co-Authored-By: Claude Opus 4.6 (1M context) --- components/CodingAgentSlackTags/SlackTagsTable.tsx | 2 +- .../{SlackTagsColumns.tsx => createSlackTagsColumns.tsx} | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) rename components/CodingAgentSlackTags/{SlackTagsColumns.tsx => createSlackTagsColumns.tsx} (95%) diff --git a/components/CodingAgentSlackTags/SlackTagsTable.tsx b/components/CodingAgentSlackTags/SlackTagsTable.tsx index 79a73fd..286b886 100644 --- a/components/CodingAgentSlackTags/SlackTagsTable.tsx +++ b/components/CodingAgentSlackTags/SlackTagsTable.tsx @@ -16,7 +16,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { createSlackTagsColumns } from "./SlackTagsColumns"; +import { createSlackTagsColumns } from "./createSlackTagsColumns"; import type { SlackTag } from "@/types/coding-agent"; interface SlackTagsTableProps { diff --git a/components/CodingAgentSlackTags/SlackTagsColumns.tsx b/components/CodingAgentSlackTags/createSlackTagsColumns.tsx similarity index 95% rename from components/CodingAgentSlackTags/SlackTagsColumns.tsx rename to components/CodingAgentSlackTags/createSlackTagsColumns.tsx index 99ff55e..0387e03 100644 --- a/components/CodingAgentSlackTags/SlackTagsColumns.tsx +++ b/components/CodingAgentSlackTags/createSlackTagsColumns.tsx @@ -78,7 +78,4 @@ export function createSlackTagsColumns(mergedPrUrls?: Set): ColumnDef Date: Mon, 23 Mar 2026 22:16:58 -0500 Subject: [PATCH 4/4] feat: add tag-to-merged-PR conversion rate metric Shows percentage of Slack tags that resulted in a merged PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx b/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx index 8cdb396..7882f2e 100644 --- a/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx +++ b/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx @@ -56,6 +56,12 @@ export default function CodingAgentSlackTagsPage() { {totalMergedPrs}{" "} merged PRs + + + {data.total > 0 ? Math.round((totalMergedPrs / data.total) * 100) : 0}% + {" "} + conversion + )}