diff --git a/templates/analytics/actions/github-blame.ts b/templates/analytics/actions/github-blame.ts new file mode 100644 index 0000000000..46ff06167c --- /dev/null +++ b/templates/analytics/actions/github-blame.ts @@ -0,0 +1,41 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; +import { getFileBlame } from "../server/lib/github"; +import { + providerError, + requireActionCredentials, +} from "./_provider-action-utils"; + +export default defineAction({ + description: + "Get Git blame for a file path in a GitHub repository. Returns the most recent commit per blame range, who authored it, and any associated PR.", + schema: z.object({ + owner: z.string().describe("GitHub repository owner (org or user)"), + repo: z.string().describe("GitHub repository name"), + path: z.string().describe("File path within the repository"), + ref: z + .string() + .default("HEAD") + .describe("Branch, tag, or commit SHA (default: HEAD)"), + }), + readOnly: true, + run: async (args) => { + const credentials = await requireActionCredentials( + ["GITHUB_TOKEN"], + "GitHub", + ); + if (credentials.ok === false) return credentials.response; + + try { + const result = await getFileBlame( + args.owner, + args.repo, + args.path, + args.ref, + ); + return result; + } catch (err) { + return providerError(err); + } + }, +}); diff --git a/templates/analytics/actions/sentry.ts b/templates/analytics/actions/sentry.ts index 7e1e366c63..3f26b7545e 100644 --- a/templates/analytics/actions/sentry.ts +++ b/templates/analytics/actions/sentry.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { getIssueEvents, getOrganizationStats, + getCodeMappings, listOrganizations, listIssues, listProjects, @@ -17,7 +18,14 @@ export default defineAction({ "Query Sentry projects, frequent issues, issue events, and organization error stats. Use this for Sentry error questions; pass statsPeriod like 7d, 30d, or 1y.", schema: z.object({ mode: z - .enum(["organizations", "projects", "issues", "issue-events", "stats"]) + .enum([ + "organizations", + "projects", + "issues", + "issue-events", + "stats", + "code-mappings", + ]) .default("issues") .describe("What to query from Sentry"), orgSlug: z @@ -53,6 +61,11 @@ export default defineAction({ if (credentials.ok === false) return credentials.response; try { + if (args.mode === "code-mappings") { + const mappings = await getCodeMappings(args.orgSlug); + return { mappings }; + } + if (args.mode === "organizations") { const organizations = await listOrganizations(); return { organizations, total: organizations.length }; diff --git a/templates/analytics/actions/slack-messages.ts b/templates/analytics/actions/slack-messages.ts index ad4d82ea35..d3a6580687 100644 --- a/templates/analytics/actions/slack-messages.ts +++ b/templates/analytics/actions/slack-messages.ts @@ -153,7 +153,7 @@ export default defineAction({ if (args.mode === "search") { if (!args.query) return { error: "query is required" }; - const result = await searchMessages(workspace, args.query); + const result = await searchMessages(workspace, args.query, args.limit); const userIds = result.messages .map((message) => message.user) .filter((id): id is string => !!id); diff --git a/templates/analytics/app/lib/data-sources.ts b/templates/analytics/app/lib/data-sources.ts index be05286e27..228b19a221 100644 --- a/templates/analytics/app/lib/data-sources.ts +++ b/templates/analytics/app/lib/data-sources.ts @@ -548,7 +548,7 @@ export const dataSources: DataSource[] = [ description: "Error tracking and performance monitoring", category: "engineering", icon: IconBug, - envKeys: ["SENTRY_AUTH_TOKEN"], + envKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG_SLUG"], docsUrl: "https://docs.sentry.io/api/", walkthroughSteps: [ { @@ -566,6 +566,14 @@ export const dataSources: DataSource[] = [ inputPlaceholder: "sntrys_...", inputType: "password", }, + { + title: "Enter your Organization Slug", + description: + "Your Sentry organization slug (e.g. my-company). Find it in your Sentry URL: sentry.io/organizations//", + inputKey: "SENTRY_ORG_SLUG", + inputLabel: "Organization Slug", + inputPlaceholder: "my-company", + }, ], }, { @@ -643,7 +651,7 @@ export const dataSources: DataSource[] = [ description: "Channel messages and workspace search", category: "communication", icon: IconMessage, - envKeys: ["SLACK_BOT_TOKEN"], + envKeys: ["SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"], docsUrl: "https://api.slack.com/methods", walkthroughSteps: [ { @@ -666,6 +674,16 @@ export const dataSources: DataSource[] = [ inputPlaceholder: "xoxb-...", inputType: "password", }, + { + title: "Add a User Token for search (optional)", + description: + "Optional. Enables the Slack search.messages API for richer search results. Without it, the bot scans channels it's been invited to.", + inputKey: "SLACK_USER_TOKEN", + inputLabel: "User Token (for search)", + inputPlaceholder: "xoxp-...", + inputType: "password", + optional: true, + }, ], }, { diff --git a/templates/analytics/app/pages/DataSources.tsx b/templates/analytics/app/pages/DataSources.tsx index a2e5ef89ae..623f8accd2 100644 --- a/templates/analytics/app/pages/DataSources.tsx +++ b/templates/analytics/app/pages/DataSources.tsx @@ -535,10 +535,14 @@ function ConnectedView({ Configured ) : optional ? ( - - - Optional - + ) : ( diff --git a/templates/analytics/app/pages/adhoc/registry.ts b/templates/analytics/app/pages/adhoc/registry.ts index c97381cb7f..1b56150f6b 100644 --- a/templates/analytics/app/pages/adhoc/registry.ts +++ b/templates/analytics/app/pages/adhoc/registry.ts @@ -63,7 +63,17 @@ export interface DashboardMeta { // Add new dashboards here. Each entry needs a matching file in this directory. // REQUIRED FIELDS: id, name, author, lastUpdated -export const dashboards: DashboardMeta[] = []; +export const dashboards: DashboardMeta[] = [ + { + id: "sentry-errors", + name: "Sentry Error Intelligence", + description: + "Top 10 errors, escalating issues, related error groups, and project breakdown", + author: "Liam DeBeasi", + lastUpdated: "2025-05-12", + dateCreated: "2025-05-12", + }, +]; const HIDDEN_KEY = "hidden-dashboards"; @@ -143,6 +153,7 @@ export const dashboardComponents: Record< > = { explorer: lazy(() => import("./explorer")), "explorer-dashboard": lazy(() => import("./explorer-dashboard")), + "sentry-errors": lazy(() => import("./sentry-errors")), }; // Validate all dashboards at module load time diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/ErrorGroupsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/ErrorGroupsPanel.tsx new file mode 100644 index 0000000000..3182c197d8 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/ErrorGroupsPanel.tsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + IconChevronDown, + IconChevronRight, + IconExternalLink, +} from "@tabler/icons-react"; +import type { SentryIssue } from "./index"; + +interface ErrorGroup { + type: string; + issues: SentryIssue[]; + totalEvents: number; + totalUsers: number; + worstLevel: string; +} + +function levelOrder(level: string): number { + return { fatal: 0, error: 1, warning: 2, info: 3, debug: 4 }[level] ?? 5; +} + +function levelColor(level: string): string { + switch (level) { + case "fatal": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800"; + case "error": + return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400 border-orange-200 dark:border-orange-800"; + case "warning": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800"; + default: + return "bg-muted text-muted-foreground border-border"; + } +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +function timeAgo(date: string): string { + const ms = Date.now() - new Date(date).getTime(); + const mins = Math.floor(ms / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +function GroupRow({ group }: { group: ErrorGroup }) { + const [expanded, setExpanded] = useState(false); + return ( +
+ + + {expanded && ( +
+ {group.issues.map((issue) => ( + + + {issue.level} + +
+

+ {issue.metadata.value ?? issue.title} +

+

+ {issue.project.name} · {timeAgo(issue.lastSeen)} ·{" "} + {formatCount(parseInt(issue.count, 10))} events +

+
+ +
+ ))} +
+ )} +
+ ); +} + +interface ErrorGroupsPanelProps { + issues: SentryIssue[]; + isLoading: boolean; +} + +export function ErrorGroupsPanel({ issues, isLoading }: ErrorGroupsPanelProps) { + const groups = useMemo((): ErrorGroup[] => { + const map = new Map(); + for (const issue of issues) { + const type = issue.metadata.type ?? issue.type ?? "Unknown"; + const arr = map.get(type) ?? []; + arr.push(issue); + map.set(type, arr); + } + return [...map.entries()] + .map(([type, issueList]) => { + const totalEvents = issueList.reduce( + (s, i) => s + parseInt(i.count, 10), + 0, + ); + const totalUsers = issueList.reduce((s, i) => s + i.userCount, 0); + const worstLevel = issueList.reduce((worst, i) => { + return levelOrder(i.level) < levelOrder(worst) ? i.level : worst; + }, "debug"); + return { type, issues: issueList, totalEvents, totalUsers, worstLevel }; + }) + .sort((a, b) => b.totalEvents - a.totalEvents); + }, [issues]); + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ ); + } + + if (!groups.length) { + return ( +
+

No error groups found

+
+ ); + } + + return ( + +
+ {groups.map((group) => ( + + ))} +
+
+ ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/GitHubBlamePanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/GitHubBlamePanel.tsx new file mode 100644 index 0000000000..6cf340ded5 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/GitHubBlamePanel.tsx @@ -0,0 +1,348 @@ +import { useState, useEffect } from "react"; +import { useActionMutation, useActionQuery } from "@agent-native/core/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + IconBrandGithub, + IconGitCommit, + IconExternalLink, + IconAlertTriangle, + IconRefresh, + IconSettings, +} from "@tabler/icons-react"; +import type { SentryIssue } from "./index"; + +// ---- Types ------------------------------------------------------------------ + +interface BlameCommit { + oid: string; + abbreviatedOid: string; + message: string; + committedDate: string; + url: string; + author: { name: string; email: string; avatarUrl?: string }; + associatedPullRequests?: { + nodes: { number: number; title: string; url: string }[]; + }; +} + +interface BlameRange { + startingLine: number; + endingLine: number; + age: number; + commit: BlameCommit; +} + +interface BlameResult { + owner: string; + repo: string; + path: string; + ranges: BlameRange[]; + latestCommit: BlameCommit | null; + hotRange: BlameRange | null; + error?: string; +} + +interface CodeMapping { + projectSlug: string; + repoName: string; + stackRoot: string; + sourceRoot: string; +} + +// ---- Helpers ---------------------------------------------------------------- + +function parseCulpritPath(issue: SentryIssue): string { + // Prefer the explicit filename from metadata + if (issue.metadata.filename) return issue.metadata.filename; + // Culprit is usually "path/to/file.ts in functionName" + const culprit = issue.culprit ?? ""; + const inIdx = culprit.indexOf(" in "); + return inIdx !== -1 ? culprit.slice(0, inIdx).trim() : culprit.trim(); +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const days = Math.floor(diff / 86_400_000); + if (days === 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(months / 12)}y ago`; +} + +function getRepoFromStorage(): string { + return localStorage.getItem("sentry_github_repo") ?? ""; +} +function saveRepoToStorage(repo: string) { + localStorage.setItem("sentry_github_repo", repo); +} + +// ---- Main Component --------------------------------------------------------- + +interface GitHubBlamePanelProps { + issue: SentryIssue; +} + +export function GitHubBlamePanel({ issue }: GitHubBlamePanelProps) { + const filePath = parseCulpritPath(issue); + const [repo, setRepo] = useState(getRepoFromStorage); + const [repoInput, setRepoInput] = useState(getRepoFromStorage); + const [editingRepo, setEditingRepo] = useState(!getRepoFromStorage()); + const [result, setResult] = useState(null); + const [fetched, setFetched] = useState(false); + + const mutation = useActionMutation("github-blame"); + + // Auto-detect repo from Sentry code mappings + const mappingsQuery = useActionQuery("sentry", { mode: "code-mappings" }); + const mappingsData = mappingsQuery.data as { + mappings?: CodeMapping[]; + } | null; + + useEffect(() => { + if (repo || !mappingsData?.mappings) return; + const projectSlug = issue.project.slug; + // Try to find a mapping for this project, or fall back to the first mapping + const match = + mappingsData.mappings.find((m) => m.projectSlug === projectSlug) ?? + mappingsData.mappings[0]; + if (match?.repoName) { + setRepo(match.repoName); + setRepoInput(match.repoName); + saveRepoToStorage(match.repoName); + setEditingRepo(false); + } + }, [mappingsData, issue.project.slug, repo]); + + const dataError = + result?.error ?? + (mutation.error ? (mutation.error as Error).message : null); + + function runBlame(repoSlug: string) { + if (!repoSlug || !filePath) return; + const [owner, repoName] = repoSlug.split("/"); + if (!owner || !repoName) return; + setFetched(true); + mutation.mutate( + { owner, repo: repoName, path: filePath }, + { onSuccess: (data) => setResult(data as BlameResult) }, + ); + } + + function handleSaveRepo() { + const slug = repoInput.trim(); + setRepo(slug); + saveRepoToStorage(slug); + setEditingRepo(false); + runBlame(slug); + } + + const latestCommit = result?.latestCommit; + const pr = latestCommit?.associatedPullRequests?.nodes?.[0]; + + // Top-N unique authors weighted by recency + const authors = result?.ranges + ? Object.values( + result.ranges.reduce< + Record< + string, + { name: string; email: string; age: number; lines: number } + > + >((acc, r) => { + const key = r.commit.author.email; + if (!acc[key]) { + acc[key] = { + name: r.commit.author.name, + email: key, + age: r.age, + lines: 0, + }; + } + acc[key].lines += r.endingLine - r.startingLine + 1; + if (r.age < acc[key].age) acc[key].age = r.age; + return acc; + }, {}), + ).sort((a, b) => b.lines - a.lines) + : []; + + return ( +
+ {/* Header */} +
+
+ + GitHub blame + {filePath && ( + + · {filePath.split("/").pop()} + + )} +
+
+ {fetched && !editingRepo && ( + + )} + +
+
+ + {/* Repo config */} + {editingRepo && ( +
+ setRepoInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveRepo(); + if (e.key === "Escape") setEditingRepo(false); + }} + placeholder="owner/repo (e.g. acme/backend)" + className="h-8 text-xs font-mono" + autoFocus + /> + +
+ )} + + {/* No file path */} + {!filePath && !editingRepo && ( +

+ No file path found in this issue's culprit. +

+ )} + + {/* Prompt to configure repo */} + {!editingRepo && !repo && filePath && ( + + )} + + {/* Loading */} + {mutation.isPending && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ )} + + {/* Error */} + {!mutation.isPending && dataError && ( +
+ + {dataError} +
+ )} + + {/* Results */} + {!mutation.isPending && !dataError && latestCommit && ( +
+ {/* Latest commit card */} + +
+
+ + {pr ? ( + + PR #{pr.number}{" "} + + · {pr.title} + + + ) : ( + + {latestCommit.abbreviatedOid} + + )} +
+ +
+

+ {latestCommit.message.split("\n")[0]} +

+
+ + {latestCommit.author.name} + + · + {timeAgo(latestCommit.committedDate)} +
+
+ + {/* Author breakdown */} + {authors.length > 1 && ( +
+

+ File ownership +

+ {authors.slice(0, 4).map((a) => ( +
+
+ {a.name[0]} +
+ + {a.name} + + + {a.lines} lines · last {a.age}d ago + +
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/IssueSparkline.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/IssueSparkline.tsx new file mode 100644 index 0000000000..d1285d3119 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/IssueSparkline.tsx @@ -0,0 +1,46 @@ +interface IssueSparklineProps { + data: number[][]; + escalating?: boolean; +} + +export function IssueSparkline({ data, escalating }: IssueSparklineProps) { + if (!data.length) return null; + + const values = data.map(([, v]) => v); + const max = Math.max(...values, 1); + const height = 24; + const width = 120; + const barW = Math.max(2, Math.floor(width / values.length) - 1); + const gap = Math.max(1, Math.floor(width / values.length) - barW); + + return ( + + ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx new file mode 100644 index 0000000000..bba9ee05a0 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/SlackMentionsPanel.tsx @@ -0,0 +1,451 @@ +import { useState } from "react"; +import { useActionMutation, sendToAgentChat } from "@agent-native/core/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + IconBrandSlack, + IconSearch, + IconMessage, + IconAlertTriangle, + IconExternalLink, + IconRefresh, + IconX, + IconChevronDown, + IconChevronUp, + IconSparkles, +} from "@tabler/icons-react"; +import type { SentryIssue } from "./index"; + +// ---- Types ------------------------------------------------------------------ + +interface SlackMessage { + type: string; + user?: string; + username?: string; + text: string; + ts: string; + thread_ts?: string; + reply_count?: number; + channel?: { id: string; name: string } | string; + permalink?: string; + replies?: SlackMessage[]; +} + +interface SlackUser { + id: string; + name: string; + real_name: string; + profile: { display_name: string }; +} + +interface SlackSearchResult { + messages?: SlackMessage[]; + users?: Record; + total?: number; + error?: string; +} + +// ---- Helpers ---------------------------------------------------------------- + +function tsToDate(ts: string): string { + const ms = parseFloat(ts) * 1000; + const diff = Date.now() - ms; + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return new Date(ms).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function resolveUsername( + msg: SlackMessage, + users: Record, +): string { + if (msg.user && users[msg.user]) { + const u = users[msg.user]; + const resolved = u.profile.display_name || u.real_name || u.name; + // Reject raw user IDs (e.g. "U08ABC123") — fall through to username + if (resolved && !/^[UW][A-Z0-9]{6,}$/.test(resolved)) return resolved; + } + return msg.username || "Unknown"; +} + +function stripSlackFormatting(text: string): string { + return text + .replace(/<@[A-Z0-9]+>/g, "@user") + .replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1") + .replace(/<([^>|]+)\|([^>]+)>/g, "$2") + .replace(/<([^>]+)>/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/`([^`]+)`/g, "$1"); +} + +function buildSearchQuery(issue: SentryIssue): string { + const parts: string[] = []; + // Issue short ID (e.g. "AIR-LAYOUT-1176") is the most specific search term + if (issue.shortId) parts.push(issue.shortId); + // Permalink — matches messages where someone pasted the Sentry issue URL + if (issue.permalink) parts.push(issue.permalink); + // Error type is meaningful (e.g. "McpError", "TypeError") + if (issue.metadata.type) parts.push(issue.metadata.type); + // Truncated error value gives context + if (issue.metadata.value) { + const trimmed = issue.metadata.value.slice(0, 50).trim(); + if (trimmed && !parts.some((p) => trimmed.includes(p))) { + parts.push(trimmed); + } + } + if (parts.length === 0) parts.push(issue.title.slice(0, 80)); + return parts.join(" ").replace(/['"]/g, "").trim(); +} + +// ---- Sub-components --------------------------------------------------------- + +function MessageContent({ + name, + text, + ts, + channelName, + showLink, +}: { + name: string; + text: string; + ts: string; + channelName?: string; + showLink?: boolean; +}) { + return ( + <> +
+ {name[0] ?? "?"} +
+
+
+ {name} + + {tsToDate(ts)} + + {channelName && ( + + #{channelName} + + )} +
+

+ {text} +

+
+ {showLink && ( + + )} + + ); +} + +// ---- Main Component --------------------------------------------------------- + +interface SlackMentionsPanelProps { + issue: SentryIssue; +} + +export function SlackMentionsPanel({ issue }: SlackMentionsPanelProps) { + const defaultQuery = buildSearchQuery(issue); + const [query, setQuery] = useState(defaultQuery); + const [editingQuery, setEditingQuery] = useState(false); + const [result, setResult] = useState(null); + const [searched, setSearched] = useState(false); + const [expandedThreads, setExpandedThreads] = useState>( + new Set(), + ); + + const mutation = useActionMutation("slack-messages"); + + const messages = (result?.messages ?? []).slice(0, 10); + const users: Record = result?.users ?? {}; + const dataError = + result?.error ?? + (mutation.error ? (mutation.error as Error).message : null); + + function handleSearch() { + const q = query.trim(); + if (!q) return; + setEditingQuery(false); + setSearched(true); + mutation.mutate( + { mode: "search", query: q, limit: 10 }, + { onSuccess: (data) => setResult(data as SlackSearchResult) }, + ); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") handleSearch(); + if (e.key === "Escape") setEditingQuery(false); + } + + return ( +
+ {/* Header */} +
+
+ + Slack mentions + {searched && !mutation.isPending && !dataError && ( + + ({messages.length} result{messages.length !== 1 ? "s" : ""}) + + )} +
+ {searched && ( + + )} +
+ + {/* Search bar */} + {editingQuery ? ( +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="h-8 text-xs font-mono" + autoFocus + /> + + +
+ ) : ( + + )} + + {/* Results */} + {mutation.isPending ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + + +
+
+ ))} +
+ ) : dataError ? ( +
+ + {dataError} +
+ ) : searched && messages.length === 0 ? ( +
+
+ + No messages found in accessible channels +
+
+

+ Searching private channels? +

+

+ The bot can only scan public channels it's been added to. To + search private channels, add a Slack{" "} + User OAuth Token (xoxp-) in{" "} + + Data Sources → Slack + + . +

+

+ To get one: Slack app → OAuth & Permissions →{" "} + User Token Scopes → add{" "} + + search:read + {" "} + → Reinstall app → copy the User OAuth Token. +

+
+
+ ) : ( +
+ {messages.map((msg) => { + const name = resolveUsername(msg, users); + const text = stripSlackFormatting(msg.text); + const channelName = + typeof msg.channel === "string" ? msg.channel : msg.channel?.name; + const hasThread = msg.replies && msg.replies.length > 0; + const isExpanded = expandedThreads.has(msg.ts); + + return ( +
+ {/* Parent message */} +
+ {msg.permalink ? ( + + + + ) : ( +
+ +
+ )} +
+ + {/* Thread section */} + {hasThread && ( +
+ {/* Toggle */} +
+ + +
+ + {/* Replies */} + {isExpanded && ( +
+ {msg.replies!.map((reply) => { + const rName = resolveUsername(reply, users); + const rText = stripSlackFormatting(reply.text); + return ( +
+
+ {rName[0] ?? "?"} +
+
+ + {rName} + + + {tsToDate(reply.ts)} + +

+ {rText} +

+
+
+ ); + })} +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx new file mode 100644 index 0000000000..7c36f89b2f --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/index.tsx @@ -0,0 +1,915 @@ +import { useState, useMemo } from "react"; +import { useActionQuery, sendToAgentChat } from "@agent-native/core/client"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { + IconBug, + IconUsers, + IconAlertTriangle, + IconTrendingUp, + IconChevronRight, + IconChevronDown, + IconExternalLink, + IconRefresh, + IconClock, + IconCopy, + IconCircleCheck, + IconLink, + IconSparkles, + IconFilter, +} from "@tabler/icons-react"; +import { IssueSparkline } from "./IssueSparkline"; +import { ErrorGroupsPanel } from "./ErrorGroupsPanel"; +import { SlackMentionsPanel } from "./SlackMentionsPanel"; +import { + classifyIssue, + classificationLabel, + classificationColor, + type IssueClass, +} from "./issueClassifier"; + +// ---- Types ------------------------------------------------------------------ + +export interface SentryIssue { + id: string; + shortId: string; + title: string; + culprit: string; + permalink: string; + level: "fatal" | "error" | "warning" | "info" | "debug"; + status: string; + platform: string; + project: { id: string; name: string; slug: string }; + type: string; + metadata: { + type?: string; + value?: string; + filename?: string; + function?: string; + }; + count: string; + userCount: number; + firstSeen: string; + lastSeen: string; + stats?: Record; +} + +interface SentryProject { + id: string; + slug: string; + name: string; + platform: string | null; +} + +type StatsPeriod = "24h" | "7d" | "14d" | "30d"; + +// ---- Helpers ---------------------------------------------------------------- + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +function timeAgo(date: string): string { + const ms = Date.now() - new Date(date).getTime(); + const mins = Math.floor(ms / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +function isEscalating(issue: SentryIssue): boolean { + const stats = issue.stats?.["24h"]; + if (!stats || stats.length < 4) return false; + const recent = stats.slice(-4).reduce((s, [, v]) => s + v, 0); + const earlier = stats.slice(-8, -4).reduce((s, [, v]) => s + v, 0); + return recent > earlier * 1.5 && recent > 0; +} + +function levelColor(level: string): string { + switch (level) { + case "fatal": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800"; + case "error": + return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400 border-orange-200 dark:border-orange-800"; + case "warning": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800"; + default: + return "bg-muted text-muted-foreground border-border"; + } +} + +// ---- Sub-components --------------------------------------------------------- + +function StatCard({ + label, + value, + icon: Icon, + accent, + isLoading, +}: { + label: string; + value: string | number; + icon: React.ComponentType<{ className?: string }>; + accent?: string; + isLoading?: boolean; +}) { + return ( + + +
+
+

{label}

+ {isLoading ? ( + + ) : ( +

+ {value} +

+ )} +
+
+ +
+
+
+
+ ); +} + +function IssueRow({ + issue, + rank, + isSelected, + escalating, + onSelect, +}: { + issue: SentryIssue; + rank: number; + isSelected: boolean; + escalating: boolean; + onSelect: () => void; +}) { + const count = parseInt(issue.count, 10); + const { classification, reason } = classifyIssue(issue); + return ( + + ); +} + +function IssueDetail({ issue }: { issue: SentryIssue }) { + const count = parseInt(issue.count, 10); + return ( +
+
+
+

Total events

+

{formatCount(count)}

+
+
+

Affected users

+

{issue.userCount}

+
+
+

First seen

+

+ {timeAgo(issue.firstSeen)} +

+
+
+

Last seen

+

+ {timeAgo(issue.lastSeen)} +

+
+
+ + {issue.culprit && ( +
+

Culprit

+ + {issue.culprit} + +
+ )} + + {issue.metadata.filename && ( +
+

Location

+ + {issue.metadata.filename} + {issue.metadata.function && ` · ${issue.metadata.function}`} + +
+ )} + + + + +
+ ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+
+ +
+

{message}

+
+ ); +} + +function ErrorState({ message }: { message: string }) { + return ( +
+
+ +
+

Could not load Sentry data

+

{message}

+

+ Check Settings → Data sources and + ensure SENTRY_AUTH_TOKEN is + configured. +

+
+ ); +} + +function IssueListSkeleton() { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ +
+ + + +
+ +
+ ))} +
+ ); +} + +// ---- Main Component --------------------------------------------------------- + +export default function SentryErrorsDashboard() { + const [period, setPeriod] = useState("7d"); + const [selectedIssueId, setSelectedIssueId] = useState(null); + const [activeTab, setActiveTab] = useState<"top10" | "escalating" | "groups">( + "top10", + ); + const [selectedProjects, setSelectedProjects] = useState>( + new Set(), + ); + + const issuesQuery = useActionQuery("sentry", { + mode: "issues", + statsPeriod: period, + query: "is:unresolved", + }); + + const statsQuery = useActionQuery("sentry", { + mode: "stats", + statsPeriod: period, + }); + + const rawData = issuesQuery.data as + | { issues?: SentryIssue[] } + | { error: string; message?: string } + | null; + + // Action returns 200 with { error: "missing_api_key", message: "..." } when + // credentials aren't configured — treat it the same as a fetch error. + const dataError: string | null = useMemo(() => { + if (issuesQuery.error) return (issuesQuery.error as Error).message; + if (rawData && "error" in rawData) { + return ( + (rawData as { message?: string }).message ?? + String((rawData as { error: string }).error) + ); + } + return null; + }, [issuesQuery.error, rawData]); + + const allIssues: SentryIssue[] = useMemo( + () => (rawData && "issues" in rawData ? (rawData.issues ?? []) : []), + [rawData], + ); + + const allProjects = useMemo(() => { + const seen = new Map(); + for (const issue of allIssues) { + if (!seen.has(issue.project.slug)) { + seen.set(issue.project.slug, issue.project.name); + } + } + return Array.from(seen.entries()).map(([slug, name]) => ({ slug, name })); + }, [allIssues]); + + const issues: SentryIssue[] = useMemo( + () => + selectedProjects.size === 0 + ? allIssues + : allIssues.filter((i) => selectedProjects.has(i.project.slug)), + [allIssues, selectedProjects], + ); + + function toggleProject(slug: string) { + setSelectedProjects((prev) => { + const next = new Set(prev); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + setSelectedIssueId(null); + } + + const top10 = useMemo( + () => + [...issues] + .sort((a, b) => parseInt(b.count, 10) - parseInt(a.count, 10)) + .slice(0, 10), + [issues], + ); + + const escalating = useMemo( + () => issues.filter(isEscalating).slice(0, 10), + [issues], + ); + + const totalEvents = useMemo( + () => issues.reduce((s, i) => s + parseInt(i.count, 10), 0), + [issues], + ); + + const totalUsers = useMemo( + () => issues.reduce((s, i) => s + i.userCount, 0), + [issues], + ); + + const displayedIssues = + activeTab === "top10" + ? top10 + : activeTab === "escalating" + ? escalating + : []; + + const isLoading = issuesQuery.isLoading; + const error = dataError; + + const classifications = useMemo( + () => issues.map((i) => ({ id: i.id, ...classifyIssue(i) })), + [issues], + ); + + const actionableCount = classifications.filter( + (c) => c.classification === "actionable", + ).length; + const noiseCount = classifications.filter( + (c) => c.classification === "noise" || c.classification === "user-error", + ).length; + + function handleClassifyWithAI() { + const sample = top10 + .map((issue, i) => { + const { classification, reason } = classifyIssue(issue); + return `${i + 1}. [${issue.shortId}] ${issue.metadata.type ?? issue.title}${ + issue.metadata.value ? `: ${issue.metadata.value.slice(0, 80)}` : "" + } — ${issue.count} events, ${issue.userCount} users, culprit: ${issue.culprit} (heuristic: ${classification}, ${reason})`; + }) + .join("\n"); + sendToAgentChat({ + message: `Please analyze these top Sentry errors and classify each one as: "actionable" (real bug we should fix), "user-error" (expected from bad user input/auth), or "noise" (transient/third-party/not worth fixing). For each, briefly explain why and suggest what to do.\n\n${sample}`, + submit: true, + }); + } + + function toggleIssue(id: string) { + setSelectedIssueId((prev) => (prev === id ? null : id)); + } + + return ( +
+ {/* Header */} +
+
+

+ + Sentry Error Intelligence +

+

+ Top errors, escalating issues, and related error groups +

+
+
+ setPeriod(v as StatsPeriod)} + > + + + 24h + + + 7d + + + 14d + + + 30d + + + + +
+
+ + {/* Stat Cards */} +
+ + + + 0 + ? "text-rose-600 dark:text-rose-400" + : undefined + } + isLoading={isLoading} + /> +
+ + {/* Project Filter */} + {!isLoading && allProjects.length > 1 && ( +
+ + Projects: + + {allProjects.map(({ slug, name }) => { + const active = selectedProjects.has(slug); + return ( + + ); + })} + {selectedProjects.size > 0 && ( + + )} +
+ )} + + {/* Classification Summary */} + {!isLoading && !error && issues.length > 0 && ( +
+
+ + Heuristic classification: + + + + + {actionableCount} actionable + + + + + + {noiseCount} noise / user error + + + + + + {issues.length - actionableCount - noiseCount} unclassified + + +
+ +
+ )} + + {/* Main Content */} + {error ? ( + + + + ) : ( +
+ {/* Issue List */} + + + + setActiveTab(v as "top10" | "escalating" | "groups") + } + > + + + Top 10 Errors + + + + Escalating + {escalating.length > 0 && !isLoading && ( + + {escalating.length} + + )} + + + Error Groups + + + + + +
+ {activeTab === "groups" ? ( + + ) : isLoading ? ( + + ) : displayedIssues.length === 0 ? ( + + ) : ( + +
+ {displayedIssues.map((issue, i) => { + const escalating_ = isEscalating(issue); + return ( +
+ toggleIssue(issue.id)} + /> + {selectedIssueId === issue.id && ( + + )} +
+ ); + })} +
+
+ )} +
+
+ + {/* Right Panel */} +
+ {/* Related Issues panel */} + i.id === selectedIssueId) ?? null) + : null + } + isLoading={isLoading} + /> + + {/* Project Breakdown */} + +
+
+ )} +
+ ); +} + +// ---- Related Issues Panel --------------------------------------------------- + +function RelatedIssuesPanel({ + issues, + selectedIssue, + isLoading, +}: { + issues: SentryIssue[]; + selectedIssue: SentryIssue | null; + isLoading: boolean; +}) { + const related = useMemo(() => { + if (!selectedIssue) return []; + const selectedType = selectedIssue.metadata.type ?? ""; + const selectedCulprit = selectedIssue.culprit; + return issues + .filter( + (i) => + i.id !== selectedIssue.id && + (i.culprit === selectedCulprit || + (selectedType && i.metadata.type === selectedType)), + ) + .slice(0, 5); + }, [issues, selectedIssue]); + + return ( + + + + + Related Issues + + + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : !selectedIssue ? ( +

+ Select an issue to see related errors +

+ ) : related.length === 0 ? ( +

+ No related issues found +

+ ) : ( + + )} +
+
+ ); +} + +// ---- Project Breakdown Panel ------------------------------------------------ + +function ProjectBreakdownPanel({ + issues, + isLoading, +}: { + issues: SentryIssue[]; + isLoading: boolean; +}) { + const projectCounts = useMemo(() => { + const map = new Map< + string, + { name: string; count: number; events: number } + >(); + for (const issue of issues) { + const key = issue.project.slug; + const existing = map.get(key) ?? { + name: issue.project.name, + count: 0, + events: 0, + }; + map.set(key, { + name: issue.project.name, + count: existing.count + 1, + events: existing.events + parseInt(issue.count, 10), + }); + } + return [...map.values()].sort((a, b) => b.events - a.events).slice(0, 6); + }, [issues]); + + const maxEvents = projectCounts[0]?.events ?? 1; + + return ( + + + + + By Project + + + + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : projectCounts.length === 0 ? ( +

+ No projects +

+ ) : ( + projectCounts.map((proj) => ( +
+
+ + {proj.name} + + + {formatCount(proj.events)} + +
+
+
+
+
+ )) + )} + + + ); +} diff --git a/templates/analytics/app/pages/adhoc/sentry-errors/issueClassifier.ts b/templates/analytics/app/pages/adhoc/sentry-errors/issueClassifier.ts new file mode 100644 index 0000000000..3440d68912 --- /dev/null +++ b/templates/analytics/app/pages/adhoc/sentry-errors/issueClassifier.ts @@ -0,0 +1,159 @@ +import type { SentryIssue } from "./index"; + +export type IssueClass = "actionable" | "noise" | "user-error" | "unknown"; + +export interface ClassificationResult { + classification: IssueClass; + reason: string; +} + +// Patterns strongly indicating non-actionable / noise +const NOISE_TYPE_PATTERNS = [ + /^ChunkLoadError/i, + /^ResizeObserver loop/i, + /^Script error/i, + /^Non-Error (promise rejection|exception)/i, + /^UnhandledRejection.*undefined/i, + /cancelled/i, + /aborted/i, +]; + +const NOISE_VALUE_PATTERNS = [ + /load failed/i, + /failed to fetch/i, + /networkerror/i, + /network request failed/i, + /the internet connection appears to be offline/i, + /cancelled/i, + /aborted/i, + /timeout/i, +]; + +// Patterns indicating user-caused errors (auth, validation, not-found) +const USER_ERROR_TYPE_PATTERNS = [ + /ValidationError/i, + /ZodError/i, + /AuthError/i, + /UnauthorizedError/i, + /ForbiddenError/i, + /NotFoundError/i, +]; + +const USER_ERROR_VALUE_PATTERNS = [ + /401/, + /403/, + /404/, + /unauthorized/i, + /forbidden/i, + /not found/i, + /invalid (token|credentials|password|email)/i, + /user.*not.*found/i, + /permission denied/i, + /not_allowed_token_type/i, + /invalid_auth/i, +]; + +// Third-party culprit patterns — lower signal, only marks as noise when combined +const THIRD_PARTY_CULPRIT_PATTERNS = [ + /node_modules/, + /cdn\./, + /googleapis\.com/, + /cloudflare/, + /amazonaws\.com/, +]; + +export function classifyIssue(issue: SentryIssue): ClassificationResult { + const type = (issue.metadata.type ?? issue.type ?? "").toLowerCase(); + const value = (issue.metadata.value ?? issue.title ?? "").toLowerCase(); + const culprit = (issue.culprit ?? "").toLowerCase(); + const title = (issue.title ?? "").toLowerCase(); + + // 1. Noise: known transient / browser / network patterns + for (const pat of NOISE_TYPE_PATTERNS) { + if (pat.test(issue.metadata.type ?? issue.type ?? issue.title)) { + return { + classification: "noise", + reason: `Error type "${issue.metadata.type ?? issue.type}" is typically non-actionable browser/network noise`, + }; + } + } + for (const pat of NOISE_VALUE_PATTERNS) { + if (pat.test(value) || pat.test(title)) { + return { + classification: "noise", + reason: + "Error message matches known transient network/browser noise patterns", + }; + } + } + + // 2. User error: auth, validation, expected HTTP errors + for (const pat of USER_ERROR_TYPE_PATTERNS) { + if (pat.test(issue.metadata.type ?? issue.type ?? "")) { + return { + classification: "user-error", + reason: `Error type "${issue.metadata.type ?? issue.type}" is typically caused by invalid user input or auth`, + }; + } + } + for (const pat of USER_ERROR_VALUE_PATTERNS) { + if (pat.test(value) || pat.test(title)) { + return { + classification: "user-error", + reason: + "Error message matches expected user-caused patterns (auth, validation, not-found)", + }; + } + } + + // 3. Third-party culprit with no affected users → likely noise + const isThirdParty = THIRD_PARTY_CULPRIT_PATTERNS.some((p) => + p.test(culprit), + ); + if (isThirdParty && issue.userCount === 0) { + return { + classification: "noise", + reason: + "Error originates in a third-party dependency and affects no users", + }; + } + + // 4. High user count + first-party → likely actionable + if (issue.userCount > 5 && !isThirdParty) { + return { + classification: "actionable", + reason: `Affects ${issue.userCount} users and originates in first-party code`, + }; + } + + return { + classification: "unknown", + reason: "Could not determine actionability from available metadata", + }; +} + +export function classificationLabel(c: IssueClass): string { + switch (c) { + case "actionable": + return "Actionable"; + case "noise": + return "Noise"; + case "user-error": + return "User error"; + case "unknown": + return "Unclassified"; + } +} + +export function classificationColor(c: IssueClass): string { + switch (c) { + case "actionable": + return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800"; + case "noise": + return "bg-slate-100 text-slate-500 dark:bg-slate-800/50 dark:text-slate-400 border-slate-200 dark:border-slate-700"; + case "user-error": + return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800"; + case "unknown": + return "bg-muted text-muted-foreground border-border"; + } +} diff --git a/templates/analytics/server/lib/credential-keys.ts b/templates/analytics/server/lib/credential-keys.ts index 8524e7f48b..938b878b2c 100644 --- a/templates/analytics/server/lib/credential-keys.ts +++ b/templates/analytics/server/lib/credential-keys.ts @@ -87,6 +87,11 @@ export const credentialKeys: CredentialKeyConfig[] = [ label: "Slack Bot Token (secondary)", required: false, }, + { + key: "SLACK_USER_TOKEN", + label: "Slack User Token", + required: false, + }, // Notion { key: "NOTION_API_KEY", label: "Notion", required: false }, // Twitter/X @@ -240,7 +245,7 @@ export const credentialProviderConfigs: CredentialProviderConfig[] = [ provider: "slack", label: "Slack", requiredKeys: ["SLACK_BOT_TOKEN"], - optionalKeys: ["SLACK_BOT_TOKEN_2"], + optionalKeys: ["SLACK_BOT_TOKEN_2", "SLACK_USER_TOKEN"], }, { provider: "notion", @@ -294,7 +299,7 @@ const credentialAliases: Record = { posthog: ["POSTHOG_API_KEY", "POSTHOG_PROJECT_ID"], pylon: ["PYLON_API_KEY"], sentry: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG_SLUG", "SENTRY_SERVER_TOKEN"], - slack: ["SLACK_BOT_TOKEN", "SLACK_BOT_TOKEN_2"], + slack: ["SLACK_BOT_TOKEN", "SLACK_BOT_TOKEN_2", "SLACK_USER_TOKEN"], stripe: ["STRIPE_SECRET_KEY"], twitter: ["TWITTER_BEARER_TOKEN"], x: ["TWITTER_BEARER_TOKEN"], diff --git a/templates/analytics/server/lib/github.ts b/templates/analytics/server/lib/github.ts index bd6b401a20..da5f8da205 100644 --- a/templates/analytics/server/lib/github.ts +++ b/templates/analytics/server/lib/github.ts @@ -420,3 +420,119 @@ export async function searchOrgPRs(opts: { return searchPRs({ query: q, type: "pr", limit }); } + +// ─── File blame (last commit per line range) ────────────────────────────────── + +export interface BlameRange { + startingLine: number; + endingLine: number; + age: number; // days since last commit + commit: { + oid: string; + abbreviatedOid: string; + message: string; + committedDate: string; + url: string; + author: { + name: string; + email: string; + avatarUrl?: string; + }; + associatedPullRequests?: { + nodes: { number: number; title: string; url: string }[]; + }; + }; +} + +export interface FileBlameResult { + owner: string; + repo: string; + path: string; + ref: string; + ranges: BlameRange[]; + /** The single most recent commit touching this file */ + latestCommit: BlameRange["commit"] | null; + /** The most recent blame range (covers the most recently touched lines) */ + hotRange: BlameRange | null; +} + +export async function getFileBlame( + owner: string, + repo: string, + path: string, + ref = "HEAD", +): Promise { + const query = ` + query FileBlame($owner: String!, $repo: String!, $path: String!, $ref: String!) { + repository(owner: $owner, name: $repo) { + object(expression: $ref) { + ... on Commit { + blame(path: $path) { + ranges { + startingLine + endingLine + commit { + oid + abbreviatedOid + message + committedDate + url + author { + name + email + avatarUrl + } + associatedPullRequests(first: 1) { + nodes { + number + title + url + } + } + } + } + } + } + } + } + } + `; + + const data = await graphql<{ + repository: { + object: { + blame: { + ranges: { + startingLine: number; + endingLine: number; + commit: BlameRange["commit"]; + }[]; + }; + } | null; + } | null; + }>(query, { owner, repo, path, ref }); + + const rawRanges = data?.repository?.object?.blame?.ranges ?? []; + + const ranges: BlameRange[] = rawRanges.map((r) => ({ + ...r, + age: Math.floor( + (Date.now() - new Date(r.commit.committedDate).getTime()) / + (1000 * 60 * 60 * 24), + ), + })); + + // Most recently committed range + const hotRange = + ranges.length > 0 + ? ranges.reduce((a, b) => + new Date(a.commit.committedDate) > new Date(b.commit.committedDate) + ? a + : b, + ) + : null; + + const latestCommit = hotRange?.commit ?? null; + + return { owner, repo, path, ref, ranges, latestCommit, hotRange }; +} diff --git a/templates/analytics/server/lib/sentry.ts b/templates/analytics/server/lib/sentry.ts index 46b82bf743..86e0eacc89 100644 --- a/templates/analytics/server/lib/sentry.ts +++ b/templates/analytics/server/lib/sentry.ts @@ -199,3 +199,37 @@ export async function getOrganizationStats( `/organizations/${org}/stats_v2/?${params.toString()}`, ); } + +export interface SentryCodeMapping { + id: string; + projectSlug: string; + repoName: string; // "owner/repo" + sourceRoot: string; + stackRoot: string; + defaultBranch: string; +} + +export async function getCodeMappings( + orgSlug?: string, +): Promise { + const org = await getOrgSlug(orgSlug); + const raw = await apiGet< + { + id: string; + project: { slug: string }; + repoName?: string; + repository?: { name: string }; + sourceRoot: string; + stackRoot: string; + defaultBranch: string; + }[] + >(`/organizations/${org}/code-mappings/`); + return raw.map((m) => ({ + id: m.id, + projectSlug: m.project?.slug ?? "", + repoName: m.repoName ?? m.repository?.name ?? "", + sourceRoot: m.sourceRoot ?? "", + stackRoot: m.stackRoot ?? "", + defaultBranch: m.defaultBranch ?? "main", + })); +} diff --git a/templates/analytics/server/lib/slack.ts b/templates/analytics/server/lib/slack.ts index 08d88f87ca..c667dd51d2 100644 --- a/templates/analytics/server/lib/slack.ts +++ b/templates/analytics/server/lib/slack.ts @@ -102,6 +102,9 @@ export interface SlackMessage { reactions?: { name: string; count: number }[]; files?: { name: string; mimetype: string; url_private: string }[]; icons?: { image_48?: string; image_72?: string }; + channel?: { id: string; name: string } | string; + permalink?: string; + replies?: SlackMessage[]; } export interface SlackBotInfo { @@ -271,30 +274,194 @@ export async function getChannelHistory( } } +async function fetchFullThread( + workspace: Workspace, + channelId: string, + threadTs: string, + limit = 20, +): Promise { + try { + const data = await slackApi<{ messages: SlackMessage[] }>( + workspace, + "conversations.replies", + { channel: channelId, ts: threadTs, limit: String(limit) }, + false, + ); + return data.messages ?? []; + } catch { + return []; + } +} + +async function enrichWithThreads( + workspace: Workspace, + messages: SlackMessage[], +): Promise { + return Promise.all( + messages.map(async (msg) => { + const channelId = + typeof msg.channel === "string" ? msg.channel : msg.channel?.id; + if (!channelId) return msg; + + // Determine the thread root timestamp: + // - If msg is a reply (thread_ts differs from ts), the root is thread_ts + // - If msg is a parent with replies (reply_count > 0), the root is ts + const isReply = msg.thread_ts && msg.thread_ts !== msg.ts; + const hasReplies = (msg.reply_count ?? 0) > 0; + + if (!isReply && !hasReplies) return msg; + + const threadRootTs = isReply ? msg.thread_ts! : msg.ts; + const thread = await fetchFullThread(workspace, channelId, threadRootTs); + // thread[0] is the parent message; the rest are replies + const replies = thread.slice(1); + return { ...msg, replies }; + }), + ); +} + export async function searchMessages( workspace: Workspace, query: string, count = 50, ): Promise<{ messages: SlackMessage[]; total: number }> { - // search.messages requires a user token, but we'll try with bot token - // If it fails, we'll fall back to channel history filtering - const data = await slackApi<{ - messages: { matches: SlackMessage[]; total: number }; - }>( - workspace, - "search.messages", - { + const botEnvKey = + workspace === "secondary" ? "SLACK_BOT_TOKEN_2" : "SLACK_BOT_TOKEN"; + const ctx = requireRequestCredentialContext(botEnvKey); + + // Prefer a user token (xoxp-) for search.messages if one was configured. + const userToken = await resolveCredential("SLACK_USER_TOKEN", ctx); + if (userToken) { + const params = new URLSearchParams({ query, count: String(Math.min(count, 100)), sort: "timestamp", sort_dir: "desc", - }, - false, + }); + const res = await fetch(`https://slack.com/api/search.messages?${params}`, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (!res.ok) throw new Error(`Slack API error ${res.status}`); + const json = (await res.json()) as { + ok: boolean; + error?: string; + messages?: { matches: SlackMessage[]; total: number }; + }; + if (json.ok) { + const matches = json.messages?.matches || []; + const enriched = await enrichWithThreads(workspace, matches); + return { messages: enriched, total: json.messages?.total || 0 }; + } + // fall through to bot-token scan on token errors + } + + // Fallback: scan recent messages from accessible channels using the bot token. + // search.messages requires a user token; this covers the common case where only + // a bot token is configured. + const terms = query + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2); + + console.log("[slack-search] query:", query); + console.log("[slack-search] terms:", terms); + + function fuzzyMatch(text: string, term: string): boolean { + if (text.includes(term)) return true; + // URLs must match exactly (trailing slash variations are already normalised) + if (term.startsWith("http")) return false; + // For Sentry short IDs like BRIDGE-TM-1234, also try just the numeric suffix + const numericSuffix = term.match(/\d{4,}$/)?.[0]; + if (numericSuffix && text.includes(numericSuffix)) return true; + // Prefix fuzzy: if the term is 6+ chars, match on the first 70% of it + if (term.length >= 6) { + const prefix = term.slice(0, Math.floor(term.length * 0.7)); + if (text.includes(prefix)) return true; + } + return false; + } + + const channelsData = await slackApi<{ + channels: { id: string; name: string }[]; + }>(workspace, "conversations.list", { + types: "public_channel", + exclude_archived: "true", + limit: "200", + }); + + const allChannels = channelsData.channels ?? []; + console.log( + "[slack-search] total channels visible to bot:", + allChannels.length, + allChannels.map((c) => c.name), ); - return { - messages: data.messages?.matches || [], - total: data.messages?.total || 0, - }; + + const channels = allChannels.slice(0, 20); + const oldest = String( + Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000), + ); + + // Fetch team domain so we can build message permalinks + let teamDomain = ""; + try { + const teamData = await slackApi<{ team: { domain: string } }>( + workspace, + "team.info", + ); + teamDomain = teamData.team?.domain ?? ""; + } catch { + // non-fatal + } + + const matches: SlackMessage[] = []; + + await Promise.all( + channels.map(async (ch) => { + try { + const histData = await slackApi<{ messages: SlackMessage[] }>( + workspace, + "conversations.history", + { channel: ch.id, limit: "200", oldest }, + false, + ); + const msgs = histData.messages ?? []; + console.log( + `[slack-search] #${ch.name} (${ch.id}): ${msgs.length} messages fetched`, + ); + for (const msg of msgs) { + const text = msg.text?.toLowerCase() ?? ""; + const matched = + terms.length === 0 || terms.some((t) => fuzzyMatch(text, t)); + if (matched) { + console.log( + `[slack-search] MATCH in #${ch.name}: "${msg.text?.slice(0, 80)}"`, + ); + // Build a Slack deep-link permalink for bot-scanned messages + const tsSlug = msg.ts.replace(".", ""); + const permalink = teamDomain + ? `https://${teamDomain}.slack.com/archives/${ch.id}/p${tsSlug}` + : undefined; + matches.push({ + ...msg, + channel: { id: ch.id, name: ch.name }, + permalink, + } as SlackMessage); + } + } + } catch (err) { + console.log( + `[slack-search] #${ch.name} error:`, + (err as Error).message, + ); + } + }), + ); + + console.log("[slack-search] total matches:", matches.length); + matches.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts)); + const limited = matches.slice(0, count); + const enriched = await enrichWithThreads(workspace, limited); + return { messages: enriched, total: matches.length }; } export async function getUserInfo( @@ -356,12 +523,8 @@ export async function resolveUsers( try { results[id] = await getUserInfo(workspace, id); } catch { - results[id] = { - id, - name: id, - real_name: id, - profile: { display_name: id, image_48: "", image_72: "" }, - }; + // Don't create a fake entry — leave it absent so resolveUsername + // falls back to msg.username (populated by search.messages API). } }), );