/** * Fetch Bugsink issue details as JSON, so an LLM can dig into a live error. * The command line lives in `bugs.ts`. */ import { parseArgs } from "@std/cli/parse-args"; import * as v from "valibot"; import { sortedByString } from "#fp-strings"; import { fetchText } from "#scripts/fetch-text.ts"; import { parseJsonWith } from "#scripts/read-json.ts"; import type { ScriptIo } from "#scripts/script-runner.ts"; import { isLocalHttpHost } from "#shared/local-host.ts"; import { parseOrThrow } from "#shared/validation/parse.ts"; export interface BugsConfig { apiKey: string; /** Bugsink site origin, without a trailing slash. */ baseUrl: string; /** The URL origin every request, pagination included, stays inside. */ origin: string; } const firstSetEnv = ( getEnv: (key: string) => string | undefined, keys: string[], ): string | undefined => keys.map(getEnv).find((value) => value !== undefined && value !== ""); export const bugsConfig = ( getEnv: (key: string) => string | undefined, ): BugsConfig => { const baseUrl = firstSetEnv(getEnv, ["SENTRY_BASE_URL", "SENTRY_BASE"]); if (baseUrl === undefined) { throw new Error( "Set SENTRY_BASE_URL in .env, for example https://bugs.chobble.com", ); } const apiKey = firstSetEnv(getEnv, ["SENTRY_API_KEY"]); if (apiKey === undefined) { throw new Error( "Set SENTRY_API_KEY in .env. Create the token in Bugsink under Tokens.", ); } let parsed: URL; try { parsed = new URL(baseUrl); } catch { throw new Error(`SENTRY_BASE_URL is not a URL: ${baseUrl}`); } if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { throw new Error(`SENTRY_BASE_URL must be an http or https URL: ${baseUrl}`); } // The API key rides every request as a Bearer token, so cleartext http is // for local networks only, the same rule as UPTIME_KUMA_URL. if (parsed.protocol === "http:" && !isLocalHttpHost(parsed.hostname)) { throw new Error( `SENTRY_BASE_URL must use HTTPS outside a local network: ${baseUrl}`, ); } if (parsed.search || parsed.hash) { throw new Error( `SENTRY_BASE_URL must not contain a query or fragment: ${baseUrl}`, ); } return { apiKey, baseUrl: baseUrl.replace(/\/+$/, ""), origin: parsed.origin, }; }; const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; /** Accept an issue page URL, a bare issue UUID, or a friendly issue id. */ export const parseIssueRef = (ref: string): string => { const uuid = UUID_PATTERN.exec(ref)?.[0]; if (uuid !== undefined) return uuid; if (/^https?:\/\//i.test(ref)) { throw new Error( `No issue id found in ${ref}. Pass the issue page URL or an issue id.`, ); } return ref; }; const parseJsonBody = (text: string, url: string): unknown => parseJsonWith((error) => { throw new Error( `Bugsink sent non-JSON for ${url}: ${(error as Error).message}`, ); })(text); const apiGet = async (config: BugsConfig, url: string): Promise => { const result = await fetchText(url, { headers: { Accept: "application/json", Authorization: `Bearer ${config.apiKey}`, }, }); if (!result.ok) { throw new Error( `Bugsink returned ${result.status} for ${url}: ${result.text.slice(0, 200)}`, ); } return parseJsonBody(result.text, url); }; /** Check the fields the tool uses, and hand back the full raw object. */ const getJson = async ( config: BugsConfig, url: string, schema: S, ): Promise> => { const raw = await apiGet(config, url); parseOrThrow( schema, raw, ([first]) => new Error( `Bugsink sent an unexpected shape for ${url}: ${first.message} at ${ v.getDotPath(first) ?? "root" }`, ), ); return raw as v.InferOutput; }; const IssueSchema = v.object({ calculated_type: v.string(), calculated_value: v.nullable(v.string()), digested_event_count: v.number(), first_seen: v.string(), friendly_id: v.string(), id: v.string(), is_muted: v.boolean(), is_resolved: v.boolean(), last_seen: v.string(), project: v.number(), stored_event_count: v.number(), }); type IssueRecord = v.InferOutput; /** The events list omits the payload; only the id is needed to fetch it. */ const EventListItemSchema = v.object({ id: v.string() }); const EventSchema = v.object({ data: v.nullable(v.unknown()), id: v.string(), issue: v.string(), stacktrace_md: v.nullable(v.string()), }); const ProjectSchema = v.object({ id: v.number(), name: v.string(), slug: v.string(), }); type ProjectRecord = v.InferOutput; const listPageSchema = (item: S) => v.object({ next: v.nullable(v.string()), results: v.array(item) }); /** Pin a pagination URL to the configured origin and the canonical API, so a * tampered response cannot point the Bearer token somewhere else. */ const pinnedNextUrl = (config: BugsConfig, next: string): string => { let parsed: URL; try { parsed = new URL(next); } catch { throw new Error(`Bugsink sent a pagination URL that is not a URL: ${next}`); } if ( parsed.origin !== config.origin || !parsed.pathname.startsWith("/api/canonical/0/") ) { throw new Error( `Bugsink sent a pagination URL outside its own canonical API: ${next}`, ); } return parsed.href; }; /** Read a cursor-paginated list until it runs out or reaches the limit. */ const listAll = async ( config: BugsConfig, firstUrl: string, item: S, limit: number, ): Promise[]> => { const items: v.InferOutput[] = []; let url: string | null = firstUrl; while (url !== null && items.length < limit) { const page: { next: string | null; results: v.InferOutput[] } = await getJson(config, url, listPageSchema(item)); items.push(...page.results); url = page.next === null ? null : pinnedNextUrl(config, page.next); } return items.slice(0, limit); }; const apiIssueUrl = (config: BugsConfig, issueId: string): string => `${config.baseUrl}/api/canonical/0/issues/${encodeURIComponent(issueId)}/`; export interface IssueBundle { events: Record[]; issue: Record; issue_url: string; } /** Fetch one issue plus its latest events, with the full event payloads. */ export const fetchIssueBundle = async ( config: BugsConfig, ref: string, eventCount: number, ): Promise => { const issue = await getJson( config, apiIssueUrl(config, parseIssueRef(ref)), IssueSchema, ); const eventsUrl = `${config.baseUrl}/api/canonical/0/events/?issue=${encodeURIComponent(issue.id)}&order=desc`; const latest = await listAll( config, eventsUrl, EventListItemSchema, eventCount, ); const events: Record[] = []; for (const listed of latest) { events.push( await getJson( config, `${config.baseUrl}/api/canonical/0/events/${listed.id}/`, EventSchema, ), ); } return { events, issue, issue_url: `${config.baseUrl}/issues/issue/${issue.id}/`, }; }; export interface IssueSummary { events: number; first_seen: string; friendly_id: string; id: string; issue_url: string; last_seen: string; muted: boolean; project: string; project_id: number; stored_events: number; type: string; value: string | null; } const summarize = (baseUrl: string, project: ProjectRecord) => (issue: IssueRecord): IssueSummary => ({ events: issue.digested_event_count, first_seen: issue.first_seen, friendly_id: issue.friendly_id, id: issue.id, issue_url: `${baseUrl}/issues/issue/${issue.id}/`, last_seen: issue.last_seen, muted: issue.is_muted, project: project.name, project_id: project.id, stored_events: issue.stored_event_count, type: issue.calculated_type, value: issue.calculated_value, }); /** List the issues of every project, newest first. */ export const fetchIssueSummaries = async ( config: BugsConfig, includeResolved: boolean, ): Promise => { const projects = await listAll( config, `${config.baseUrl}/api/canonical/0/projects/`, ProjectSchema, Number.POSITIVE_INFINITY, ); const summaries: IssueSummary[] = []; for (const project of projects) { const issuesUrl = `${config.baseUrl}/api/canonical/0/issues/?project=${project.id}&sort=last_seen&order=desc`; const issues = await listAll( config, issuesUrl, IssueSchema, Number.POSITIVE_INFINITY, ); summaries.push( ...issues .filter((issue) => includeResolved || !issue.is_resolved) .map(summarize(config.baseUrl, project)), ); } return sortedByString( (issue: IssueSummary) => issue.last_seen, "desc", )(summaries); }; export const USAGE = `Fetch Bugsink issue details as JSON. Usage: deno task bugs ... [--events N] deno task bugs list [--all] Options: --events N Fetch the latest N events per issue. Default 1. 0 prints the issue only. --all With list, also print resolved issues. -h, --help Print this text. Reads SENTRY_BASE_URL (or SENTRY_BASE) and SENTRY_API_KEY from .env. Create a token in Bugsink under Tokens. `; const wholeNumber = (text: string): number | undefined => /^\d+$/.test(text) ? Number(text) : undefined; export const runBugsCli = async (io: ScriptIo): Promise => { const flags = parseArgs(io.args, { alias: { h: "help" }, boolean: ["all", "help"], string: ["events"], }); if (flags.help || flags._.length === 0) { io.stderr(USAGE); return flags.help ? 0 : 1; } const eventCount = wholeNumber(flags.events ?? "1"); if (eventCount === undefined) { io.stderr( `The --events value must be a whole number, got: ${flags.events}`, ); io.stderr(USAGE); return 1; } try { const config = bugsConfig(io.getEnv); const refs = flags._.map((value) => String(value)); if (refs[0] === "list") { if (refs.length > 1) { io.stderr("The list command takes no issue ids."); return 1; } io.stdout( JSON.stringify(await fetchIssueSummaries(config, flags.all), null, 2), ); return 0; } const bundles: IssueBundle[] = []; for (const ref of refs) { io.stderr(`Fetching ${ref}`); bundles.push(await fetchIssueBundle(config, ref, eventCount)); } io.stdout( JSON.stringify(bundles.length === 1 ? bundles[0] : bundles, null, 2), ); return 0; } catch (error) { io.stderr(`error: ${(error as Error).message}`); return 1; } };