Skip to content

Instantly share code, notes, and snippets.

@wilsonowilson
Last active April 8, 2026 17:32
Show Gist options
  • Select an option

  • Save wilsonowilson/6ea71b6a8788077d85af5976c56776ce to your computer and use it in GitHub Desktop.

Select an option

Save wilsonowilson/6ea71b6a8788077d85af5976c56776ce to your computer and use it in GitHub Desktop.
Codex sentry autofix

SOP: Codex Sentry Autofix (Generic)

Goal Automatically create a PR (or close/ignore the issue) when a Sentry issue alert fires, using Codex in CI.

Setup

  1. Codex auth (CI secret)

    • On a dev machine, run pnpm dlx @openai/codex login to create ~/.codex/auth.json.
    • Base64‑encode it and store as a CI secret CODEX_AUTH_JSON_BASE64.
    • Your workflow should decode this into ~/.codex/auth.json before running Codex.
  2. Sentry API token (CI secret)

    • Create a Sentry token with read access to issues/events and write access to update issue status.
    • Store as SENTRY_AGENT_TOKEN in CI.
  3. Axiom API token (CI secret)

    • Store as AXIOM_TOKEN in CI.
    • Default dataset can be production, but allow overrides via workflow inputs.
  4. Webhook auth (app/server env)

    • Choose one authentication method for incoming Sentry webhooks:
      • Shared secret: set CODEX_SENTRY_WEBHOOK_SECRET (or similar) and accept it via Authorization: Bearer <secret> or x-codex-webhook-secret.
      • Signed webhook: set SENTRY_WEBHOOK_CLIENT_SECRET and validate sentry-hook-signature (HMAC SHA‑256 over raw body).
  5. GitHub dispatch token (app/server env)

    • Set GITHUB_ACTIONS_TRIGGER_TOKEN with permission to call repository_dispatch on your repo.
    • The server uses this to kick off the CI workflow.

Trigger

  1. Primary: Sentry webhook

    • Configure Sentry Issue Alert → webhook URL:
      POST https://<your-app>/super/codex/sentry-alert
    • Optional headers/query:
      • x-linear-issue-id: ENG-123 (or any tracking id you use)
      • x-codex-urgency: urgent|high|medium|low
      • ?urgency=high
  2. Backup: manual workflow run

    • Run workflow manually with:
      • issue_ref (required)
      • issue_title (optional)
      • issue_url (optional)
      • linear_issue_id (optional)
      • axiom_dataset (optional)

Expected Outcome

  • Codex runs in CI, builds context from Sentry + Axiom, and:
    • opens or updates a PR if code changes are needed, or
    • marks the issue resolved/ignored if no code changes are required.

If you want this written into a repo file (e.g., docs/sop-codex-sentry-autofix.md), tell me the target path and I’ll drop it in.

