scripts/bugs-lib.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<unknown> => {
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 <S extends v.GenericSchema>(
config: BugsConfig,
url: string,
schema: S,
): Promise<v.InferOutput<S>> => {
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<S>;
};
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<typeof IssueSchema>;
/** 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<typeof ProjectSchema>;
const listPageSchema = <S extends v.GenericSchema>(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 <S extends v.GenericSchema>(
config: BugsConfig,
firstUrl: string,
item: S,
limit: number,
): Promise<v.InferOutput<S>[]> => {
const items: v.InferOutput<S>[] = [];
let url: string | null = firstUrl;
while (url !== null && items.length < limit) {
const page: { next: string | null; results: v.InferOutput<S>[] } =
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<string, unknown>[];
issue: Record<string, unknown>;
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<IssueBundle> => {
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<string, unknown>[] = [];
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<IssueSummary[]> => {
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 <issue-url-or-id>... [--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<number> => {
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;
}
};