const DEFAULT_DATASET = 'production';
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
type JsonObject = Record<string, JsonValue>;
type CliFlags = Record<string, string | string[] | boolean>;
type ParsedArgs = {
command: string;
positionals: string[];
flags: CliFlags;
};
type AxiomDataset = {
id?: string;
name?: string;
description?: string | null;
retentionDays?: number;
canWrite?: boolean;
kind?: string;
region?: string;
created?: string;
};
type AxiomDashboard = {
id?: string;
uid?: string;
version?: string | number;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
updatedBy?: string;
dashboard?: {
name?: string;
datasets?: string[];
owner?: string;
charts?: unknown[];
layout?: unknown[];
refreshTime?: number;
schemaVersion?: number;
timeWindowStart?: string;
timeWindowEnd?: string;
};
};
type AxiomMonitor = {
id?: string;
name?: string;
description?: string;
type?: string;
enabled?: boolean;
schedule?: string;
aplQuery?: string;
};
type AxiomNotifier = {
id?: string;
name?: string;
type?: string;
};
type AxiomAnnotation = {
id?: string;
title?: string;
type?: string;
time?: string;
endTime?: string | null;
datasets?: string[];
url?: string;
};
type AxiomStarredQuery = {
id?: string;
name?: string;
description?: string;
query?: {
apl?: string;
};
};
type AxiomTabularField = {
name: string;
type?: string;
};
type AxiomTabularTable = {
name?: string;
fields?: AxiomTabularField[];
columns?: JsonValue[][];
};
type AxiomTabularResult = {
format?: string;
datasetNames?: string[];
tables?: AxiomTabularTable[];
status?: Record<string, unknown>;
fieldsMetaMap?: Record<string, unknown>;
};
class AxiomApiError extends Error {
status: number;
body: unknown;
constructor(message: string, status: number, body: unknown) {
super(message);
this.name = 'AxiomApiError';
this.status = status;
this.body = body;
}
}
function parseArgs(argv: string[]): ParsedArgs {
const flags: CliFlags = {};
const positionals: string[] = [];
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (!current.startsWith('--')) {
positionals.push(current);
continue;
}
const flag = current.slice(2);
const equalsIndex = flag.indexOf('=');
if (equalsIndex >= 0) {
const key = flag.slice(0, equalsIndex);
const value = flag.slice(equalsIndex + 1);
appendFlag(flags, key, value);
continue;
}
const next = argv[index + 1];
if (next && !next.startsWith('--')) {
appendFlag(flags, flag, next);
index += 1;
continue;
}
appendFlag(flags, flag, true);
}
const [command = 'help', ...rest] = positionals;
return {
command,
positionals: rest,
flags
};
}
function appendFlag(flags: CliFlags, key: string, value: string | boolean) {
const existing = flags[key];
if (existing === undefined) {
flags[key] = value;
return;
}
if (Array.isArray(existing)) {
existing.push(String(value));
return;
}
flags[key] = [String(existing), String(value)];
}
function getFlag(flags: CliFlags, key: string): string | undefined {
const value = flags[key];
if (Array.isArray(value)) return value.at(-1);
if (typeof value === 'string') return value;
return undefined;
}
function hasFlag(flags: CliFlags, key: string): boolean {
const value = flags[key];
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
return !['false', '0', 'no', 'off'].includes(value.trim().toLowerCase());
}
return false;
}
function requireFlag(flags: CliFlags, key: string): string {
const value = getFlag(flags, key)?.trim();
if (!value) {
throw new Error(`Missing required flag: --${key}`);
}
return value;
}
function getDatasetFlag(flags: CliFlags, key = 'dataset'): string {
return getFlag(flags, key)?.trim() || DEFAULT_DATASET;
}
function getNumberFlag(
flags: CliFlags,
key: string,
options?: { fallback?: number; minimum?: number }
): number {
const raw = getFlag(flags, key);
if (raw === undefined || raw === '') {
return options?.fallback ?? 0;
}
const value = Number(raw);
if (!Number.isFinite(value)) {
throw new Error(`Expected --${key} to be a number.`);
}
if (options?.minimum !== undefined && value < options.minimum) {
throw new Error(`Expected --${key} to be >= ${options.minimum}.`);
}
return value;
}
function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function parseJsonBody(text: string): unknown {
if (!text) return null;
try {
return JSON.parse(text) as JsonValue;
} catch {
return text;
}
}
function isJsonObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function readJsonFile(pathValue: string): JsonValue {
const path = resolve(process.cwd(), pathValue);
const source = readFileSync(path, 'utf-8');
return JSON.parse(source) as JsonValue;
}
function readTextFile(pathValue: string): string {
const path = resolve(process.cwd(), pathValue);
return readFileSync(path, 'utf-8');
}
function getAplSource(flags: CliFlags): string {
const apl = getFlag(flags, 'apl');
if (apl?.trim()) {
return apl.trim();
}
const file = getFlag(flags, 'file');
if (file?.trim()) {
return readTextFile(file).trim();
}
throw new Error('Pass --apl or --file with an APL query.');
}
function toRows(result: AxiomTabularResult): Array<Record<string, JsonValue>> {
const table = result.tables?.[0];
if (!table?.fields?.length || !table.columns) {
return [];
}
const rows: Array<Record<string, JsonValue>> = [];
const rowCount = table.columns[0]?.length ?? 0;
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
const row: Record<string, JsonValue> = {};
for (let columnIndex = 0; columnIndex < table.fields.length; columnIndex += 1) {
const field = table.fields[columnIndex];
row[field.name] = table.columns[columnIndex]?.[rowIndex] ?? null;
}
rows.push(row);
}
return rows;
}
function printJson(value: unknown) {
console.log(JSON.stringify(value, null, 2));
}
function printRows(result: AxiomTabularResult) {
const rows = toRows(result);
if (rows.length === 0) {
console.log('No rows returned.');
return;
}
for (const row of rows) {
console.log(JSON.stringify(row));
}
}
function buildDatasetSelector(dataset: string) {
return `["${dataset}"]`;
}
function escapeAplString(value: string) {
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
}
function buildLogsApl(options: {
dataset: string;
service?: string;
traceId?: string;
name?: string;
errorsOnly?: boolean;
limit: number;
}) {
const clauses = [
`${buildDatasetSelector(options.dataset)}`,
`| extend service = tostring(["service.name"])`,
`| extend status = tostring(["status.code"])`,
`| extend error_type = tostring(["attributes.error.type"])`,
`| extend error_message = tostring(["attributes.error.message"])`,
`| extend severity = iff(error == true or status startswith "ERROR", "error", "info")`
];
if (options.service) {
clauses.push(`| where service == "${escapeAplString(options.service)}"`);
}
if (options.traceId) {
clauses.push(`| where trace_id == "${escapeAplString(options.traceId)}"`);
}
if (options.name) {
clauses.push(`| where name contains "${escapeAplString(options.name)}"`);
}
if (options.errorsOnly) {
clauses.push(
'| where error == true or status startswith "ERROR" or isnotempty(["attributes.error.message"]) or isnotempty(["attributes.error.type"])'
);
}
clauses.push(
'| project _time, service, kind, name, severity, status, error_type, error_message, trace_id, duration, route = tostring(["attributes.http.route"])'
);
clauses.push('| sort by _time desc');
clauses.push(`| limit ${options.limit}`);
return clauses.join('\n');
}
function buildTracesApl(options: {
dataset: string;
service?: string;
traceId?: string;
name?: string;
limit: number;
}) {
const clauses = [
`${buildDatasetSelector(options.dataset)}`,
`| where kind in ("client", "server", "internal")`,
`| extend service = tostring(["service.name"])`
];
if (options.service) {
clauses.push(`| where service == "${escapeAplString(options.service)}"`);
}
if (options.traceId) {
clauses.push(`| where trace_id == "${escapeAplString(options.traceId)}"`);
}
if (options.name) {
clauses.push(`| where name contains "${escapeAplString(options.name)}"`);
}
clauses.push(
'| project _time, service, kind, name, duration, status = tostring(["status.code"]), trace_id, span_id, parent_span_id, route = tostring(["attributes.http.route"])'
);
clauses.push('| sort by _time desc');
clauses.push(`| limit ${options.limit}`);
return clauses.join('\n');
}
function buildTraceDetailApl(options: {
dataset: string;
traceId: string;
limit: number;
}) {
return [
`${buildDatasetSelector(options.dataset)}`,
`| where trace_id == "${escapeAplString(options.traceId)}"`,
'| extend service = tostring(["service.name"])',
'| extend status = tostring(["status.code"])',
'| extend error_type = tostring(["attributes.error.type"])',
'| extend error_message = tostring(["attributes.error.message"])',
'| project _time, service, kind, name, duration, status, error, error_type, error_message, trace_id, span_id, parent_span_id, route = tostring(["attributes.http.route"]), url = tostring(["attributes.url.full"])',
'| sort by _time asc',
`| limit ${options.limit}`
].join('\n');
}
function buildSchemaApl(dataset: string) {
return `${buildDatasetSelector(dataset)} | sort by _time desc | limit 1`;
}
class AxiomClient {
private readonly token: string;
private readonly baseUrl = 'https://api.axiom.co';
constructor(token: string) {
this.token = token;
}
async request<T>(
pathname: string,
options?: {
method?: 'GET' | 'POST' | 'PUT';
query?: Record<string, string | boolean | number | undefined>;
body?: JsonValue;
}
): Promise<T> {
const url = new URL(pathname, this.baseUrl);
for (const [key, value] of Object.entries(options?.query ?? {})) {
if (value === undefined) continue;
url.searchParams.set(key, String(value));
}
const response = await fetch(url, {
method: options?.method ?? 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${this.token}`,
...(options?.body !== undefined ? { 'Content-Type': 'application/json' } : {})
},
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined
});
const text = await response.text();
const body = parseJsonBody(text);
if (!response.ok) {
throw new AxiomApiError(
`Axiom API request failed with ${response.status} ${response.statusText}`,
response.status,
body
);
}
return body as T;
}
async listDatasets() {
return this.request<AxiomDataset[]>('/v2/datasets');
}
async queryApl(options: {
apl: string;
startTime?: string;
endTime?: string;
includeCursor?: boolean;
}) {
return this.request<AxiomTabularResult>('/v1/datasets/_apl', {
method: 'POST',
query: {
format: 'tabular'
},
body: {
apl: options.apl,
...(options.startTime ? { startTime: options.startTime } : {}),
...(options.endTime ? { endTime: options.endTime } : {}),
...(options.includeCursor ? { includeCursor: true } : {})
}
});
}
async listDashboards() {
return this.request<AxiomDashboard[]>('/v2/dashboards');
}
async getDashboard(uid: string) {
return this.request<AxiomDashboard>(`/v2/dashboards/uid/${encodeURIComponent(uid)}`);
}
async createDashboard(payload: JsonValue) {
const body =
isJsonObject(payload) && isJsonObject(payload.dashboard)
? payload
: { dashboard: payload };
return this.request<JsonObject>('/v2/dashboards', {
method: 'POST',
body
});
}
async updateDashboard(uid: string, payload: JsonValue) {
const body =
isJsonObject(payload) && isJsonObject(payload.dashboard)
? payload
: { dashboard: payload };
return this.request<JsonObject>(`/v2/dashboards/uid/${encodeURIComponent(uid)}`, {
method: 'PUT',
body
});
}
async listMonitors() {
return this.request<AxiomMonitor[]>('/v2/monitors');
}
async getMonitor(id: string) {
return this.request<AxiomMonitor>(`/v2/monitors/${encodeURIComponent(id)}`);
}
async createMonitor(payload: JsonValue) {
return this.request<JsonObject>('/v2/monitors', {
method: 'POST',
body: payload
});
}
async updateMonitor(id: string, payload: JsonValue) {
return this.request<JsonObject>(`/v2/monitors/${encodeURIComponent(id)}`, {
method: 'PUT',
body: payload
});
}
async getMonitorHistory(id: string) {
return this.request<JsonObject[]>(`/v2/monitors/${encodeURIComponent(id)}/history`);
}
async listNotifiers() {
return this.request<AxiomNotifier[]>('/v2/notifiers');
}
async listAnnotations(options?: {
datasets?: string;
start?: string;
end?: string;
}) {
return this.request<AxiomAnnotation[]>('/v2/annotations', {
query: {
datasets: options?.datasets,
start: options?.start,
end: options?.end
}
});
}
async listStarredQueries() {
return this.request<AxiomStarredQuery[]>('/v2/apl-starred-queries');
}
}
function printHelp() {
console.log(`Axiom agent CLI
Required environment variable:
AXIOM_TOKEN
Commands:
help
Show this help text.
list-datasets
List accessible datasets.
schema [--dataset=production]
Inspect the live field schema by sampling one recent row. Defaults to production.
query-apl --apl='["production"] | limit 5' [--start=now-1h] [--end=now]
query-apl --file=queries/errors.apl [--json]
Run a raw APL query.
query-logs [--dataset=production] [--service=node] [--trace-id=...] [--name=...] [--errors-only] [--limit=20]
Run a debugging-oriented log/error query over a dataset. Defaults to production.
query-traces [--dataset=production] [--service=node] [--trace-id=...] [--name=...] [--limit=20]
Run a trace/span query over a dataset. Defaults to production.
trace [--dataset=production] --trace-id=... [--limit=100]
Fetch the full event chain for one trace id. Defaults to production.
list-dashboards
get-dashboard --uid=...
create-dashboard --file=dashboard.json
update-dashboard --uid=... --file=dashboard.json
Manage dashboards without destructive delete support. Dashboard payloads follow Axiom's dashboard API shape.
list-monitors
get-monitor --id=...
create-monitor --file=monitor.json
update-monitor --id=... --file=monitor.json
monitor-history --id=...
Manage monitors without destructive delete support. Monitor payloads follow Axiom's monitor API shape.
list-notifiers
List notifiers that monitors can target.
list-annotations [--datasets=production] [--start=2026-03-01T00:00:00Z] [--end=2026-03-19T00:00:00Z]
List chart annotations. Defaults to production.
list-starred-queries
List saved APL starred queries.
Flags:
--json
Print raw JSON instead of the compact default output.
`);
}
async function main() {
const token = requireEnv('AXIOM_TOKEN');
const client = new AxiomClient(token);
const { command, flags } = parseArgs(process.argv.slice(2));
const json = hasFlag(flags, 'json');
switch (command) {
case 'help': {
printHelp();
return;
}
case 'list-datasets': {
const datasets = await client.listDatasets();
if (json) {
printJson(datasets);
return;
}
for (const dataset of datasets) {
console.log(
`${dataset.id ?? '(unknown)'} retention=${dataset.retentionDays ?? '?'}d write=${dataset.canWrite === true ? 'yes' : 'no'} kind=${dataset.kind ?? 'unknown'} region=${dataset.region ?? 'unknown'}`
);
}
return;
}
case 'schema': {
const dataset = getDatasetFlag(flags);
const result = await client.queryApl({
apl: buildSchemaApl(dataset),
startTime: getFlag(flags, 'start'),
endTime: getFlag(flags, 'end')
});
if (json) {
printJson(result);
return;
}
const fields = result.tables?.[0]?.fields ?? [];
for (const field of fields) {
console.log(`${field.name} (${field.type ?? 'unknown'})`);
}
return;
}
case 'query-apl': {
const result = await client.queryApl({
apl: getAplSource(flags),
startTime: getFlag(flags, 'start'),
endTime: getFlag(flags, 'end'),
includeCursor: hasFlag(flags, 'include-cursor')
});
if (json) {
printJson(result);
return;
}
printRows(result);
return;
}
case 'query-logs': {
const result = await client.queryApl({
apl: buildLogsApl({
dataset: getDatasetFlag(flags),
service: getFlag(flags, 'service'),
traceId: getFlag(flags, 'trace-id'),
name: getFlag(flags, 'name'),
errorsOnly: hasFlag(flags, 'errors-only'),
limit: getNumberFlag(flags, 'limit', { fallback: 20, minimum: 1 })
}),
startTime: getFlag(flags, 'start'),
endTime: getFlag(flags, 'end')
});
if (json) {
printJson(result);
return;
}
printRows(result);
return;
}
case 'query-traces': {
const result = await client.queryApl({
apl: buildTracesApl({
dataset: getDatasetFlag(flags),
service: getFlag(flags, 'service'),
traceId: getFlag(flags, 'trace-id'),
name: getFlag(flags, 'name'),
limit: getNumberFlag(flags, 'limit', { fallback: 20, minimum: 1 })
}),
startTime: getFlag(flags, 'start'),
endTime: getFlag(flags, 'end')
});
if (json) {
printJson(result);
return;
}
printRows(result);
return;
}
case 'trace': {
const result = await client.queryApl({
apl: buildTraceDetailApl({
dataset: getDatasetFlag(flags),
traceId: requireFlag(flags, 'trace-id'),
limit: getNumberFlag(flags, 'limit', { fallback: 100, minimum: 1 })
}),
startTime: getFlag(flags, 'start'),
endTime: getFlag(flags, 'end')
});
if (json) {
printJson(result);
return;
}
printRows(result);
return;
}
case 'list-dashboards': {
const dashboards = await client.listDashboards();
if (json) {
printJson(dashboards);
return;
}
for (const dashboard of dashboards) {
const datasets = dashboard.dashboard?.datasets?.join(',') ?? 'none';
console.log(
`${dashboard.dashboard?.name ?? '(unnamed)'} uid=${dashboard.uid ?? '(none)'} id=${dashboard.id ?? '(none)'} datasets=${datasets}`
);
}
return;
}
case 'get-dashboard': {
const uid = requireFlag(flags, 'uid');
const dashboard = await client.getDashboard(uid);
printJson(dashboard);
return;
}
case 'create-dashboard': {
const payload = readJsonFile(requireFlag(flags, 'file'));
const response = await client.createDashboard(payload);
printJson(response);
return;
}
case 'update-dashboard': {
const uid = requireFlag(flags, 'uid');
const payload = readJsonFile(requireFlag(flags, 'file'));
const response = await client.updateDashboard(uid, payload);
printJson(response);
return;
}
case 'list-monitors': {
const monitors = await client.listMonitors();
if (json) {
printJson(monitors);
return;
}
for (const monitor of monitors) {
console.log(
`${monitor.id ?? '(unknown)'} ${monitor.name ?? '(unnamed)'} type=${monitor.type ?? 'unknown'} enabled=${monitor.enabled === true ? 'yes' : 'no'}`
);
}
return;
}
case 'get-monitor': {
const monitor = await client.getMonitor(requireFlag(flags, 'id'));
printJson(monitor);
return;
}
case 'create-monitor': {
const payload = readJsonFile(requireFlag(flags, 'file'));
const response = await client.createMonitor(payload);
printJson(response);
return;
}
case 'update-monitor': {
const response = await client.updateMonitor(
requireFlag(flags, 'id'),
readJsonFile(requireFlag(flags, 'file'))
);
printJson(response);
return;
}
case 'monitor-history': {
const history = await client.getMonitorHistory(requireFlag(flags, 'id'));
printJson(history);
return;
}
case 'list-notifiers': {
const notifiers = await client.listNotifiers();
if (json) {
printJson(notifiers);
return;
}
for (const notifier of notifiers) {
console.log(`${notifier.id ?? '(unknown)'} ${notifier.name ?? '(unnamed)'} type=${notifier.type ?? 'unknown'}`);
}
return;
}
case 'list-annotations': {
const annotations = await client.listAnnotations({
datasets: getFlag(flags, 'datasets') ?? DEFAULT_DATASET,
start: getFlag(flags, 'start'),
end: getFlag(flags, 'end')
});
if (json) {
printJson(annotations);
return;
}
for (const annotation of annotations) {
console.log(
`${annotation.time ?? '(no-time)'} ${annotation.type ?? '(no-type)'} ${annotation.title ?? '(untitled)'} id=${annotation.id ?? '(none)'}`
);
}
return;
}
case 'list-starred-queries': {
const queries = await client.listStarredQueries();
if (json) {
printJson(queries);
return;
}
for (const query of queries) {
console.log(`${query.id ?? '(unknown)'} ${query.name ?? '(unnamed)'}`);
}
return;
}
default: {
throw new Error(`Unknown command: ${command}`);
}
}
}
main().catch((error) => {
if (error instanceof AxiomApiError) {
console.error(error.message);
console.error(JSON.stringify(error.body, null, 2));
process.exitCode = 1;
return;
}
if (error instanceof Error) {
console.error(error.message);
process.exitCode = 1;
return;
}
console.error(error);
process.exitCode = 1;
});

name: codex-sentry-autofix

on: workflow_dispatch: inputs: issue_ref: description: Sentry issue id, short id, or URL required: true type: string issue_title: description: Optional Sentry issue title for better prompt and PR naming required: false type: string issue_url: description: Optional Sentry issue permalink required: false type: string linear_issue_id: description: Optional Linear issue identifier like ENG-123 required: false type: string axiom_dataset: description: Axiom dataset to use required: false default: production type: string urgency: description: Optional urgency override (urgent, high, medium, low) required: false type: string repository_dispatch: types: - codex_sentry_autofix

concurrency: group: codex-sentry-${{ github.event.client_payload.issue_ref || inputs.issue_ref || github.run_id }} cancel-in-progress: false

jobs: codex_autofix: runs-on: ubuntu-latest permissions: contents: write pull-requests: write outputs: issue_ref: ${{ steps.resolve_inputs.outputs.issue_ref }} issue_title: ${{ steps.resolve_issue_title.outputs.issue_title }} issue_url: ${{ steps.resolve_inputs.outputs.issue_url }} linear_issue_id: ${{ steps.resolve_inputs.outputs.linear_issue_id }} urgency: ${{ steps.resolve_inputs.outputs.urgency }} has_changes: ${{ steps.outcome.outputs.has_changes }} workflow_outcome: ${{ steps.outcome.outputs.workflow_outcome }} pr_title: ${{ steps.pr_metadata.outputs.pr_title }} pr_branch: ${{ steps.pr_metadata.outputs.pr_branch }} pr_commit_message: ${{ steps.pr_metadata.outputs.pr_commit_message }} env: CODEX_AUTH_JSON_BASE64: ${{ secrets.CODEX_AUTH_JSON_BASE64 }} SENTRY_AGENT_TOKEN: ${{ secrets.SENTRY_AGENT_TOKEN }} AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }}

steps:
  - uses: actions/checkout@v4
    with:
      fetch-depth: 0

  - uses: pnpm/action-setup@v4
    with:
      version: 9.4.0

  - uses: actions/setup-node@v4
    with:
      node-version: 22
      cache: pnpm
      cache-dependency-path: pnpm-lock.yaml

  - name: Resolve incident inputs
    id: resolve_inputs
    shell: bash
    run: |
      if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
        ISSUE_REF='${{ inputs.issue_ref }}'
        ISSUE_TITLE='${{ inputs.issue_title }}'
        ISSUE_URL='${{ inputs.issue_url }}'
        LINEAR_ISSUE_ID='${{ inputs.linear_issue_id }}'
        AXIOM_DATASET='${{ inputs.axiom_dataset }}'
        URGENCY='${{ inputs.urgency || 'high' }}'
      else
        ISSUE_REF='${{ github.event.client_payload.issue_ref }}'
        ISSUE_TITLE='${{ github.event.client_payload.issue_title }}'
        ISSUE_URL='${{ github.event.client_payload.issue_url }}'
        LINEAR_ISSUE_ID='${{ github.event.client_payload.linear_issue_id }}'
        AXIOM_DATASET='${{ github.event.client_payload.axiom_dataset || 'production' }}'
        URGENCY='${{ github.event.client_payload.urgency || 'high' }}'
      fi

      echo "ISSUE_REF=${ISSUE_REF}" >> "$GITHUB_ENV"
      echo "ISSUE_TITLE=${ISSUE_TITLE}" >> "$GITHUB_ENV"
      echo "ISSUE_URL=${ISSUE_URL}" >> "$GITHUB_ENV"
      echo "LINEAR_ISSUE_ID=${LINEAR_ISSUE_ID}" >> "$GITHUB_ENV"
      echo "AXIOM_DATASET=${AXIOM_DATASET}" >> "$GITHUB_ENV"
      echo "URGENCY=${URGENCY}" >> "$GITHUB_ENV"

      echo "issue_ref=${ISSUE_REF}" >> "$GITHUB_OUTPUT"
      echo "issue_url=${ISSUE_URL}" >> "$GITHUB_OUTPUT"
      echo "linear_issue_id=${LINEAR_ISSUE_ID}" >> "$GITHUB_OUTPUT"
      echo "urgency=${URGENCY}" >> "$GITHUB_OUTPUT"

  - name: Validate secrets
    shell: bash
    run: |
      for key in CODEX_AUTH_JSON_BASE64 SENTRY_AGENT_TOKEN AXIOM_TOKEN; do
        if [[ -z "${!key}" ]]; then
          echo "Missing required secret: ${key}"
          exit 1
        fi
      done

  - name: Install dependencies
    run: pnpm install --frozen-lockfile --prefer-offline

  - name: Prepare Codex auth
    shell: bash
    run: |
      mkdir -p "$HOME/.codex"
      node <<'EOF'
      const fs = require('node:fs');
      const path = `${process.env.HOME}/.codex/auth.json`;
      const encoded = process.env.CODEX_AUTH_JSON_BASE64 || '';
      fs.writeFileSync(path, Buffer.from(encoded, 'base64'));
      EOF
      chmod 600 "$HOME/.codex/auth.json"
      echo "Seeded Codex auth from CODEX_AUTH_JSON_BASE64"

  - name: Check Codex auth
    shell: bash
    run: |
      pnpm dlx @openai/codex login status

  - name: Build incident context
    run: |
      mkdir -p "$RUNNER_TEMP/codex-sentry"
      pnpm exec tsx .agents/scripts/build-sentry-autofix-context.ts \
        --issue="$ISSUE_REF" \
        --dataset="$AXIOM_DATASET" \
        --output="$RUNNER_TEMP/codex-sentry/context.md"

  - name: Resolve incident metadata
    id: resolve_issue_title
    shell: bash
    run: |
      ISSUE_TITLE_VALUE="${ISSUE_TITLE:-}"
      ISSUE_URL_VALUE="${ISSUE_URL:-}"

      if [[ -z "$ISSUE_TITLE_VALUE" ]]; then
        ISSUE_TITLE_VALUE=$(node -e "const fs=require('node:fs'); const context=fs.readFileSync(process.env.CONTEXT_PATH,'utf8'); const match=context.match(/^- Issue title: (.+)$/m); process.stdout.write(match?.[1] ?? '');")
      fi

      if [[ -z "$ISSUE_URL_VALUE" ]]; then
        ISSUE_URL_VALUE=$(node -e "const fs=require('node:fs'); const context=fs.readFileSync(process.env.CONTEXT_PATH,'utf8'); const match=context.match(/^- Permalink: (.+)$/m); process.stdout.write(match?.[1] ?? '');")
      fi

      echo "ISSUE_TITLE=${ISSUE_TITLE_VALUE}" >> "$GITHUB_ENV"
      echo "ISSUE_URL=${ISSUE_URL_VALUE}" >> "$GITHUB_ENV"
      echo "issue_title=${ISSUE_TITLE_VALUE}" >> "$GITHUB_OUTPUT"
    env:
      CONTEXT_PATH: ${{ runner.temp }}/codex-sentry/context.md

  - name: Build Codex prompt
    shell: bash
    run: |
      mkdir -p "$RUNNER_TEMP/codex-sentry"
      cat .github/codex/prompts/sentry-autofix.md > "$RUNNER_TEMP/codex-sentry/prompt.md"
      printf '\n\n## Issue Title\n%s\n' "${ISSUE_TITLE:-unknown}" >> "$RUNNER_TEMP/codex-sentry/prompt.md"
      printf '\n\n## Issue URL\n%s\n' "${ISSUE_URL:-unknown}" >> "$RUNNER_TEMP/codex-sentry/prompt.md"
      printf '\n\n## Linear Issue\n%s\n' "${LINEAR_ISSUE_ID:-not linked}" >> "$RUNNER_TEMP/codex-sentry/prompt.md"
      printf '\n\n## Sentry Issue\n%s\n' "${ISSUE_REF}" >> "$RUNNER_TEMP/codex-sentry/prompt.md"
      printf '\n\n## Urgency\n%s\n' "${URGENCY:-high}" >> "$RUNNER_TEMP/codex-sentry/prompt.md"
      printf '\n\n## Context\n' >> "$RUNNER_TEMP/codex-sentry/prompt.md"
      cat "$RUNNER_TEMP/codex-sentry/context.md" >> "$RUNNER_TEMP/codex-sentry/prompt.md"

  - name: Run Codex autofix
    shell: bash
    run: |
      pnpm dlx @openai/codex exec \
        --dangerously-bypass-approvals-and-sandbox \
        -C . \
        -m gpt-5.4 \
        -c model_reasoning_effort='"xhigh"' \
        --output-schema .github/codex/schemas/sentry-autofix-result.json \
        -o "$RUNNER_TEMP/codex-sentry/result.json" \
        < "$RUNNER_TEMP/codex-sentry/prompt.md"

  - name: Determine Codex outcome
    id: outcome
    shell: bash
    run: |
      RESULT_PATH="$RUNNER_TEMP/codex-sentry/result.json"
      BODY_PATH="$RUNNER_TEMP/codex-sentry/pr-body.md"

      if [[ ! -f "$RESULT_PATH" ]]; then
        echo "Missing Codex result at $RESULT_PATH"
        exit 1
      fi

      DISPOSITION=$(node <<'EOF'
      const fs = require('node:fs');
      const resultPath = process.env.RESULT_PATH;
      const bodyPath = process.env.BODY_PATH;
      const raw = fs.readFileSync(resultPath, 'utf8').trim();
      const result = JSON.parse(raw);
      const prBody = String(result.pr_body || '').trim();
      const quickLinks = [];
      const issueUrl = (process.env.ISSUE_URL || '').trim();

      if (issueUrl && issueUrl !== 'unknown') {
        quickLinks.push(`[Sentry Issue](${issueUrl})`);
      }

      const bodyParts = [];
      if (quickLinks.length > 0) {
        bodyParts.push(quickLinks.join(' · '));
      }
      if (prBody) {
        bodyParts.push(prBody);
      }

      fs.writeFileSync(bodyPath, bodyParts.length > 0 ? `${bodyParts.join('\n\n')}\n` : '');
      process.stdout.write(result.disposition);
      EOF
      )

      HAS_CHANGES=false
      if [[ -n "$(git status --porcelain)" ]]; then
        HAS_CHANGES=true
      fi

      if [[ "$HAS_CHANGES" == "true" ]]; then
        WORKFLOW_OUTCOME=open_pr
      else
        WORKFLOW_OUTCOME="${DISPOSITION}"
      fi

      echo "CODEX_DISPOSITION=${DISPOSITION}" >> "$GITHUB_ENV"
      echo "HAS_CHANGES=${HAS_CHANGES}" >> "$GITHUB_ENV"
      echo "WORKFLOW_OUTCOME=${WORKFLOW_OUTCOME}" >> "$GITHUB_ENV"
      echo "has_changes=${HAS_CHANGES}" >> "$GITHUB_OUTPUT"
      echo "workflow_outcome=${WORKFLOW_OUTCOME}" >> "$GITHUB_OUTPUT"
    env:
      RESULT_PATH: ${{ runner.temp }}/codex-sentry/result.json
      BODY_PATH: ${{ runner.temp }}/codex-sentry/pr-body.md

  - name: Sanitize branch name
    if: env.HAS_CHANGES == 'true'
    shell: bash
    run: |
      ISSUE_SLUG=$(printf '%s' "$ISSUE_REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g')
      LINEAR_SLUG=$(printf '%s' "${LINEAR_ISSUE_ID:-}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g')
      echo "ISSUE_SLUG=${ISSUE_SLUG}" >> "$GITHUB_ENV"
      echo "LINEAR_SLUG=${LINEAR_SLUG}" >> "$GITHUB_ENV"

  - name: Build PR metadata
    id: pr_metadata
    if: env.HAS_CHANGES == 'true'
    shell: bash
    run: |
      DISPLAY_TITLE=$(node <<'EOF'
      const raw = (process.env.ISSUE_TITLE || '').trim();
      if (!raw || raw === process.env.ISSUE_REF) {
        process.stdout.write('');
        process.exit(0);
      }
      const normalized = raw.replace(/\s+/g, ' ').trim();
      const compact = normalized.length > 72 ? `${normalized.slice(0, 69).trimEnd()}...` : normalized;
      process.stdout.write(compact);
      EOF
      )

      CODEX_PR_TITLE=$(node -e 'const fs=require("node:fs"); const raw=fs.readFileSync(process.env.RESULT_PATH,"utf8").trim(); const result=JSON.parse(raw); const candidate=typeof result.pr_title==="string" ? result.pr_title.replace(/\s+/g," ").trim() : ""; const normalized=candidate.startsWith("🪲") ? candidate : (candidate ? "🪲 " + candidate : ""); const compact=normalized.length > 120 ? normalized.slice(0,117).trimEnd() + "..." : normalized; process.stdout.write(compact);')

      if [[ -n "${LINEAR_ISSUE_ID:-}" ]]; then
        if [[ -n "${CODEX_PR_TITLE}" ]]; then
          PR_TITLE="${LINEAR_ISSUE_ID}: ${CODEX_PR_TITLE}"
          PR_COMMIT_MESSAGE="Part of ${LINEAR_ISSUE_ID}: fix ${ISSUE_REF}"
        elif [[ -n "${DISPLAY_TITLE}" ]]; then
          PR_TITLE="${LINEAR_ISSUE_ID}: 🪲 Fix ${ISSUE_REF}: ${DISPLAY_TITLE}"
          PR_COMMIT_MESSAGE="Part of ${LINEAR_ISSUE_ID}: fix ${ISSUE_REF} ${DISPLAY_TITLE}"
        else
          PR_TITLE="${LINEAR_ISSUE_ID}: 🪲 Fix ${ISSUE_REF}"
          PR_COMMIT_MESSAGE="Part of ${LINEAR_ISSUE_ID}: fix ${ISSUE_REF}"
        fi
        PR_BRANCH="codex/${LINEAR_SLUG}-${ISSUE_SLUG}"
      else
        if [[ -n "${CODEX_PR_TITLE}" ]]; then
          PR_TITLE="${CODEX_PR_TITLE}"
          PR_COMMIT_MESSAGE="fix: ${ISSUE_REF}"
        elif [[ -n "${DISPLAY_TITLE}" ]]; then
          PR_TITLE="🪲 Fix ${ISSUE_REF}: ${DISPLAY_TITLE}"
          PR_COMMIT_MESSAGE="fix: ${ISSUE_REF} ${DISPLAY_TITLE}"
        else
          PR_TITLE="🪲 Fix ${ISSUE_REF}"
          PR_COMMIT_MESSAGE="fix: ${ISSUE_REF}"
        fi
        PR_BRANCH="codex/sentry-${ISSUE_SLUG}"
      fi

      echo "PR_TITLE=${PR_TITLE}" >> "$GITHUB_ENV"
      echo "PR_BRANCH=${PR_BRANCH}" >> "$GITHUB_ENV"
      echo "PR_COMMIT_MESSAGE=${PR_COMMIT_MESSAGE}" >> "$GITHUB_ENV"

      echo "pr_title=${PR_TITLE}" >> "$GITHUB_OUTPUT"
      echo "pr_branch=${PR_BRANCH}" >> "$GITHUB_OUTPUT"
      echo "pr_commit_message=${PR_COMMIT_MESSAGE}" >> "$GITHUB_OUTPUT"
    env:
      RESULT_PATH: ${{ runner.temp }}/codex-sentry/result.json

  - name: Prepare PR artifacts
    if: env.HAS_CHANGES == 'true'
    shell: bash
    run: |
      mkdir -p "$RUNNER_TEMP/codex-sentry-artifacts"
      git add -A
      git diff --cached --binary > "$RUNNER_TEMP/codex-sentry-artifacts/changes.patch"
      cp "$RUNNER_TEMP/codex-sentry/pr-body.md" "$RUNNER_TEMP/codex-sentry-artifacts/pr-body.md"

  - name: Upload PR artifacts
    if: env.HAS_CHANGES == 'true'
    uses: actions/upload-artifact@v4
    with:
      name: codex-sentry-pr-${{ steps.resolve_inputs.outputs.issue_ref }}
      path: |
        ${{ runner.temp }}/codex-sentry-artifacts/changes.patch
        ${{ runner.temp }}/codex-sentry-artifacts/pr-body.md

  - name: Resolve Sentry issue automatically
    if: env.HAS_CHANGES != 'true' && (env.WORKFLOW_OUTCOME == 'resolve_without_code' || env.WORKFLOW_OUTCOME == 'ignore_without_code')
    shell: bash
    run: |
      if [[ -z "${SENTRY_AGENT_TOKEN:-}" ]]; then
        echo "Missing SENTRY_AGENT_TOKEN for no-code Sentry closure."
        exit 1
      fi

      mkdir -p "$RUNNER_TEMP/codex-sentry"
      ./node_modules/.bin/tsx .agents/scripts/sentry.ts get-issue --issue="$ISSUE_REF" --json > "$RUNNER_TEMP/codex-sentry/issue.json"

      ISSUE_ID=$(node -e "const fs=require('node:fs'); const issue=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); if(!issue.id){process.exit(1)}; process.stdout.write(String(issue.id));" "$RUNNER_TEMP/codex-sentry/issue.json")

      if [[ "$WORKFLOW_OUTCOME" == "ignore_without_code" ]]; then
        STATUS=ignored
      else
        STATUS=resolved
      fi

      curl --fail --silent --show-error \
        -X PUT \
        "https://sentry.io/api/0/organizations/ferndesk/issues/${ISSUE_ID}/" \
        -H "Authorization: Bearer ${SENTRY_AGENT_TOKEN}" \
        -H "Content-Type: application/json" \
        -d "{\"status\":\"${STATUS}\"}" > /dev/null

create_pr: needs: codex_autofix if: needs.codex_autofix.outputs.has_changes == 'true' runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0

  - name: Check for existing open PR
    id: existing_pr
    shell: bash
    env:
      GITHUB_TOKEN: ${{ github.token }}
      PR_BRANCH: ${{ needs.codex_autofix.outputs.pr_branch }}
    run: |
      node <<'EOF'
      const { appendFileSync } = require('node:fs');

      (async () => {
        const outputPath = process.env.GITHUB_OUTPUT;
        const branch = process.env.PR_BRANCH;
        const owner = 'ferndesk';
        const repo = 'ferndesk';

        if (!branch) {
          throw new Error('Missing PR_BRANCH for duplicate PR check.');
        }

        const query = new URLSearchParams({
          state: 'open',
          head: `${owner}:${branch}`
        });

        const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?${query}`, {
          headers: {
            Accept: 'application/vnd.github+json',
            Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
            'X-GitHub-Api-Version': '2022-11-28'
          }
        });

        if (!response.ok) {
          const body = await response.text();
          throw new Error(`Failed to check existing PRs (${response.status}): ${body}`);
        }

        const pulls = await response.json();
        const exists = Array.isArray(pulls) && pulls.length > 0;
        const firstUrl = exists && pulls[0]?.html_url ? String(pulls[0].html_url) : '';

        appendFileSync(outputPath, `exists=${exists}\n`);
        appendFileSync(outputPath, `url=${firstUrl}\n`);
      })().catch((error) => {
        console.error(error);
        process.exit(1);
      });
      EOF

  - name: Skip duplicate repository-dispatch update
    if: github.event_name == 'repository_dispatch' && steps.existing_pr.outputs.exists == 'true'
    shell: bash
    run: |
      echo "Open PR already exists for ${PR_BRANCH}; skipping automatic branch update."
    env:
      PR_BRANCH: ${{ needs.codex_autofix.outputs.pr_branch }}

  - name: Download PR artifacts
    if: github.event_name != 'repository_dispatch' || steps.existing_pr.outputs.exists != 'true'
    uses: actions/download-artifact@v4
    with:
      name: codex-sentry-pr-${{ needs.codex_autofix.outputs.issue_ref }}
      path: ${{ runner.temp }}/codex-sentry-artifacts

  - name: Apply Codex patch
    if: github.event_name != 'repository_dispatch' || steps.existing_pr.outputs.exists != 'true'
    shell: bash
    run: |
      git apply --binary --index "${RUNNER_TEMP}/codex-sentry-artifacts/changes.patch"

  - name: Create PR
    if: github.event_name != 'repository_dispatch' || steps.existing_pr.outputs.exists != 'true'
    uses: peter-evans/create-pull-request@v7
    with:
      branch: ${{ needs.codex_autofix.outputs.pr_branch }}
      commit-message: ${{ needs.codex_autofix.outputs.pr_commit_message }}
      title: ${{ needs.codex_autofix.outputs.pr_title }}
      body-path: ${{ runner.temp }}/codex-sentry-artifacts/pr-body.md
      draft: false
      labels: |
        codex
        sentry
const SENTRY_ORG_SLUG = '';
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
type CliFlags = Record<string, string | string[] | boolean>;
type ParsedArgs = {
command: string;
positionals: string[];
flags: CliFlags;
};
type SentryIssue = {
id?: string | number;
shortId?: string;
title?: string;
culprit?: string;
level?: string;
status?: string;
permalink?: string;
project?: {
id?: string | number;
name?: string;
slug?: string;
} | null;
metadata?: Record<string, unknown> | null;
firstSeen?: string;
lastSeen?: string;
count?: string | number;
userCount?: number;
type?: string;
assignedTo?: {
id?: string | number;
name?: string | null;
email?: string | null;
} | null;
};
type SentryEvent = {
id?: string;
eventID?: string;
groupID?: string | number;
title?: string;
message?: string;
platform?: string;
type?: string;
level?: string;
dateCreated?: string;
tags?: Array<{ key?: string; value?: string }>;
contexts?: Record<string, unknown>;
entries?: Array<{
type?: string;
data?: Record<string, unknown>;
}>;
metadata?: Record<string, unknown> | null;
};
type SentryTagValue = {
key?: string;
name?: string;
value?: string;
count?: number;
firstSeen?: string;
lastSeen?: string;
};
type SentryIssueHash = {
id?: string;
count?: number;
firstSeen?: string;
lastSeen?: string;
latestEvent?: {
id?: string;
eventID?: string;
title?: string;
message?: string;
metadata?: Record<string, unknown> | null;
tags?: Array<{ key?: string; value?: string }>;
};
};
class SentryApiError extends Error {
status: number;
body: unknown;
constructor(message: string, status: number, body: unknown) {
super(message);
this.name = 'SentryApiError';
this.status = status;
this.body = body;
}
}
function parseArgs(argv: string[]): ParsedArgs {
const flags: CliFlags = {};
const positionals: string[] = [];
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (!current.startsWith('--')) {
positionals.push(current);
continue;
}
const flag = current.slice(2);
const equalsIndex = flag.indexOf('=');
if (equalsIndex >= 0) {
const key = flag.slice(0, equalsIndex);
const value = flag.slice(equalsIndex + 1);
appendFlag(flags, key, value);
continue;
}
const next = argv[index + 1];
if (next && !next.startsWith('--')) {
appendFlag(flags, flag, next);
index += 1;
continue;
}
appendFlag(flags, flag, true);
}
const [command = 'help', ...rest] = positionals;
return {
command,
positionals: rest,
flags
};
}
function appendFlag(flags: CliFlags, key: string, value: string | boolean) {
const existing = flags[key];
if (existing === undefined) {
flags[key] = value;
return;
}
if (Array.isArray(existing)) {
existing.push(String(value));
return;
}
flags[key] = [String(existing), String(value)];
}
function getFlag(flags: CliFlags, key: string): string | undefined {
const value = flags[key];
if (Array.isArray(value)) {
return value.at(-1);
}
if (typeof value === 'string') {
return value;
}
return undefined;
}
function getFlagValues(flags: CliFlags, key: string): string[] {
const value = flags[key];
if (Array.isArray(value)) {
return value.map(String);
}
if (typeof value === 'string') {
return [value];
}
return [];
}
function hasFlag(flags: CliFlags, key: string): boolean {
const value = flags[key];
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
return !['false', '0', 'no', 'off'].includes(value.trim().toLowerCase());
}
return false;
}
function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function normalizeBaseUrl(value?: string): string {
const base = value?.trim() || 'https://sentry.io/api/0/';
return base.endsWith('/') ? base : `${base}/`;
}
function parseIssueIdFromInput(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) return trimmed;
try {
const url = new URL(trimmed);
const match = url.pathname.match(/\/issues\/(\d+)\b/);
return match?.[1] ?? null;
} catch {
return null;
}
}
function parseJsonBody(text: string): unknown {
if (!text) return null;
try {
return JSON.parse(text) as JsonValue;
} catch {
return text;
}
}
class SentryClient {
private readonly token: string;
private readonly orgSlug: string;
private readonly baseUrl: string;
constructor(config: { token: string; orgSlug: string; baseUrl?: string }) {
this.token = config.token;
this.orgSlug = config.orgSlug;
this.baseUrl = normalizeBaseUrl(config.baseUrl);
}
async request<T>(
pathname: string,
options?: {
method?: 'GET' | 'POST';
query?: Record<string, string | boolean | number | undefined>;
body?: Record<string, unknown>;
}
): Promise<T> {
const url = new URL(pathname.replace(/^\//, ''), this.baseUrl);
for (const [key, value] of Object.entries(options?.query ?? {})) {
if (value === undefined) continue;
url.searchParams.set(key, String(value));
}
const response = await fetch(url, {
method: options?.method ?? 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${this.token}`,
...(options?.body ? { 'Content-Type': 'application/json' } : {})
},
body: options?.body ? JSON.stringify(options.body) : undefined
});
const text = await response.text();
const body = parseJsonBody(text);
if (!response.ok) {
throw new SentryApiError(
`Sentry API request failed with ${response.status} ${response.statusText}`,
response.status,
body
);
}
return body as T;
}
async resolveIssueId(issueRef: string): Promise<string> {
const directId = parseIssueIdFromInput(issueRef);
if (directId) return directId;
const query = issueRef.trim();
if (!query) {
throw new Error('Missing issue reference.');
}
const issues = await this.request<SentryIssue[]>(
`organizations/${this.orgSlug}/issues/`,
{
query: {
query,
shortIdLookup: 1
}
}
);
const exact =
issues.find((issue) => issue.shortId?.toLowerCase() === query.toLowerCase()) ?? issues[0];
if (!exact?.id) {
throw new Error(
`Could not resolve issue reference "${issueRef}". Pass a numeric issue id, issue URL, or short id.`
);
}
return String(exact.id);
}
async getIssue(issueId: string) {
return this.request<SentryIssue>(`organizations/${this.orgSlug}/issues/${issueId}/`);
}
async listIssues(options?: {
query?: string;
limit?: number;
project?: string[];
environment?: string[];
}) {
const query: Record<string, string | boolean | number | undefined> = {};
if (options?.query) {
query.query = options.query;
}
if (options?.limit !== undefined) {
query.limit = options.limit;
}
if ((options?.project?.length ?? 0) > 0) {
query.project = options?.project?.join(',');
}
if ((options?.environment?.length ?? 0) > 0) {
query.environment = options?.environment?.join(',');
}
return this.request<SentryIssue[]>(`organizations/${this.orgSlug}/issues/`, { query });
}
async listIssueEvents(issueId: string, options?: { full?: boolean; environment?: string[] }) {
const query: Record<string, string | boolean | number | undefined> = {};
if (options?.full) {
query.full = true;
}
const environments = options?.environment ?? [];
if (environments.length > 0) {
query.environment = environments.join(',');
}
return this.request<SentryEvent[]>(
`organizations/${this.orgSlug}/issues/${issueId}/events/`,
{ query }
);
}
async getIssueEvent(issueId: string, eventId: string, options?: { environment?: string[] }) {
const query: Record<string, string | boolean | number | undefined> = {};
const environments = options?.environment ?? [];
if (environments.length > 0) {
query.environment = environments.join(',');
}
return this.request<SentryEvent>(
`organizations/${this.orgSlug}/issues/${issueId}/events/${eventId}/`,
{ query }
);
}
async getIssueTagValues(
issueId: string,
key: string,
options?: { sort?: string; environment?: string[] }
) {
const query: Record<string, string | boolean | number | undefined> = {};
if (options?.sort) {
query.sort = options.sort;
}
const environments = options?.environment ?? [];
if (environments.length > 0) {
query.environment = environments.join(',');
}
return this.request<SentryTagValue[]>(
`organizations/${this.orgSlug}/issues/${issueId}/tags/${encodeURIComponent(key)}/values/`,
{ query }
);
}
async getIssueHashes(issueId: string, options?: { full?: boolean }) {
const query: Record<string, string | boolean | number | undefined> = {};
if (options?.full) {
query.full = 1;
}
return this.request<SentryIssueHash[]>(
`organizations/${this.orgSlug}/issues/${issueId}/hashes/`,
{ query }
);
}
async getReplayCount(options: {
query: string;
statsPeriod?: string;
project?: string[];
environment?: string[];
}) {
const query: Record<string, string | boolean | number | undefined> = {
query: options.query
};
if (options.statsPeriod) {
query.statsPeriod = options.statsPeriod;
}
if ((options.project?.length ?? 0) > 0) {
query.project_id_or_slug = options.project?.join(',');
}
if ((options.environment?.length ?? 0) > 0) {
query.environment = options.environment?.join(',');
}
return this.request<Record<string, number>>(
`organizations/${this.orgSlug}/replay-count/`,
{ query }
);
}
async getSourceMapDebug(options: {
project: string;
eventId: string;
exceptionIndex: number;
frameIndex: number;
}) {
return this.request<unknown>(
`projects/${this.orgSlug}/${options.project}/events/${options.eventId}/source-map-debug/`,
{
query: {
exception_idx: options.exceptionIndex,
frame_idx: options.frameIndex
}
}
);
}
async queryExplore(options: {
dataset: 'logs' | 'spans';
query?: string;
fields: string[];
limit?: number;
sort?: string;
statsPeriod?: string;
}) {
const query: Record<string, string | boolean | number | undefined> = {
dataset: options.dataset,
per_page: options.limit ?? 10
};
if (options.query) {
query.query = options.query;
}
if (options.sort) {
query.sort = options.sort;
}
if (options.statsPeriod) {
query.statsPeriod = options.statsPeriod;
}
const url = new URL(`organizations/${this.orgSlug}/events/`, this.baseUrl);
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue;
url.searchParams.set(key, String(value));
}
for (const field of options.fields) {
url.searchParams.append('field', field);
}
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${this.token}`
}
});
const text = await response.text();
const body = parseJsonBody(text);
if (!response.ok) {
throw new SentryApiError(
`Sentry API request failed with ${response.status} ${response.statusText}`,
response.status,
body
);
}
return body as {
data: Array<Record<string, unknown>>;
meta?: Record<string, unknown>;
};
}
}
function extractExceptionSummary(event: SentryEvent | null | undefined) {
if (!event?.entries) return null;
const exceptionEntry = event.entries.find((entry) => entry.type === 'exception');
const values = Array.isArray(exceptionEntry?.data?.values)
? (exceptionEntry?.data?.values as Array<Record<string, unknown>>)
: [];
if (values.length === 0) return null;
const primary = values[0];
const stacktrace = primary.stacktrace as Record<string, unknown> | undefined;
const frames = Array.isArray(stacktrace?.frames)
? (stacktrace?.frames as Array<Record<string, unknown>>)
: [];
const inAppFrames = frames.filter((frame) => frame.inApp !== false);
const selectedFrames = (inAppFrames.length > 0 ? inAppFrames : frames).slice(-8);
return {
type: typeof primary.type === 'string' ? primary.type : null,
value: typeof primary.value === 'string' ? primary.value : null,
mechanism:
typeof (primary.mechanism as Record<string, unknown> | undefined)?.type === 'string'
? ((primary.mechanism as Record<string, unknown>).type as string)
: null,
frames: selectedFrames.map((frame) => ({
function:
typeof frame.function === 'string'
? frame.function
: typeof frame.raw_function === 'string'
? frame.raw_function
: null,
file:
typeof frame.filename === 'string'
? frame.filename
: typeof frame.absPath === 'string'
? frame.absPath
: null,
line: typeof frame.lineNo === 'number' ? frame.lineNo : null,
column: typeof frame.colNo === 'number' ? frame.colNo : null,
inApp: frame.inApp !== false
}))
};
}
function extractBreadcrumbSummary(event: SentryEvent | null | undefined) {
if (!event?.entries) return [];
const breadcrumbEntry = event.entries.find((entry) => entry.type === 'breadcrumbs');
const values = Array.isArray(breadcrumbEntry?.data?.values)
? (breadcrumbEntry.data?.values as Array<Record<string, unknown>>)
: [];
return values.slice(-10).map((crumb) => ({
timestamp: typeof crumb.timestamp === 'string' ? crumb.timestamp : null,
type: typeof crumb.type === 'string' ? crumb.type : null,
category: typeof crumb.category === 'string' ? crumb.category : null,
level: typeof crumb.level === 'string' ? crumb.level : null,
message: typeof crumb.message === 'string' ? crumb.message : null,
data:
crumb.data && typeof crumb.data === 'object'
? (crumb.data as Record<string, unknown>)
: null
}));
}
function extractTraceSummary(event: SentryEvent | null | undefined) {
const trace = (event?.contexts?.trace as Record<string, unknown> | undefined) ?? null;
if (!trace) return null;
return {
traceId: typeof trace.trace_id === 'string' ? trace.trace_id : null,
spanId: typeof trace.span_id === 'string' ? trace.span_id : null,
op: typeof trace.op === 'string' ? trace.op : null,
status: typeof trace.status === 'string' ? trace.status : null
};
}
function summarizeEvent(event: SentryEvent | null | undefined) {
if (!event) return null;
return {
id: event.id ?? null,
eventID: event.eventID ?? null,
title: event.title ?? null,
message: event.message ?? null,
platform: event.platform ?? null,
type: event.type ?? null,
dateCreated: event.dateCreated ?? null,
metadata: event.metadata ?? null,
trace: extractTraceSummary(event),
exception: extractExceptionSummary(event),
breadcrumbs: extractBreadcrumbSummary(event),
tags: (event.tags ?? []).map((tag) => ({
key: tag.key ?? null,
value: tag.value ?? null
}))
};
}
function printHelp() {
console.log(`
Local Sentry CLI
Usage:
pnpm agent:sentry <command> [flags]
Commands:
list-issues Browse recent issues in the org
query-logs Query Sentry logs through the Explore API
query-traces Query Sentry spans through the Explore API
get-issue Fetch a Sentry issue by numeric ID, issue URL, or short ID
issue-tags List values for a specific issue tag key
issue-hashes List an issue's grouping hashes
replay-count Count replays for an issue
source-map-debug Ask Sentry why a specific event frame failed sourcemap resolution
list-events Fetch recent events for an issue
get-event Fetch a specific issue event, or "recommended", "latest", "oldest"
bundle Build a normalized JSON bundle for a Codex-style debugging prompt
Environment:
SENTRY_AGENT_TOKEN Required bearer token
Common flags:
--query=<value> Search query for list-issues
--field=<value> Repeatable Explore field selection
--issue=<value> Numeric issue ID, issue URL, or short ID
--project=<slug> Repeatable project filter for list-issues
--key=<value> Tag key for issue-tags
--event=<value> Event ID or one of recommended/latest/oldest
--stats-period=<v> Replay window, for example 24h or 7d
--sort=<value> Explore sort field, for example -timestamp
--exception-idx=<n> Exception index for source-map-debug
--frame-idx=<n> Frame index for source-map-debug
--environment=<env> Repeatable environment filter
--json Print raw JSON where supported
Examples:
pnpm agent:sentry list-issues --limit=10
pnpm agent:sentry list-issues --project=engine --query='is:unresolved'
pnpm agent:sentry query-logs --query='severity:warn project.name:engine'
pnpm agent:sentry query-traces --query='trace:67bc386862b3534c08713e1ad3a98d72'
pnpm agent:sentry get-issue --issue=ENG-123
pnpm agent:sentry issue-tags --issue=APP-5 --key=release
pnpm agent:sentry issue-hashes --issue=APP-5
pnpm agent:sentry replay-count --issue=APP-5 --stats-period=7d
pnpm agent:sentry source-map-debug --project=app --event=<event_id> --exception-idx=0 --frame-idx=0
pnpm agent:sentry list-events --issue=https://sentry.io/organizations/acme/issues/123/ --json
pnpm agent:sentry get-event --issue=123 --event=recommended
pnpm agent:sentry bundle --issue=123 > sentry-bundle.json
`.trim());
}
function printPrettyIssueList(issues: SentryIssue[]) {
if (issues.length === 0) {
console.log('No issues returned.');
return;
}
for (const [index, issue] of issues.entries()) {
console.log(
`${index + 1}. ${issue.shortId ?? issue.id ?? 'unknown'} | ${issue.status ?? 'n/a'} | ${issue.level ?? 'n/a'} | ${issue.project?.slug ?? issue.project?.name ?? 'n/a'} | ${issue.title ?? 'Untitled'}`
);
}
}
function printPrettyTagValues(values: SentryTagValue[]) {
if (values.length === 0) {
console.log('No tag values returned.');
return;
}
for (const [index, value] of values.entries()) {
console.log(
`${index + 1}. ${value.value ?? value.name ?? value.key ?? 'unknown'} | count=${value.count ?? 0} | firstSeen=${value.firstSeen ?? 'n/a'} | lastSeen=${value.lastSeen ?? 'n/a'}`
);
}
}
function printPrettyIssueHashes(hashes: SentryIssueHash[]) {
if (hashes.length === 0) {
console.log('No hashes returned.');
return;
}
for (const [index, hash] of hashes.entries()) {
console.log(
`${index + 1}. ${hash.id ?? 'unknown'} | latestEvent=${hash.latestEvent?.eventID ?? hash.latestEvent?.id ?? 'n/a'} | ${hash.latestEvent?.title ?? hash.latestEvent?.message ?? 'Untitled'}`
);
}
}
function printPrettyExploreRows(rows: Array<Record<string, unknown>>, fields: string[]) {
if (rows.length === 0) {
console.log('No rows returned.');
return;
}
for (const [index, row] of rows.entries()) {
const summary = fields
.map((field) => `${field}=${formatExploreValue(row[field])}`)
.join(' | ');
console.log(`${index + 1}. ${summary}`);
}
}
function formatExploreValue(value: unknown) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
function printPrettyIssue(issue: SentryIssue) {
console.log(`Issue ${issue.shortId ?? issue.id ?? 'unknown'}`);
console.log(`Title: ${issue.title ?? 'n/a'}`);
console.log(`Status: ${issue.status ?? 'n/a'}`);
console.log(`Level: ${issue.level ?? 'n/a'}`);
console.log(`Project: ${issue.project?.slug ?? issue.project?.name ?? 'n/a'}`);
console.log(`First seen: ${issue.firstSeen ?? 'n/a'}`);
console.log(`Last seen: ${issue.lastSeen ?? 'n/a'}`);
console.log(`Count: ${issue.count ?? 'n/a'}`);
console.log(`Users: ${issue.userCount ?? 'n/a'}`);
console.log(`Culprit: ${issue.culprit ?? 'n/a'}`);
if (issue.permalink) {
console.log(`Permalink: ${issue.permalink}`);
}
if (issue.assignedTo) {
console.log(
`Assigned to: ${issue.assignedTo.name ?? issue.assignedTo.email ?? issue.assignedTo.id ?? 'n/a'}`
);
}
}
function printPrettyEvents(events: SentryEvent[]) {
if (events.length === 0) {
console.log('No events returned.');
return;
}
for (const [index, event] of events.entries()) {
console.log(
`${index + 1}. ${event.eventID ?? event.id ?? 'unknown'} | ${event.dateCreated ?? 'n/a'} | ${event.title ?? event.message ?? 'Untitled'}`
);
}
}
function printPrettyEvent(event: SentryEvent) {
const summary = summarizeEvent(event);
console.log(`Event ${summary?.eventID ?? summary?.id ?? 'unknown'}`);
console.log(`Title: ${summary?.title ?? 'n/a'}`);
console.log(`Date: ${summary?.dateCreated ?? 'n/a'}`);
console.log(`Platform: ${summary?.platform ?? 'n/a'}`);
console.log(`Type: ${summary?.type ?? 'n/a'}`);
if (summary?.trace?.traceId) {
console.log(`Trace: ${summary.trace.traceId}`);
}
if (summary?.exception?.type || summary?.exception?.value) {
console.log(
`Exception: ${summary.exception.type ?? 'unknown'}${summary.exception.value ? `: ${summary.exception.value}` : ''}`
);
}
if ((summary?.exception?.frames?.length ?? 0) > 0) {
console.log('Frames:');
for (const frame of summary!.exception!.frames) {
console.log(
` - ${frame.function ?? '<anonymous>'} (${frame.file ?? 'unknown'}:${frame.line ?? '?'})`
);
}
}
}
function printJson(value: unknown) {
console.log(JSON.stringify(value, null, 2));
}
function getOptionalIssueFlag(flags: CliFlags) {
const issue = getFlag(flags, 'issue') ?? '';
if (!issue) {
throw new Error('Missing issue reference. Provide --issue.');
}
return issue;
}
function getEnvironmentFilters(flags: CliFlags) {
return getFlagValues(flags, 'environment');
}
function requireFlag(flags: CliFlags, key: string, message?: string) {
const value = getFlag(flags, key);
if (!value) {
throw new Error(message ?? `Missing required flag --${key}.`);
}
return value;
}
async function main() {
const { command, flags } = parseArgs(process.argv.slice(2));
if (command === 'help' || hasFlag(flags, 'help')) {
printHelp();
return;
}
const client = new SentryClient({
token: requireEnv('SENTRY_AGENT_TOKEN'),
orgSlug: SENTRY_ORG_SLUG,
baseUrl: 'https://sentry.io/api/0/'
});
const json = hasFlag(flags, 'json');
const environments = getEnvironmentFilters(flags);
const projects = getFlagValues(flags, 'project');
const exploreFields = getFlagValues(flags, 'field');
switch (command) {
case 'list-issues': {
const limit = Number(getFlag(flags, 'limit') ?? '10');
const issues = await client.listIssues({
query: getFlag(flags, 'query'),
limit: Number.isFinite(limit) ? Math.max(limit, 1) : 10,
project: projects,
environment: environments
});
if (json) {
printJson(issues);
} else {
printPrettyIssueList(issues);
}
return;
}
case 'query-logs': {
const fields =
exploreFields.length > 0
? exploreFields
: ['timestamp', 'project.name', 'severity_text', 'trace', 'message'];
const result = await client.queryExplore({
dataset: 'logs',
query: getFlag(flags, 'query'),
fields,
limit: Number(getFlag(flags, 'limit') ?? '10'),
sort: getFlag(flags, 'sort') ?? '-timestamp',
statsPeriod: getFlag(flags, 'stats-period')
});
if (json) {
printJson(result);
} else {
printPrettyExploreRows(result.data, fields);
}
return;
}
case 'query-traces': {
const fields =
exploreFields.length > 0
? exploreFields
: ['timestamp', 'project.name', 'transaction', 'span.op', 'span.duration', 'trace', 'id'];
const result = await client.queryExplore({
dataset: 'spans',
query: getFlag(flags, 'query'),
fields,
limit: Number(getFlag(flags, 'limit') ?? '10'),
sort: getFlag(flags, 'sort') ?? '-timestamp',
statsPeriod: getFlag(flags, 'stats-period')
});
if (json) {
printJson(result);
} else {
printPrettyExploreRows(result.data, fields);
}
return;
}
case 'get-issue': {
const issueRef = getOptionalIssueFlag(flags);
const issueId = await client.resolveIssueId(issueRef);
const issue = await client.getIssue(issueId);
if (json) {
printJson(issue);
} else {
printPrettyIssue(issue);
}
return;
}
case 'issue-tags': {
const issueRef = getOptionalIssueFlag(flags);
const issueId = await client.resolveIssueId(issueRef);
const key = requireFlag(flags, 'key', 'Missing tag key. Provide --key.');
const values = await client.getIssueTagValues(issueId, key, {
sort: getFlag(flags, 'sort'),
environment: environments
});
if (json) {
printJson(values);
} else {
printPrettyTagValues(values);
}
return;
}
case 'issue-hashes': {
const issueRef = getOptionalIssueFlag(flags);
const issueId = await client.resolveIssueId(issueRef);
const hashes = await client.getIssueHashes(issueId, { full: hasFlag(flags, 'full') });
if (json) {
printJson(hashes);
} else {
printPrettyIssueHashes(hashes);
}
return;
}
case 'replay-count': {
const issueRef = getOptionalIssueFlag(flags);
const issueId = await client.resolveIssueId(issueRef);
const result = await client.getReplayCount({
query: `issue.id:["${issueId}"]`,
statsPeriod: getFlag(flags, 'stats-period') ?? '14d',
project: projects,
environment: environments
});
if (json) {
printJson(result);
} else {
console.log(`Replay count for issue ${issueRef}: ${result[issueId] ?? 0}`);
}
return;
}
case 'source-map-debug': {
const project = requireFlag(flags, 'project', 'Missing project slug. Provide --project.');
const eventId = requireFlag(flags, 'event', 'Missing event id. Provide --event.');
const exceptionIndex = Number(getFlag(flags, 'exception-idx') ?? '0');
const frameIndex = Number(getFlag(flags, 'frame-idx') ?? '0');
const result = await client.getSourceMapDebug({
project,
eventId,
exceptionIndex,
frameIndex
});
printJson(result);
return;
}
case 'list-events': {
const issueRef = getOptionalIssueFlag(flags);
const issueId = await client.resolveIssueId(issueRef);
const limit = Number(getFlag(flags, 'limit') ?? '5');
const events = await client.listIssueEvents(issueId, {
full: hasFlag(flags, 'full'),
environment: environments
});
const sliced = events.slice(0, Number.isFinite(limit) ? Math.max(limit, 0) : 5);
if (json) {
printJson(sliced);
} else {
printPrettyEvents(sliced);
}
return;
}
case 'get-event': {
const issueRef = getOptionalIssueFlag(flags);
const issueId = await client.resolveIssueId(issueRef);
const eventId = getFlag(flags, 'event') ?? 'recommended';
const event = await client.getIssueEvent(issueId, eventId, {
environment: environments
});
if (json) {
printJson(event);
} else {
printPrettyEvent(event);
}
return;
}
case 'bundle': {
const issueRef = getOptionalIssueFlag(flags);
const issueId = await client.resolveIssueId(issueRef);
const issue = await client.getIssue(issueId);
const [recommendedEvent, recentEvents] = await Promise.all([
client.getIssueEvent(issueId, getFlag(flags, 'event') ?? 'recommended', {
environment: environments
}),
client.listIssueEvents(issueId, { full: false, environment: environments })
]);
const bundle = {
organization: SENTRY_ORG_SLUG,
issue: {
id: issue.id ?? null,
shortId: issue.shortId ?? null,
title: issue.title ?? null,
status: issue.status ?? null,
level: issue.level ?? null,
type: issue.type ?? null,
project: issue.project ?? null,
firstSeen: issue.firstSeen ?? null,
lastSeen: issue.lastSeen ?? null,
culprit: issue.culprit ?? null,
permalink: issue.permalink ?? null,
metadata: issue.metadata ?? null
},
recommendedEvent: summarizeEvent(recommendedEvent),
recentEvents: recentEvents.slice(0, 5).map((event) => ({
id: event.id ?? null,
eventID: event.eventID ?? null,
title: event.title ?? null,
message: event.message ?? null,
dateCreated: event.dateCreated ?? null,
metadata: event.metadata ?? null
}))
};
printJson(bundle);
return;
}
default:
throw new Error(`Unknown command "${command}". Run with "help" to see supported commands.`);
}
}
main().catch((error) => {
if (error instanceof SentryApiError) {
console.error(error.message);
console.error(JSON.stringify(error.body, null, 2));
process.exit(1);
}
if (error instanceof Error) {
console.error(error.message);
process.exit(1);
}
console.error(String(error));
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment