chore: publish from main

This commit is contained in:
github-actions[bot]
2026-08-26 00:04:55 +00:00
parent 00ce5234bd
commit 42e27e0fdc
40 changed files with 14473 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# Sentry Triage Plugin
Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand
issues off for tracking or a fix PR.
## Installation
```bash
copilot plugin install sentry-triage@awesome-copilot
```
## Source
This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot).
## License
MIT
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Liz Tom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,111 @@
# Sentry Triage Canvas
A GitHub Copilot canvas for on-call error triage. It scans your live Sentry
issues, groups them by why they need attention, and lets you hand a selected
issue off to Copilot — either to file a tracking issue or to spin up a session
that drafts a fix pull request.
## Features
- **Live scan** of Sentry issues for an organization (optionally narrowed to a
single project), grouped by urgency.
- **Plain-English titles** toggle that rewrites the raw Sentry error into a
one-sentence, user-facing summary.
- **Tracking detection** so already-triaged issues (filed with the
`sentry-triage` label) are recognized on later scans.
- **📝 Create issue** files or reuses a tracking issue only — no code, branch, or
PR.
- **🔧 Fix with Copilot** files/reuses the tracking issue **and** spawns a
dedicated session that drafts a fix PR.
- **Settings panel** to confirm the repository, local checkout, base branch, and
issue tracker before anything is created.
## Files
- `extension.mjs` — canvas declaration, agent hand-off tools, and orchestration.
- `server.mjs` — loopback HTTP server that backs the canvas webview.
- `state.mjs` — per-canvas state and render coordination.
- `styles.mjs` — canvas styling.
- `sentry.mjs` / `sentryClient.mjs` — Sentry CLI (library mode) access: scan
issues, list orgs and projects.
- `preflight.mjs` — Sentry sign-in / connection checks with the setup gate.
- `prefs.mjs` — persisted user preferences.
- `escape.mjs` — prompt/HTML escaping helpers.
- `components/``page.mjs`, `card.mjs`, and `category.mjs` webview components.
- `assets/preview.png` — preview image for the extensions gallery.
- `package.json` — ESM entry point and runtime dependencies.
- `copilot-extension.json` — Copilot extension name/version metadata.
## Prerequisites
- **Node.js 22 or newer.**
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
- A Sentry sign-in. This canvas reads issues through the
[Sentry CLI](https://cli.sentry.dev) in library mode — there is no MCP server
to configure. Sign in once from your terminal:
```sh
sentry auth login
```
This stores an OAuth credential the canvas auto-detects. For non-interactive
environments, export a token instead:
```sh
export SENTRY_AUTH_TOKEN=<your-token>
```
The login or token needs these scopes: `event:read`, `org:read`,
`project:read`. The canvas checks for a valid sign-in each time it opens; if
you aren't signed in it shows a setup gate with the exact reason.
## Install
Drop this folder at `~/.copilot/extensions/sentry-triage/` for user scope, or in
a repository at `.github/extensions/sentry-triage/` for project scope. Then
install dependencies from inside the copied folder (this canvas depends on the
`sentry` npm package at runtime):
```sh
# User scope
cd ~/.copilot/extensions/sentry-triage
# Or project scope, from the repository root
cd .github/extensions/sentry-triage
npm install
```
Reload extensions in the GitHub Copilot app, then open the `sentry-triage`
canvas.
## Open the canvas in the correct repository scope
Open the canvas from a Copilot session scoped to the repository where you want
issues and draft pull requests created, so it can detect the repository, local
checkout, and base branch automatically. If you opened it outside the intended
repository, open **Settings** and confirm the targets before selecting **Create
issue** or **Fix with Copilot**.
## Use the canvas
1. Open the canvas from the repository you want to work in.
2. Confirm the Sentry **organization** (auto-detected from your sign-in). If your
account has more than one org, pick it from the dropdown. **Scan issues**
stays disabled until an organization is set.
3. Optionally narrow to a single **project** using the autocomplete field. Leave
it blank to scan the whole org.
4. Review the issues grouped by why they need attention. Toggle **Plain-English
titles** to swap the raw Sentry error for a readable summary.
5. Select issues and choose an action in the toolbar — **📝 Create issue** or
**🔧 Fix with Copilot**.
## Agent tools
The canvas exposes structured hand-off tools the agent calls instead of printing
JSON into the timeline: `submit_issue_summaries`, `submit_projects`,
`submit_tracking`, `submit_related`, and `submit_work_pr`.
## License
MIT — see [LICENSE](LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

@@ -0,0 +1,115 @@
import { escapeHtml, safeHref } from '../escape.mjs'
function statusLabel(workStatus) {
if (!workStatus || typeof workStatus !== 'object') return ''
if (workStatus.phase === 'working' || workStatus.phase === 'queued') return '⏳ working…'
if (workStatus.phase === 'done') {
// Numbers move into clickable links (statusLinks); keep the label a plain badge.
return 'created ✓'
}
if (workStatus.phase === 'handed-off') {
const sessionPart = workStatus.sessionName ? `🧵 ${workStatus.sessionName}` : '🧵 session started'
return `${sessionPart}`
}
if (workStatus.phase === 'skipped') return '🔒 already being worked on'
if (workStatus.phase === 'tracked') return '👀 Tracked'
if (workStatus.phase === 'error') return `⚠️ ${workStatus.error || 'work failed'}`
return ''
}
function stateSuffix(stateStr) {
const s = typeof stateStr === 'string' ? stateStr.trim().toLowerCase() : ''
return s ? ` (${s})` : ''
}
function linkOrText(url, label) {
return url
? `<a href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
: `<span>${escapeHtml(label)}</span>`
}
function statusLinks(workStatus) {
if (!workStatus || typeof workStatus !== 'object') return ''
const links = []
const phase = workStatus.phase
// Freshly created artifacts (done / handed-off): link the new issue and PR the
// same way tracked/skipped link their existing ones, so the numbers are clickable.
if (phase === 'done' || phase === 'handed-off') {
if (workStatus.issueUrl || workStatus.issueNumber) {
const base = workStatus.issueNumber ? `issue #${workStatus.issueNumber}` : 'issue created'
links.push(linkOrText(workStatus.issueUrl, base))
}
if ((phase === 'done' || phase === 'handed-off') && (workStatus.prUrl || workStatus.prNumber)) {
const base = workStatus.prNumber ? `PR #${workStatus.prNumber}` : 'PR opened'
const label = `${base}${stateSuffix(workStatus.prState)}`
links.push(linkOrText(workStatus.prUrl, label))
}
}
if (workStatus.existingIssueUrl) {
const base = workStatus.existingIssueNumber ? `issue #${workStatus.existingIssueNumber}` : 'existing issue'
const label = `${base}${stateSuffix(workStatus.existingIssueState)}`
links.push(linkOrText(workStatus.existingIssueUrl, label))
}
if (workStatus.existingPrUrl) {
const base = workStatus.existingPrNumber ? `PR #${workStatus.existingPrNumber}` : 'existing PR'
const label = `${base}${stateSuffix(workStatus.existingPrState)}`
links.push(linkOrText(workStatus.existingPrUrl, label))
}
return links.join(' · ')
}
export function Card({ key, summary, plainEnglish, reason, events, users, url, workStatus, plainEnglishView, availableModels = [] }) {
const href = safeHref(url)
const safeKey = escapeHtml(key)
const title = plainEnglishView ? (plainEnglish || summary) : summary
const status = statusLabel(workStatus)
const links = statusLinks(workStatus)
const statusClass = workStatus?.phase === 'done'
? 'done'
: workStatus?.phase === 'handed-off'
? 'handed-off'
: workStatus?.phase === 'skipped'
? 'skipped'
: workStatus?.phase === 'tracked'
? 'tracked'
: workStatus?.phase === 'error'
? 'error'
: workStatus?.phase === 'working' || workStatus?.phase === 'queued'
? 'working'
: 'idle'
const meta = [
events != null ? `${Number(events).toLocaleString()} events` : null,
users != null ? `${Number(users).toLocaleString()} users` : null
].filter(Boolean).join(' · ')
// Per-card model override. Empty value means "use the toolbar/batch default".
const cardModelOptions = ['<option value="">Default</option>']
.concat(
(Array.isArray(availableModels) ? availableModels : [])
.filter((model) => model && model.id)
.map((model) => `<option value="${escapeHtml(model.id)}">${escapeHtml(model.label)}</option>`)
)
.join('')
return `<div class="card" data-key="${safeKey}">
<label class="card-checkbox" title="Select this issue">
<input type="checkbox" class="issue-check" data-key="${safeKey}" aria-label="Select issue ${safeKey}" />
</label>
<div class="card-link">
<div class="card-header">
<a href="${href}" target="_blank" rel="noopener noreferrer" class="card-key">${safeKey}</a>
<span class="card-summary">${escapeHtml(title)}</span>
</div>
<span class="card-reason">${escapeHtml(reason)}</span>
${meta ? `<span class="card-meta">${meta}</span>` : ''}
<span class="card-work-status ${statusClass}" data-key="${safeKey}" ${status ? '' : 'style="display:none;"'}>
${escapeHtml(status)}
${links ? `<span class="card-work-links">${links}</span>` : ''}
</span>
</div>
<label class="card-model-wrap" data-key="${safeKey}" style="display:none;" title="Model for this issue's fix session — Default uses the model chosen above the list">
<span class="card-model-label">Model</span>
<select class="card-model" data-key="${safeKey}">${cardModelOptions}</select>
</label>
</div>`
}
@@ -0,0 +1,29 @@
import { Card } from './card.mjs'
import { escapeHtml } from '../escape.mjs'
const CATEGORY_DESCRIPTIONS = {
'regressions': 'Issues that previously existed, were resolved, and have resurfaced — likely tied to a recent release.',
'escalating': 'Issues Sentry flagged as escalating, plus older high-impact issues affecting many users or generating many events.',
'new-critical': 'Brand new issues that have already hit multiple users within hours of first appearing.'
}
export function Category({ id, name, issues, plainEnglishView, availableModels = [] }) {
if (!issues.length) return ''
const description = CATEGORY_DESCRIPTIONS[id] || ''
const safeId = escapeHtml(id)
return `<section class="category" data-category="${safeId}">
<div class="category-header">
<h2>${escapeHtml(name)} <span class="badge">${issues.length}</span></h2>
<label class="category-select-all">
<input type="checkbox" class="category-check" data-category="${safeId}" />
Select all
</label>
${description ? `<p class="category-description">${description}</p>` : ''}
</div>
<div class="card-list">
${issues.map((issue) => Card({ ...issue, plainEnglishView, availableModels })).join('')}
</div>
</section>`
}
@@ -0,0 +1,4 @@
{
"name": "sentry-triage",
"version": 1
}
@@ -0,0 +1,70 @@
// Shared HTML-escaping helpers. All Sentry / MCP / agent-derived values are
// interpolated into HTML string templates, so every dynamic text or attribute
// value MUST pass through here before it reaches the webview.
const HTML_ENTITIES = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
/** Escape a value for use as HTML text or a double/single-quoted attribute. */
export function escapeHtml(value) {
if (value == null) return ''
return String(value).replace(/[&<>"']/g, (ch) => HTML_ENTITIES[ch])
}
/**
* Return a safe href: only http(s) URLs are allowed through, everything else
* (javascript:, data:, malformed, etc.) collapses to '#'. The result is still
* attribute-escaped for embedding.
*/
export function safeHref(value) {
if (value == null) return '#'
const raw = String(value).trim()
try {
const url = new URL(raw)
if (url.protocol === 'http:' || url.protocol === 'https:') {
return escapeHtml(url.href)
}
} catch {
// not an absolute URL — fall through
}
return '#'
}
/**
* Serialize a value as JSON safe to embed inside an inline <script> block.
* Escapes the characters that can terminate the script element or break the
* JS string context (`<`, `>`, U+2028, U+2029).
*/
export function jsonForScript(value) {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
}
/**
* Neutralize a piece of untrusted, Sentry-derived text before it is embedded in
* an agent prompt that can create issues / spawn code-writing sessions. A
* crafted Sentry title or message is prompt-injection input, so we:
* - collapse newlines and control characters to spaces, so it cannot introduce
* its own instruction lines, fenced blocks, or fake tool directives;
* - strip Markdown/emphasis and backtick fences that could restructure the
* prompt;
* - cap the length so a huge payload can't bury the real instructions.
* This is defense-in-depth on top of treating the values as labeled data — not a
* substitute for keeping untrusted content out of executable instructions.
*/
export function sanitizeForPrompt(value, maxLen = 300) {
let text = value == null ? '' : String(value)
text = text.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ')
text = text.replace(/[`]+/g, "'")
text = text.replace(/\s+/g, ' ').trim()
if (text.length > maxLen) text = text.slice(0, maxLen - 1).trimEnd() + '…'
return text
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
{
"name": "sentry-triage",
"version": "1.0.0",
"main": "extension.mjs",
"author": "Liz Tom",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=22"
},
"dependencies": {
"@github/copilot-sdk": "1.0.11",
"sentry": "0.42.2"
},
"description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.",
"keywords": [
"canvas",
"copilot-canvas",
"copilot-extension",
"error-triage",
"incident-response",
"on-call",
"sentry"
]
}
@@ -0,0 +1,160 @@
// Connection preflight: make sure the canvas can actually reach Sentry BEFORE the
// user tries to use the board.
//
// Sentry is a hard requirement: without it there is no data at all. We verify it
// through the Sentry CLI SDK (./sentryClient.mjs) by making a live auth.whoami()
// call — if it succeeds a credential is present and valid, if it throws
// (SentryError: "Not authenticated…", or a transient network error) we surface
// that as "not connected" rather than an empty board. Auth is resolved by the SDK
// from a one-time `sentry auth login` (or SENTRY_AUTH_TOKEN); the canvas never
// handles a raw token.
//
// GitHub is intentionally NOT preflighted: the "Work on selected" hand-off is
// performed by the agent (session.sendAndWait), which has its own GitHub access.
//
// buildConnections() is pure so it can be unit-tested offline; checkConnections()
// is the thin wrapper that talks to Sentry.
import { whoami, SentryError } from './sentryClient.mjs'
// Shape the raw signals into the connection status the UI consumes. Pure.
export function buildConnections({
sentryConfigured = false,
sentryReachable = false,
sentryError = '',
sentryTransient = false,
} = {}) {
return {
checked: true,
sentry: {
configured: Boolean(sentryConfigured),
reachable: Boolean(sentryReachable),
// Whether the failure is worth retrying (a network blip) vs. a settled
// problem. Only meaningful while unreachable. The gate uses this to promise
// recovery for blips but show neutral guidance for unknown failures that
// won't self-heal, instead of routing every failure to a "network" message.
transient: sentryReachable ? false : Boolean(sentryTransient),
error: sentryReachable ? '' : String(sentryError || ''),
},
}
}
// The state a canvas starts with, before any preflight has run. Optimistic
// (checked:false) so the UI never flashes a setup gate before we actually know.
export function unknownConnections() {
return {
checked: false,
sentry: { configured: false, reachable: false, transient: false, error: '' },
}
}
const msg = (err) => (err instanceof Error ? err.message : String(err))
// Text used for classification: the message plus any CLI stderr, since a rejected
// credential (HTTP 401/403) often surfaces its status in stderr rather than the
// Error message.
const errText = (err) => `${msg(err)} ${(err && err.stderr) || ''}`
// "Not authenticated" means there is no stored login yet — that is the setup
// gate's whole reason for existing, NOT a transient blip, so we do not retry it.
const isNotAuthenticated = (err) =>
(err instanceof SentryError && err.exitCode === 10) ||
/not authenticated|no (?:stored )?(?:auth|credential|token)|run 'sentry auth login'|please log ?in/i.test(msg(err))
// A credential problem the user must fix by (re-)authenticating. Either there is
// no stored login at all (isNotAuthenticated) OR a credential IS present but the
// server rejected it — an expired/invalid/revoked token, or an HTTP 401/403.
// Both are resolved by `sentry auth login`, and neither is worth retrying, so we
// keep them out of the transient/network bucket and mark the connection as
// not-configured so the gate shows sign-in guidance instead of "check your VPN".
const isAuthFailure = (err) =>
isNotAuthenticated(err) ||
(err instanceof SentryError && (err.exitCode === 401 || err.exitCode === 403)) ||
/\b40[13]\b|unauthor(?:i[sz]ed)|forbidden|invalid (?:auth|credential|token|api ?key|session)|(?:auth|credential|token|session|login)\b[^.]{0,24}?\b(?:expired|invalid|revoked)|(?:expired|revoked)\b[^.]{0,24}?\b(?:auth|credential|token|session|login)/i.test(errText(err))
// A dropped socket / DNS hiccup / timeout can fail one probe and succeed on the
// next. Treat those as transient so a re-check retries instead of parking the
// user on the setup gate for a blip. Auth failures are never transient.
const isTransient = (err) =>
!isAuthFailure(err) &&
/econnreset|socket hang up|etimedout|timeout|enotfound|eai_again|network|temporarily|transport|connection (?:closed|reset)|fetch failed/i.test(msg(err))
// Turn the raw error into something a human can act on.
const humanizeSentryError = (err) => {
const t = msg(err)
if (isNotAuthenticated(err)) {
return 'Sentry isnt connected yet. Run `sentry auth login` in your terminal, then re-open this canvas.'
}
if (isAuthFailure(err)) {
return 'Sentry rejected your credential (expired or invalid). Run `sentry auth login` in your terminal, then re-open this canvas.'
}
if (isTransient(err)) {
return 'Couldnt reach Sentry just now (network). It should recover on the next check.'
}
return `Could not reach Sentry: ${t}`
}
// Classify a failed Sentry probe into the two decisions the caller cares about,
// plus a human message. Pure and exported so the gate/retry branching can be
// unit-tested without mocking the SDK:
// - configured: does a usable credential exist? Auth failures (no login OR a
// rejected/expired credential) clear it so the gate shows sign-in guidance;
// everything else keeps it so the gate shows connectivity guidance.
// - transient: worth retrying? Only network blips — never auth failures, even
// when their text happens to mention a network keyword.
export function classifySentryError(err) {
return {
configured: !isAuthFailure(err),
transient: isTransient(err),
message: humanizeSentryError(err),
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// One authoritative Sentry probe: a live auth.whoami() decides reachability.
// Returns { configured, reachable, transient, error }.
async function probeSentry() {
try {
await whoami()
return { configured: true, reachable: true, transient: false, error: '' }
} catch (err) {
// classifySentryError decides gate (configured) vs retry (transient). Only an
// auth failure clears `configured` so the gate shows sign-in guidance; on a
// network blip a credential most likely exists, so report configured:true and
// let the gate show connectivity guidance instead of wrongly telling an
// already-signed-in user to run `sentry auth login`.
const { configured, transient } = classifySentryError(err)
return { configured, reachable: false, transient, error: err }
}
}
// Shape a probe result into the UI connection object.
const shape = (result) =>
buildConnections({
sentryConfigured: result.configured,
sentryReachable: result.reachable,
sentryTransient: result.transient,
sentryError: result.reachable ? '' : humanizeSentryError(result.error),
})
// FAST single-probe check. Returns immediately after one call. `transient` marks
// a network blip (not an auth failure) so a caller can choose to retry.
export async function checkConnectionsOnce() {
const result = await probeSentry()
return { connections: shape(result), transient: !result.reachable && result.transient }
}
// The connection check behind the initial preflight and re-open. A network blip
// can fail the first probe and succeed a moment later, so retry a few times on a
// transient error (but never on an auth failure — that needs the user to run
// `sentry auth login`, and retrying would just stall the gate).
export async function checkConnections() {
const backoffs = [400, 700, 1100]
let result = await probeSentry()
for (let i = 0; i < backoffs.length && !result.reachable && result.transient; i++) {
await sleep(backoffs[i])
result = await probeSentry()
}
return shape(result)
}
@@ -0,0 +1,68 @@
// User preferences for the Sentry Triage canvas, persisted to a small on-disk
// JSON file so an explicit choice (currently: the default org) survives canvas
// reopens. There is no first-class "extension preferences" API in the Copilot
// SDK, so we own this file ourselves. Design constraints we deliberately keep:
// - Only ever written on an explicit user action (never silently).
// - Only stores low-sensitivity data (an org slug) — never tokens/secrets.
// - Versioned + minimal shape so it's easy to reason about and migrate later.
// - Never throws: unreadable/corrupt/missing prefs fall back to empty defaults
// so the canvas keeps working.
// - Atomic writes (temp file + rename) so a crash mid-write can't leave a
// half-written file behind.
// Stored OUTSIDE ~/.copilot/extensions/sentry-triage/ (the redeploy target) so
// `npm run deploy` can't clobber the user's saved preference.
import { homedir } from 'node:os'
import { join } from 'node:path'
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'
// Where the prefs file lives. Resolved lazily (not a module constant) so tests
// can point it at a temp directory via SENTRY_TRIAGE_PREFS_DIR and never touch a
// developer's real ~/.copilot/sentry-triage/preferences.json.
function prefsDir() {
return process.env.SENTRY_TRIAGE_PREFS_DIR || join(homedir(), '.copilot', 'sentry-triage')
}
function prefsPath() {
return join(prefsDir(), 'preferences.json')
}
// Shown to the user in the UI so the write is transparent (no leaked home dir).
export const PREFS_DISPLAY_PATH = join('~', '.copilot', 'sentry-triage', 'preferences.json')
const PREFS_VERSION = 1
function normalizeSlug(slug) {
return typeof slug === 'string' ? slug.trim().toLowerCase() : ''
}
// Read + normalize the prefs file. Always returns a well-formed object, even if
// the file is missing or corrupt.
export function readPrefs() {
try {
const data = JSON.parse(readFileSync(prefsPath(), 'utf-8'))
if (!data || typeof data !== 'object') return { version: PREFS_VERSION, defaultOrg: '' }
return {
version: PREFS_VERSION,
defaultOrg: normalizeSlug(data.defaultOrg),
}
} catch {
return { version: PREFS_VERSION, defaultOrg: '' }
}
}
export function getDefaultOrg() {
return readPrefs().defaultOrg
}
// Persist the user's chosen default org. Passing an empty/blank slug clears the
// saved default. Returns the normalized slug that was saved (or '' when cleared).
// Throws if the atomic write fails, so callers can report a real error instead of
// showing a success toast for a preference that never landed on disk.
export function saveDefaultOrg(slug) {
const value = normalizeSlug(slug)
const payload = JSON.stringify({ version: PREFS_VERSION, defaultOrg: value }, null, 2) + '\n'
mkdirSync(prefsDir(), { recursive: true })
const path = prefsPath()
const tmp = `${path}.${process.pid}.tmp`
writeFileSync(tmp, payload, 'utf-8')
renameSync(tmp, path)
return value
}
@@ -0,0 +1,472 @@
// Deterministic Sentry data layer for the triage canvas.
//
// Everything that decides WHICH issues exist, their counts, and how they are
// categorized lives here in code — driven straight off the Sentry CLI SDK. The
// model is never asked "what are the issues"; it is only (optionally, and
// elsewhere) asked to rephrase a title into plain English. That is what stops the
// canvas from ever showing remembered / stale data.
//
// The SDK returns parsed JSON (typed IssueListResult / project / org objects), so
// there is no markdown to parse: we adapt those JSON shapes into the canvas's
// internal issue model. The adapters + categorizer are pure functions; only
// listOrgs / listProjects / findProject / scanIssues touch the network (via
// ./sentryClient.mjs), and they surface SentryError as a user-facing message.
import {
orgList,
projectView,
issueList,
projectListRaw,
runSerial,
SentryError,
} from './sentryClient.mjs'
// Surface only genuinely high-impact ongoing issues in "escalating" when Sentry
// itself has not tagged them is:escalating. Kept as named constants so the
// selection rule is explicit rather than a magic number buried in a condition.
const HIGH_IMPACT_USERS = 40
const HIGH_IMPACT_EVENTS = 100
// "New" = first seen within roughly the on-call window.
const NEW_MAX_AGE_HOURS = 24
const NEW_MIN_USERS = 2
// Human label for the selected window, e.g. "24h" -> "last 24 hours".
export function windowLabel(period) {
const m = String(period || '').match(/^(\d+)\s*([hdw])$/i)
if (!m) return 'selected window'
const n = parseInt(m[1], 10)
const u = m[2].toLowerCase()
const unit = u === 'h' ? 'hour' : u === 'd' ? 'day' : 'week'
return `last ${n} ${unit}${n === 1 ? '' : 's'}`
}
function toInt(value) {
if (value == null) return 0
const n = parseInt(String(value).replace(/[,\s]/g, ''), 10)
return Number.isFinite(n) ? n : 0
}
// Age in hours from an ISO 8601 timestamp. The SDK returns real first/last-seen
// timestamps (unlike the old MCP markdown, which clamped first-seen to the
// window edge), so this is a true age. Missing / unparsable -> Infinity so the
// issue never counts as "new".
export function ageHoursFromIso(iso, now = Date.now()) {
if (!iso) return Infinity
const t = Date.parse(iso)
if (!Number.isFinite(t)) return Infinity
return (now - t) / 3_600_000
}
// Render an ISO timestamp as a short relative phrase ("just now", "20 hours ago",
// "5 days ago") for the human-facing reason text. Empty / unparsable -> ''.
export function humanizeSince(iso, now = Date.now()) {
if (!iso) return ''
const t = Date.parse(iso)
if (!Number.isFinite(t)) return ''
const mins = Math.max(0, Math.round((now - t) / 60_000))
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
const hours = Math.round(mins / 60)
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
const days = Math.round(hours / 24)
if (days < 7) return `${days} day${days === 1 ? '' : 's'} ago`
const weeks = Math.round(days / 7)
if (weeks < 5) return `${weeks} week${weeks === 1 ? '' : 's'} ago`
const months = Math.round(days / 30)
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
const years = Math.round(days / 365)
return `${years} year${years === 1 ? '' : 's'} ago`
}
// Adapt one raw SDK IssueListResult into the canvas's internal issue shape.
// `now` is injectable for deterministic tests. firstSeen/lastSeen are humanized
// for display; ageHours is the true numeric age used by the categorizer.
export function mapIssue(raw, now = Date.now()) {
if (!raw || !raw.shortId) return null
const firstSeenIso = raw.firstSeen || ''
return {
key: String(raw.shortId),
url: raw.permalink || '',
title: raw.title ? String(raw.title).replace(/\s+/g, ' ').trim() : String(raw.shortId),
users: toInt(raw.userCount),
events: toInt(raw.count),
firstSeen: humanizeSince(firstSeenIso, now),
lastSeen: humanizeSince(raw.lastSeen || '', now),
firstSeenIso,
ageHours: ageHoursFromIso(firstSeenIso, now),
}
}
// Adapt a list of raw SDK issues, dropping anything without a short id.
export function mapIssues(list, now = Date.now()) {
const out = []
for (const raw of Array.isArray(list) ? list : []) {
const issue = mapIssue(raw, now)
if (issue) out.push(issue)
}
return out
}
// Adapt raw SDK org objects into slugs (deduped, order preserved).
export function mapOrgs(list) {
const out = []
const seen = new Set()
for (const raw of Array.isArray(list) ? list : []) {
const slug = String(raw?.slug || '').trim()
if (slug && !seen.has(slug)) {
seen.add(slug)
out.push(slug)
}
}
return out
}
// Adapt raw SDK project objects into slugs (deduped, order preserved).
export function mapProjects(list) {
const out = []
const seen = new Set()
for (const raw of Array.isArray(list) ? list : []) {
const slug = String(raw?.slug || '').trim()
if (slug && !seen.has(slug)) {
seen.add(slug)
out.push(slug)
}
}
return out
}
// List the org slugs this Sentry connection can access. Fast and reliable, so
// it's safe to call on canvas open. Never throws — returns [] on any error
// (including "not authenticated") so the setup form still renders and the auth
// gate is what tells the user to sign in.
export async function listOrgs() {
try {
return mapOrgs(await orgList())
} catch (err) {
console.error('[sentry-triage] listOrgs failed:', err instanceof Error ? err.message : err)
return []
}
}
function reasonFor(id, issue, ctx) {
if (id === 'regressions') {
return `Sentry flagged this as regressed`
}
if (id === 'escalating') {
if (issue.sentryEscalating) return `Sentry flagged this as escalating`
return `Active within the ${ctx.windowLabel}`
}
// new-critical: the SDK gives a true first-seen timestamp, so we can report it
// honestly whenever we have one.
if (Number.isFinite(issue.ageHours) && issue.firstSeen) {
return `First seen ${issue.firstSeen}`
}
return `Active within the ${ctx.windowLabel}`
}
// Pure categorizer. Takes the parsed main issue list plus the sets of issue keys
// Sentry itself classifies as regressed / escalating, and returns the canvas
// category structure (plainEnglish is filled in later by the caller). `period`
// is the selected board window, used to phrase window-relative reasons honestly.
export function categorize({ issues = [], regressed = new Set(), escalating = new Set(), period = '24h' } = {}) {
const byUrgency = (a, b) => b.users - a.users || b.events - a.events
const regressions = []
const escalatingOut = []
const newCritical = []
for (const issue of issues) {
if (regressed.has(issue.key)) {
regressions.push(issue)
} else if (
escalating.has(issue.key) ||
(issue.ageHours >= NEW_MAX_AGE_HOURS &&
(issue.users >= HIGH_IMPACT_USERS || issue.events >= HIGH_IMPACT_EVENTS))
) {
escalatingOut.push({ ...issue, sentryEscalating: escalating.has(issue.key) })
} else if (issue.ageHours < NEW_MAX_AGE_HOURS && issue.users >= NEW_MIN_USERS) {
// New Critical is strictly `<` the boundary; the escalation branch above
// owns exactly-at-boundary issues via `>=`. Together they leave no gap: at
// a 24h scan Sentry clamps an older issue's first-seen to exactly 24h, and
// that boundary case belongs with old/ongoing (escalating if high-impact),
// never "New Critical". A boundary issue that isn't high-impact simply
// isn't urgent enough for any bucket.
newCritical.push(issue)
}
}
regressions.sort(byUrgency)
escalatingOut.sort(byUrgency)
newCritical.sort(byUrgency)
const ctx = { windowLabel: windowLabel(period) }
const make = (id, name, arr) => ({
id,
name,
issues: arr.map((issue) => ({
key: issue.key,
summary: issue.title,
plainEnglish: '',
reason: reasonFor(id, issue, ctx),
events: issue.events,
users: issue.users,
url: issue.url,
})),
})
const categories = []
if (regressions.length) categories.push(make('regressions', '🔄 Regressions', regressions))
if (escalatingOut.length) categories.push(make('escalating', '📈 Escalating', escalatingOut))
if (newCritical.length) categories.push(make('new-critical', '🆕 New Critical', newCritical))
return categories
}
// All project slugs in an org. Pages through the list (the API caps each page at
// 100) so orgs with many projects are fully represented in the dropdown. The
// loop is bounded and stops as soon as a page adds no new slugs, so it stays
// safe even if the underlying cursor doesn't advance. Throws SentryError on an
// auth/permission failure.
//
// `onPage(slugsSoFar)` — if provided, called after each page with a snapshot of
// everything collected so far. This lets callers stream results to the UI: the
// first ~100 projects land in ~1s and the rest fill in over the following
// seconds, instead of the caller waiting for the whole (potentially large) list.
export async function listProjects(org, onPage) {
// Run the ENTIRE paged traversal as one atomic SDK operation. The CLI resolves
// the symbolic "next" cursor through global per-command state, so pages must not
// interleave with each other or with any other SDK call (a concurrent issue
// scan, another instance's discovery, a second traversal of this same org).
// runSerial holds the module-wide queue for the whole loop, which guarantees
// that. Inside the task we use projectListRaw (un-queued) to avoid re-entering
// the queue we already hold.
return runSerial(() => listProjectsPaged(org, onPage))
}
async function listProjectsPaged(org, onPage) {
const seen = new Set()
const out = []
const PAGE = 100
const MAX_PAGES = 20
// Wall-clock budget: mega-orgs (e.g. "github" has thousands of projects) can
// take minutes to fully page through, leaving the autocomplete spinning. Stop
// once we've spent this long and return what we have — the client filters the
// collected slugs, and a few hundred is plenty to type against. With streaming
// (onPage) the first page is usable almost immediately regardless.
const BUDGET_MS = 8000
const started = Date.now()
let cursor
for (let page = 0; page < MAX_PAGES; page++) {
let raw
try {
raw = await projectListRaw(org, PAGE, cursor)
} catch (err) {
// A transient page failure (network blip / rate limit) mid-pagination must
// not discard the projects we already collected. Surface the error only
// when we have nothing at all (e.g. page 0 failed => likely auth/bad org).
if (out.length) break
throw err
}
let added = 0
for (const slug of mapProjects(raw)) {
if (seen.has(slug)) continue
seen.add(slug)
out.push(slug)
added++
}
if (added && typeof onPage === 'function') {
try { onPage(out.slice()) } catch { /* streaming is best-effort */ }
}
if (raw.length < PAGE || added === 0) break
if (Date.now() - started > BUDGET_MS) break
cursor = 'next'
}
return out
}
// Verify a specific project slug exists / is accessible. The org's project list
// is capped, so a valid slug may not appear in it; project view resolves any
// slug directly. Returns the canonical slug, or '' if it doesn't exist / isn't
// accessible.
export async function findProject(org, slug) {
const wanted = String(slug || '').trim()
if (!wanted) return ''
try {
const project = await projectView(org, wanted)
const resolved = String(project?.slug || '').trim()
return resolved || wanted
} catch (err) {
if (err instanceof SentryError) return ''
throw err
}
}
// Per-query issue search. `limit` bounds a single call; the SDK auto-pages up to
// the SDK max (1000) to satisfy it. The primary board search and the targeted
// regression/escalation searches all pass an explicit bounded cap so an org with
// far more than 100 open/priority issues doesn't silently lose the remainder.
async function searchIssueList(orgProject, query, period, limit = 100) {
return mapIssues(await issueList({ orgProject, query, sort: 'date', limit, period }))
}
// Bounded safety cap for the primary `is:unresolved` board search. 100 (one API
// page) let a high-impact issue outside the 100 most-recently-seen fall off the
// board entirely; we page up to the SDK max so the "thousands of unresolved
// errors" the canvas promises are actually considered, while still bounding work.
const PRIMARY_LIMIT = 1000
// Upper bound for the targeted priority searches (regressed / escalating). These
// are the buckets we can't afford to truncate, so we ask for the SDK's max.
const PRIORITY_LIMIT = 1000
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
// Run a targeted priority search with one retry. A bare `catch {}` here would
// turn a transient/rate-limited failure into a silent "no regressions", so a
// real regression could be dropped or misbucketed while the scan still reports
// success. A permanent "unsupported filter" (400) means Sentry rejected this
// priority query, which is indistinguishable from a legitimately empty bucket —
// but these are the exact high-priority buckets the canvas must not silently
// drop, so record a warning (don't retry) rather than reporting a clean empty.
// On a persistent transport failure, log and record a warning too so the
// incompleteness isn't swallowed.
async function searchPriority(orgProject, query, period, warnings, label) {
for (let attempt = 0; attempt < 2; attempt++) {
try {
return await searchIssueList(orgProject, query, period, PRIORITY_LIMIT)
} catch (err) {
const info = sentryErrorInfo(err)
if (info && info.code === 400) {
warnings.push(label)
console.error(`[sentry-triage] priority search rejected (400) (${label}):`, err instanceof Error ? err.message : err)
return []
}
if (attempt === 0) {
await delay(250)
continue
}
warnings.push(label)
console.error(`[sentry-triage] priority search failed (${label}):`, err instanceof Error ? err.message : err)
return []
}
}
return []
}
// Merge several issue lists into one, de-duplicating by key and keeping the
// first record seen (the primary list wins). Used so priority issues fetched by
// targeted searches still appear even when they fall outside the main list.
function mergeIssues(...lists) {
const seen = new Set()
const out = []
for (const list of lists) {
for (const issue of list) {
if (!issue || !issue.key || seen.has(issue.key)) continue
seen.add(issue.key)
out.push(issue)
}
}
return out
}
// Extract an HTTP-ish status code from a thrown SentryError so a permission/API
// failure isn't silently rendered as "0 issues" (an empty, all-clear board). The
// SDK carries the API message in `.message` / `.stderr`; a scoped read failure
// shows up as "(403)" / "403 Forbidden". Returns { code } or null.
export function sentryErrorInfo(err) {
if (!err) return null
const t = `${err.message || ''}\n${err.stderr || ''}`
const m = t.match(/\b(?:API error|HTTP)?\s*\(?(40\d|41\d|42\d|50\d)\)?\b/i)
if (m) return { code: Number(m[1]) }
if (/permission|forbidden|not authorized|unauthorized/i.test(t)) return { code: 403 }
return null
}
// Turn a detected Sentry error into a short, plain-English message for the board.
// A scoped 403 is the common case: the account can search the org but not that
// specific project.
export function describeScanError(info, project) {
const where = project ? `project "${project}"` : 'this organization'
if (info && info.code === 403) {
return project
? `Your Sentry account doesn't have access to project "${project}" (403). Try a project you have access to, or check your Sentry login.`
: `Your Sentry account doesn't have permission to read issues here (403). Check your Sentry login.`
}
if (info && info.code) return `Sentry returned an error (${info.code}) while reading ${where}. Try again shortly.`
return `Sentry returned an error while reading ${where}. Try again shortly.`
}
// Fetch + categorize live from Sentry over the requested time window (default
// 24h). The user can widen the window from the issues list, so we honor the
// chosen period exactly rather than silently auto-widening. Sentry's own
// is:regressed / is:escalating sets drive those buckets so they reflect real
// Sentry state rather than a heuristic guess.
//
// The primary `is:unresolved` search is paged up to a bounded cap (PRIMARY_LIMIT)
// and sorted by last-seen; a plain 100-item page could drop a high-impact issue
// that hasn't been seen recently. We additionally fetch the regressed /
// escalating sets and merge them in before categorizing — those two priority
// buckets are exactly the ones we can't afford to miss.
//
// Returns { categories, error, scanned, capped, older, olderCapped, warnings }:
// error is a user-facing string when Sentry refused the primary request (e.g. a
// scoped 403); scanned is the number of distinct open issues we examined; capped
// is true when the primary search hit PRIMARY_LIMIT (so more open issues likely
// exist beyond what we scanned); warnings is a (usually empty) list of
// non-fatal priority-bucket labels that failed to load, so a partially-complete
// scan isn't silently reported as all-clear; older counts unresolved issues that
// exist OUTSIDE the window (only computed when the window came back empty) so the
// empty state can point the user at a wider range.
export async function scanIssues(org, project, period = '24h') {
const win = typeof period === 'string' && period ? period : '24h'
// The Sentry CLI treats a bare slug as a *project*, so an org-wide scan must
// pass the `<org>/` form; a scoped scan keeps the `<org>/<project>` form.
const orgProject = project ? `${org}/${project}` : `${String(org || '').replace(/\/+$/, '')}/`
// A tool error on the primary search means we can't trust anything below it —
// surface it instead of falling through to an empty board.
let issues
try {
issues = await searchIssueList(orgProject, 'is:unresolved', win, PRIMARY_LIMIT)
} catch (err) {
return { categories: [], error: describeScanError(sentryErrorInfo(err), project), scanned: 0, capped: false, warnings: [] }
}
const capped = issues.length >= PRIMARY_LIMIT
const warnings = []
const regressedIssues = await searchPriority(orgProject, 'is:unresolved is:regressed', win, warnings, 'regressed issues')
const escalatingIssues = await searchPriority(orgProject, 'is:unresolved is:escalating', win, warnings, 'escalating issues')
const regressed = new Set(regressedIssues.map((i) => i.key))
const escalating = new Set(escalatingIssues.map((i) => i.key))
// Union the priority issues in so an important regression/escalation outside
// the 100-most-recent primary list still gets surfaced. Main list first so its
// records win on dedup; categorize re-sorts each bucket by impact anyway.
const merged = mergeIssues(issues, regressedIssues, escalatingIssues)
// When the chosen window is empty, the project may still have unresolved
// issues that are simply older than the window (a common source of "but I know
// there are issues here!" confusion). Do one wider look-back so the empty state
// can say how many exist outside the window and nudge the user to widen it.
// Only when the window itself came back empty (so anything the wide search
// finds is genuinely outside it) and only when a wider window exists.
let older = 0
let olderCapped = false
if (merged.length === 0 && win !== '90d') {
try {
const wide = await searchIssueList(orgProject, 'is:unresolved', '90d', PRIMARY_LIMIT)
older = wide.length
olderCapped = wide.length >= PRIMARY_LIMIT
} catch {
/* best-effort — leave older at 0 */
}
}
return {
categories: categorize({ issues: merged, regressed, escalating, period: win }),
error: null,
scanned: merged.length,
capped,
older,
olderCapped,
warnings,
}
}
@@ -0,0 +1,125 @@
// Thin wrapper around the Sentry CLI's in-process SDK (the `sentry` npm package,
// a.k.a. getsentry/cli "library usage"). This is the ONLY place the canvas talks
// to Sentry: every issue / project / org read goes through the typed SDK, which
// spawns the bundled CLI and returns parsed JSON (or throws SentryError).
//
// Auth is resolved by the SDK/CLI itself, in this order: the `token` option ->
// SENTRY_AUTH_TOKEN -> SENTRY_TOKEN -> the OAuth credential stored by a one-time
// `sentry auth login` (in ~/.sentry). We deliberately pass NO token here so the
// stored login is used and the canvas never handles a raw secret. Because the
// extension process runs as the same user, it reads the same stored credential.
//
// Everything below returns raw SDK JSON (or throws SentryError). Shaping into the
// canvas's internal issue model lives in sentry.mjs so this file stays a thin,
// swappable transport.
import createSentrySDK, { SentryError } from 'sentry'
export { SentryError }
let sdk = null
// Lazily construct the SDK once. cwd affects the CLI's project-root / DSN
// detection; we anchor it to the extension's cwd for determinism.
function getSdk() {
if (!sdk) sdk = createSentrySDK({ cwd: process.cwd() })
return sdk
}
// Module-wide serialization of ALL SDK work.
//
// `sentry@0.42.2` explicitly does not support concurrent library calls: the
// bundled CLI SDK keeps global per-command and pagination ("next" cursor) state,
// so two in-flight calls corrupt each other's results. This module's `sdk` is a
// process singleton shared across BOTH fan-out within one canvas (e.g. refreshAll
// kicks off project discovery without awaiting it, then immediately scans issues)
// AND every canvas instance in this extension process. A per-org or per-command
// queue can't see the whole picture, so we funnel every SDK invocation through
// ONE FIFO chain here — nothing else in the codebase touches the SDK directly.
//
// `runSerial(task)` runs `task` only once all previously enqueued work has
// settled, so at most one SDK operation is ever in flight process-wide. It is the
// single choke point; the public functions below are thin queued wrappers, and
// multi-call traversals (see projectListRaw) run as ONE task so their paging
// can't interleave with anything.
let sdkQueue = Promise.resolve()
export function runSerial(task) {
// Chain onto the tail whether the previous task fulfilled or rejected, so one
// failed call never wedges the queue for everyone behind it. Each caller still
// awaits `run` for its own result/error.
const run = sdkQueue.then(task, task)
// Keep the internal chain from emitting unhandled-rejection warnings; callers
// own the real settlement via `run`.
sdkQueue = run.then(() => {}, () => {})
return run
}
// Coerce the SDK's list results into a plain array. `sentry@0.42.2`'s typed
// resources (issue.list, project.list, org.list) return a paginated envelope
// whose records live under `data` — verified against the installed SDK:
// project.list -> { data: [...], hasMore, nextCursor, hasPrev }
// issue.list -> { data: [...], hasMore, hasPrev }
// so `data` is the real key we depend on. The other keys (issues/projects/…,
// including `items`) are belt-and-suspenders for a future CLI shape change so a
// mismatch fails soft (empty board) rather than throwing.
function asArray(res) {
if (Array.isArray(res)) return res
if (res && typeof res === 'object') {
for (const key of ['data', 'items', 'issues', 'projects', 'organizations', 'orgs', 'results']) {
if (Array.isArray(res[key])) return res[key]
}
}
return []
}
// Authentication probe. Resolves with the current user/token identity when a
// credential is present and valid; throws SentryError ("Not authenticated…")
// otherwise. Used by preflight to gate the board.
export async function whoami() {
return runSerial(() => getSdk().auth.whoami())
}
// All organizations the stored credential can see. Raw org objects (each has a
// `slug`).
export async function orgList(limit = 100) {
return runSerial(async () => asArray(await getSdk().org.list({ limit })))
}
// Single-page project fetch within an org. Deliberately NOT wrapped in
// runSerial on its own: the CLI's positional org/project value treats a BARE
// slug as a *project*, so listing every project in an org requires the trailing
// `<org>/` form — we normalize to exactly one trailing slash here. Raw project
// objects (each has a `slug`). `cursor` navigates pages ("next"/"prev"/raw
// cursor). Callers that page through the full list must instead run
// projectListRaw inside a single runSerial task so the whole traversal is atomic
// (see listProjects in sentry.mjs).
export async function projectListRaw(org, limit = 100, cursor) {
const orgProject = `${String(org || '').replace(/\/+$/, '')}/`
return asArray(await getSdk().project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
}
// Verify a specific project exists / is accessible. Returns the raw project
// object on success; throws SentryError when the slug is unknown or forbidden.
export async function projectView(org, slug) {
return runSerial(() => getSdk().project.view({ orgProject: `${org}/${slug}` }))
}
// Search issues. `orgProject` is "org/project" (or the trailing-slash "org/" form
// for all projects in the org — a bare slug would be read as a project). Mirrors the previous MCP search: date sort, 100 cap, windowed by
// `period`. Returns an array of raw IssueListResult objects; throws SentryError
// on an API/permission failure so the caller can surface it instead of rendering
// an empty (all-clear) board.
export async function issueList({ orgProject, query, sort = 'date', limit = 100, period } = {}) {
return runSerial(async () =>
asArray(
await getSdk().issue.list({
...(orgProject ? { orgProject } : {}),
...(query ? { query } : {}),
...(period ? { period } : {}),
sort,
limit,
})
)
)
}
@@ -0,0 +1,472 @@
import { createServer } from 'node:http'
import { randomUUID } from 'node:crypto'
import { createState, PERIODS } from './state.mjs'
import { PREFS_DISPLAY_PATH } from './prefs.mjs'
import { Page } from './components/page.mjs'
// Cap the buffered request body for the loopback mutation API. Even though every
// mutation is Host-checked and capability-token gated, an attacker-reachable page
// shouldn't be able to grow this process's memory without bound. On overflow we
// answer 413, destroy the request, and resolve null so the caller bails out.
const MAX_BODY_BYTES = 1 << 20 // 1 MiB
function readBody(req, res) {
return new Promise((resolve) => {
let body = ''
let size = 0
let aborted = false
req.on('data', (chunk) => {
if (aborted) return
size += chunk.length
if (size > MAX_BODY_BYTES) {
aborted = true
if (res && !res.headersSent) {
res.writeHead(413, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'Request body too large' }))
}
req.destroy()
resolve(null)
return
}
body += chunk
})
req.on('end', () => { if (!aborted) resolve(body) })
req.on('error', () => { if (!aborted) resolve(null) })
// 'end' never fires if the client aborts mid-stream; 'close' always does, so
// resolve here too (idempotent — a prior 'end' resolve wins) to avoid a hung
// handler promise.
req.on('close', () => { if (!aborted) resolve(null) })
})
}
function parseJson(body) {
try {
return body ? JSON.parse(body) : {}
} catch {
return {}
}
}
// Localhost trust model for this per-instance, unauthenticated API.
//
// Two distinct browser attacks reach a localhost port:
// 1. Plain cross-origin POST — a page on attacker.com POSTs a CORS-safelisted
// body to 127.0.0.1:PORT. The browser sends `Origin: https://attacker.com`
// and `Host: 127.0.0.1:PORT` (the real target), so Origin != Host.
// 2. DNS rebinding — attacker.com is re-resolved to 127.0.0.1, so the page's
// own origin now points AT this server. Here Origin == Host (both read
// attacker.com), so an Origin==Host check passes and the attack succeeds.
//
// Defenses (both required, applied to every request):
// * Host allow-list — the loopback server only ever answers on the exact
// `127.0.0.1:PORT` it bound. A rebinding page still sends `Host: attacker.com`
// (the name the browser navigated to), so requiring Host to equal the bound
// loopback authority rejects rebinding while the legitimate canvas — loaded
// from http://127.0.0.1:PORT/ — matches.
// * Per-instance capability token — a random secret minted at startup, embedded
// only in the served page and required as a header on every mutation. A
// cross-origin/rebinding page cannot read the page body cross-origin, so it
// cannot learn the token; same-origin canvas fetches replay it and pass.
function hostMatches(req, boundHost) {
return typeof req.headers.host === 'string' && req.headers.host === boundHost
}
function hasValidToken(req, token) {
const header = req.headers['x-sentry-triage-csrf']
const value = Array.isArray(header) ? header[0] : header
return value === token
}
export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onListProjects, onInvalidateEnrichment, defaults } = {}) {
// Per-instance state + SSE clients — never shared across canvas instances.
const state = createState()
state.applyRepoDefaults(defaults)
const sseClients = new Set()
// Per-instance capability token (see hostMatches/hasValidToken). Minted before
// the server binds; embedded in the served page and required on mutations.
const csrfToken = randomUUID()
// The exact `127.0.0.1:PORT` authority this server binds. Set in the listen
// callback (the port may be ephemeral) and used to reject DNS-rebinding hosts.
let boundHost = null
// Single source of truth for the full state snapshot pushed to clients (SSE
// initial payload, page render props, and every notifyClients broadcast).
function snapshot() {
return {
categories: state.getCategories(),
scanError: state.getScanError(),
scannedTotal: state.getScannedTotal(),
scannedCapped: state.getScannedCapped(),
scannedOlder: state.getScannedOlder(),
scannedOlderCapped: state.getScannedOlderCapped(),
org: state.getOrg(),
orgOptions: state.getOrgOptions(),
orgDefault: state.getOrgDefault(),
savedDefaultOrg: state.getSavedDefaultOrg(),
project: state.getProject(),
projectOptions: state.getProjectOptions(),
period: state.getPeriod(),
periods: PERIODS,
projects: state.getProjects(),
projectsOrg: state.getProjectsOrg(),
connections: state.getConnections(),
prTargets: state.getPrTargets(),
availableModels: state.getAvailableModels(),
prSettingsOpen: state.getPrSettingsOpen(),
plainEnglishView: state.getPlainEnglishView(),
issueTrackers: state.getIssueTrackers(),
selectedTracker: state.getSelectedTracker(),
workByIssueKey: state.getWorkByIssueKey(),
}
}
function notifyClients() {
const data = JSON.stringify(snapshot())
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
function notifyWork(key, patch) {
const status = state.setWorkStatus(key, patch)
if (!status) return
const data = JSON.stringify({ work: { key, ...status } })
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
// Broadcast a transient toast message (e.g. "no project found") without
// touching state — the client shows it and moves on.
function notifyFlash(message, kind = 'info') {
const data = JSON.stringify({ flash: { message: String(message || ''), kind } })
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
// Broadcast scan start/stop so the client can show a blocking overlay while a
// (slow, agent-driven) re-scan is in flight and hide it exactly when done.
function notifyScanning(isScanning) {
const data = JSON.stringify({ scanning: Boolean(isScanning) })
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
function handleRequest(req, res) {
// Reject DNS-rebinding hosts on EVERY request (see the trust-model comment):
// a rebinding page still carries its own hostname in `Host`, so anything but
// the exact bound loopback authority is untrusted — this also keeps the page
// and its embedded token from being served to a rebinding origin. boundHost
// is set before the server accepts connections, so it is always populated.
if (boundHost && !hostMatches(req, boundHost)) {
res.writeHead(403, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'untrusted host rejected' }))
return
}
// Every state-changing endpoint is a POST, so gating POST with the per-
// instance capability token covers all mutations in one place. GET (the page
// + SSE reads) is exempt so the page can load and hand the token to its own
// same-origin fetches.
if (req.method === 'POST' && !hasValidToken(req, csrfToken)) {
res.writeHead(403, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'missing or invalid request token' }))
return
}
// SSE endpoint
if (req.url === '/api/events') {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.write(`data: ${JSON.stringify(snapshot())}\n\n`)
sseClients.add(res)
req.on('close', () => sseClients.delete(res))
return
}
// Bulk action endpoint
if (req.method === 'POST' && req.url === '/api/action') {
readBody(req, res).then((body) => {
if (body === null) return
const { categoryId, issueKeys } = parseJson(body)
state.removeIssues(categoryId, issueKeys)
notifyClients()
if (onAction) onAction(categoryId, issueKeys)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
// Refresh endpoint
if (req.method === 'POST' && req.url === '/api/refresh') {
if (onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
}
// Re-run the MCP connection preflight (Sentry). Used by the setup
// gate / warning banner "Re-check" button after the user connects a server.
if (req.method === 'POST' && req.url === '/api/recheck-connections') {
Promise.resolve(onRecheck ? onRecheck() : null)
.then((connections) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: connections || state.getConnections() }))
})
.catch((err) => {
console.error('[sentry-triage] recheck failed:', err instanceof Error ? err.message : err)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: state.getConnections() }))
})
return
}
// Fetch the Sentry project list for an org so the project field can render
// as a dropdown. Fire-and-forget from the client's perspective: the fetched
// list is broadcast to all clients over SSE by the handler. Used when the
// user changes the org on the setup screen (before committing to a scan).
if (req.method === 'POST' && req.url === '/api/list-projects') {
readBody(req, res).then((body) => {
if (body === null) return
const { org } = parseJson(body)
Promise.resolve(onListProjects ? onListProjects(typeof org === 'string' ? org : '') : null)
.catch((err) => console.error('[sentry-triage] list-projects failed:', err instanceof Error ? err.message : err))
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
// Set org endpoint
if (req.method === 'POST' && req.url === '/api/set-org') {
readBody(req, res).then((body) => {
if (body === null) return
const { org, project, repo } = parseJson(body)
// Normalize the incoming scope with the same rules the setters apply, so
// we can tell whether the org/project actually changed. Clicking Fetch on
// an unchanged scope must NOT clear work badges or bump the scope
// generation — doing so would abort an in-flight work batch and discard
// its later PR callback.
const nextOrg = typeof org === 'string' ? org.trim().toLowerCase() : ''
const nextProject = typeof project === 'string' ? project.trim() : ''
const scopeChanged = nextOrg !== state.getOrg() || nextProject !== state.getProject()
state.setOrg(org)
state.setProject(project)
if (typeof repo === 'string') {
const current = state.getPrTargets()
state.setPrTargets({
...current,
cloud: { ...current.cloud, repo: repo.trim() },
})
}
if (scopeChanged) state.clearWorkStatuses()
if (onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
// Persist (or clear) the user's default org. Explicit user action only — the
// setup screen's ⭐ control POSTs here. Broadcasts so the control reflects the
// saved state immediately. Returns the display path so the UI can show the
// user exactly where the preference was written.
if (req.method === 'POST' && req.url === '/api/set-default-org') {
readBody(req, res).then((body) => {
if (body === null) return
const { org } = parseJson(body)
try {
const saved = state.setSavedDefaultOrg(typeof org === 'string' ? org : '')
notifyClients()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, savedDefaultOrg: saved, path: PREFS_DISPLAY_PATH }))
} catch (err) {
console.error('[sentry-triage] set-default-org failed:', err instanceof Error ? err.message : err)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'Could not save your default org — the preferences file could not be written.' }))
}
})
return
}
// Switch project endpoint — re-scans the same org scoped to the new project.
if (req.method === 'POST' && req.url === '/api/set-project') {
readBody(req, res).then((body) => {
if (body === null) return
const { project } = parseJson(body)
state.setProject(project)
state.clearWorkStatuses()
// Broadcast the new selection immediately so the dropdown + label update
// while the (slower) re-scan runs in the background.
notifyClients()
if (onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
// Change the Sentry search window — re-scans the same org/project over the
// new period. Work statuses are kept: a wider window is a superset of the
// same issues, so their in-flight/handed-off state still applies.
if (req.method === 'POST' && req.url === '/api/set-period') {
readBody(req, res).then((body) => {
if (body === null) return
const { period } = parseJson(body)
state.setPeriod(period)
// Broadcast the new selection immediately so the dropdown updates while
// the (slower) re-scan runs in the background.
notifyClients()
if (onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
if (req.method === 'POST' && req.url === '/api/work-selected') {
readBody(req, res).then((body) => {
if (body === null) return
const parsed = parseJson(body)
const keys = parsed.keys
const issueKeys = Array.isArray(keys) ? keys.filter((key) => typeof key === 'string' && key) : []
const modelByKey = parsed.modelByKey && typeof parsed.modelByKey === 'object' ? parsed.modelByKey : {}
const assignCopilot = parsed.assignCopilot === true
if (onWorkSelected && issueKeys.length > 0) onWorkSelected(issueKeys, modelByKey, assignCopilot)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, queued: issueKeys.length }))
})
return
}
if (req.method === 'POST' && req.url === '/api/select-tracker') {
readBody(req, res).then((body) => {
if (body === null) return
const { tracker } = parseJson(body)
state.setSelectedTracker(tracker)
notifyClients()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
if (req.method === 'POST' && req.url === '/api/set-pr-config') {
readBody(req, res).then((body) => {
if (body === null) return
const payload = parseJson(body)
const current = state.getPrTargets()
const pick = (value, fallback) => (typeof value === 'string' ? value : fallback)
const next = {
mode: pick(payload.mode, current.mode),
model: pick(payload.model, current.model),
local: {
path: pick(payload.localPath, current.local.path),
baseBranch: pick(payload.localBranch, current.local.baseBranch),
projectId: pick(payload.localProjectId, current.local.projectId),
projectName: pick(payload.localProjectName, current.local.projectName),
},
cloud: {
repo: pick(payload.cloudRepo, current.cloud.repo),
baseBranch: pick(payload.cloudBranch, current.cloud.baseBranch),
},
}
// Proactively-detected tracking badges and "possibly related" hints are
// derived from the CURRENTLY selected repo. If the target repo identity
// changes, every such annotation on the board is now stale — and an
// enrichment turn still in flight from the previous repo could otherwise
// reapply the old links right after this save. So on a repo change: drop
// the stale annotations now, invalidate any pending enrichment, and
// re-derive against the new repo. Model/base-branch-only edits keep them.
const repoId = (t) =>
[t.cloud.repo, t.local.path, t.local.projectId].map((v) => (v || '').trim()).join('\u0000')
const repoChanged = repoId(next) !== repoId(current)
state.setPrTargets(next)
if (repoChanged) {
state.clearTrackedWorkStatuses()
state.clearRelatedIncidents()
if (onInvalidateEnrichment) onInvalidateEnrichment()
}
notifyClients()
if (repoChanged && onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
if (req.method === 'POST' && req.url === '/api/toggle-pr-settings') {
state.togglePrSettingsOpen()
notifyClients()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
}
if (req.method === 'POST' && req.url === '/api/toggle-title-mode') {
const plainEnglishView = state.togglePlainEnglishView()
notifyClients()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, plainEnglishView }))
return
}
// Default: serve the page. The capability token is embedded here (and ONLY
// here — never in snapshot()/SSE broadcasts) so it reaches same-origin page
// scripts without being exposed on any readable data channel.
//
// Anti-clickjacking: the token defends reads, but a third-party page could
// still FRAME this loopback origin and trick the user into clicking the real
// controls — whose own same-origin script would attach the valid token to the
// state-changing request (create issue, start fix session). The Copilot host
// loads this canvas as a TOP-LEVEL webview (verified: window.top === window
// and no ancestor origins), never inside an iframe, so denying all framing
// costs the legitimate canvas nothing while blocking that attack outright.
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'")
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(Page({ ...snapshot(), csrfToken }))
}
const server = createServer(handleRequest)
// Gracefully shut down: an open EventSource keeps a socket alive, and
// server.close() only waits for idle connections. End every SSE response and
// force-drop any lingering sockets so onClose can't hang indefinitely.
function close() {
for (const res of sseClients) {
try { res.end() } catch { /* already gone */ }
}
sseClients.clear()
return new Promise((resolve) => {
server.close(() => resolve())
// Node >=18.2: terminate connections still open after close() is issued.
server.closeAllConnections?.()
})
}
return new Promise((resolve) => {
server.listen(port, '127.0.0.1', () => {
const address = server.address()
const actualPort = typeof address === 'object' && address ? address.port : 0
// Lock the Host allow-list to the exact loopback authority we bound before
// handling any request (see hostMatches / DNS-rebinding defense).
boundHost = `127.0.0.1:${actualPort}`
resolve({
server,
url: `http://127.0.0.1:${actualPort}/`,
state,
notifyClients,
notifyWork,
notifyFlash,
notifyScanning,
close,
})
})
})
}
@@ -0,0 +1,536 @@
// Per-instance triage state. Each canvas instance gets its own server, and each
// server gets its own state object via createState(), so opening two triage
// canvases never cross-broadcasts one instance's org / results into the other.
import { getDefaultOrg, saveDefaultOrg } from './prefs.mjs'
// Sentry search windows we expose, in order. Values are the exact `period`
// strings the Sentry SDK's issue.list accepts (see sentryClient.mjs); labels
// drive the dropdown.
export const PERIODS = [
{ value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' },
{ value: '14d', label: 'Last 14 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
]
const PERIOD_VALUES = new Set(PERIODS.map((p) => p.value))
// Keys that, if written into a plain object, would mutate its prototype chain
// instead of adding an own property. Work-status keys can come from untrusted
// agent/Sentry data, so any of these must never be used to index workByIssueKey.
const UNSAFE_STATUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
function isUnsafeStatusKey(key) {
return UNSAFE_STATUS_KEYS.has(key)
}
export function createState() {
const categories = []
let scanError = ''
let scannedTotal = 0
let scannedCapped = false
let scannedOlder = 0
let scannedOlderCapped = false
let org = ''
let orgOptions = []
// Monotonic scope generation. Bumped whenever the user switches org/project
// (the only callers of clearWorkStatuses). In-flight work hand-offs capture the
// generation at start; a late result or PR callback that resolves after a scope
// switch is rejected instead of repopulating the new scope's board (where a
// colliding short key could show links from the previous org).
let scopeGen = 0
// The user's explicitly-saved default org (persisted to disk via prefs.mjs), or
// '' if they never set one. Read once at startup so a saved choice survives
// canvas reopens. It takes precedence over the runtime-derived default below.
let savedDefaultOrg = getDefaultOrg()
// The slug the setup screen prefills. Seeds from the saved default; otherwise
// filled in by setOrgOptions once orgs are discovered.
let orgDefault = savedDefaultOrg
let project = ''
let projects = []
// The org the current `projects` list belongs to, so clients can attribute an
// SSE project broadcast to the right org (setup screen switches org before any
// scan, so the panel's own org isn't a reliable signal).
let projectsOrg = ''
// Registered app projects the "Work on selected" local hand-off can spawn the
// fix session in. Enumerated eagerly on canvas open via the agent's
// list_projects tool (there is no direct SDK API), each entry is
// { id, name, repo, defaultBranch, path }.
let projectOptions = []
// Sentry search window. Defaults to the last day; the user can widen it from
// the issues list to look further back. Only Sentry's supported periods are
// accepted (see PERIODS below).
let period = '24h'
// Connection preflight status for the MCP servers this canvas depends on.
// Starts optimistic (checked:false) so the UI never flashes a setup gate
// before an actual check has run.
let connections = {
checked: false,
sentry: { configured: false, reachable: false, transient: false, error: '' },
}
let prSettingsOpen = false
// Whether card titles show the model's plain-English summary instead of the raw
// Sentry error. Canvas-wide, defaults to the raw error so on-call sees exactly
// what Sentry reported first; the user can flip to plain English from the header.
let plainEnglishView = false
let selectedTracker = 'github'
const workByIssueKey = {}
// The issue trackers the "Work on selected" hand-off can file into. The agent
// does the actual filing with its own MCP access, so every tracker here is
// selectable; if the chosen one isn't connected the hand-off returns an error
// the user sees on the card. GitHub is always first/default.
const issueTrackers = [
{ id: 'github', label: 'GitHub Issues', connected: true },
{ id: 'linear', label: 'Linear', connected: true },
{ id: 'atlassian', label: 'Jira (Atlassian)', connected: true },
]
// Models the spawned remediation session can run under. The empty id means
// "let the session pick its default model"; every other id must match a value
// the host create_session tool accepts, or the hand-off would fail.
const availableModels = [
{ id: '', label: 'Auto (session default)' },
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
{ id: 'claude-opus-4.8', label: 'Claude Opus 4.8' },
{ id: 'gpt-5.5', label: 'GPT-5.5' },
{ id: 'gpt-5.4', label: 'GPT-5.4' },
{ id: 'gpt-5.3-codex', label: 'GPT-5.3-Codex' },
]
const MODEL_IDS = new Set(availableModels.map((model) => model.id))
const prTargets = {
mode: 'local',
model: '',
local: { path: '', baseBranch: '', projectId: '', projectName: '' },
cloud: { repo: '', baseBranch: '' },
}
function normalizePrTargets(input) {
const src = input && typeof input === 'object' ? input : {}
const local = src.local && typeof src.local === 'object' ? src.local : {}
const cloud = src.cloud && typeof src.cloud === 'object' ? src.cloud : {}
const str = (value) => (typeof value === 'string' ? value.trim() : '')
return {
mode: src.mode === 'cloud' ? 'cloud' : 'local',
model: MODEL_IDS.has(str(src.model)) ? str(src.model) : '',
local: {
path: str(local.path),
baseBranch: str(local.baseBranch),
projectId: str(local.projectId),
projectName: str(local.projectName),
},
cloud: {
repo: str(cloud.repo),
baseBranch: str(cloud.baseBranch),
},
}
}
function normalizeIssueTrackers(list) {
if (!Array.isArray(list)) return [{ id: 'github', label: 'GitHub Issues', connected: true }]
const seen = new Set()
const out = []
for (const tracker of list) {
if (!tracker || typeof tracker !== 'object') continue
const id = typeof tracker.id === 'string' ? tracker.id.trim() : ''
if (!id || seen.has(id)) continue
seen.add(id)
out.push({
id,
label: typeof tracker.label === 'string' && tracker.label.trim() ? tracker.label.trim() : id,
connected: tracker.connected !== false,
})
}
if (!seen.has('github')) out.unshift({ id: 'github', label: 'GitHub Issues', connected: true })
return out.length ? out : [{ id: 'github', label: 'GitHub Issues', connected: true }]
}
return {
getCategories() {
return categories
},
getScanError() {
return scanError
},
setScanError(msg) {
scanError = typeof msg === 'string' ? msg : ''
return scanError
},
getScannedTotal() {
return scannedTotal
},
getScannedCapped() {
return scannedCapped
},
getScannedOlder() {
return scannedOlder
},
getScannedOlderCapped() {
return scannedOlderCapped
},
// How many distinct open issues the last scan examined, and whether the
// primary search hit its configured cap (so more may exist). Drives the
// "prioritized from N open" subtitle and the empty-state count. `older` is
// how many unresolved issues exist OUTSIDE the current window (only set when
// the window itself was empty) so the empty state can nudge widening.
setScannedInfo({ total = 0, capped = false, older = 0, olderCapped = false } = {}) {
scannedTotal = Number.isFinite(total) && total >= 0 ? total : 0
scannedCapped = Boolean(capped)
scannedOlder = Number.isFinite(older) && older >= 0 ? older : 0
scannedOlderCapped = Boolean(olderCapped)
return { total: scannedTotal, capped: scannedCapped, older: scannedOlder, olderCapped: scannedOlderCapped }
},
getOrg() {
return org
},
getOrgOptions() {
return orgOptions
},
getOrgDefault() {
return orgDefault
},
// The org the user has explicitly saved as their default (or '' if none).
// Distinct from getOrgDefault(), which may be a runtime-derived best guess.
getSavedDefaultOrg() {
return savedDefaultOrg
},
// Cache the org slugs discovered from the Sentry connection so the setup
// form can prefill / offer a dropdown. `def` is the slug to preselect. A
// user-saved default wins over the runtime-derived guess, but ONLY when it
// is actually one of the discovered orgs — otherwise it's stale (e.g. left
// over from a different Sentry account) and we fall back to the runtime pick
// so the setup form still auto-detects a valid org.
setOrgOptions(list, def) {
orgOptions = Array.isArray(list) ? list.filter((s) => typeof s === 'string' && s) : []
const inOptions = (s) => orgOptions.some((o) => o.toLowerCase() === String(s || '').toLowerCase())
if (savedDefaultOrg && inOptions(savedDefaultOrg)) orgDefault = savedDefaultOrg
else if (typeof def === 'string' && def) orgDefault = def
else if (orgOptions.length) orgDefault = orgOptions[0]
return orgOptions
},
// Persist (or, with an empty slug, clear) the user's default org. Only ever
// called from an explicit user action in the UI. Updates the prefilled
// orgDefault to match so the setup screen reflects the change immediately.
setSavedDefaultOrg(slug) {
savedDefaultOrg = saveDefaultOrg(slug)
if (savedDefaultOrg) orgDefault = savedDefaultOrg
else if (orgOptions.length) orgDefault = orgOptions[0]
return savedDefaultOrg
},
getProject() {
return project
},
getPeriod() {
return period
},
// Set the Sentry search window. Ignores unknown values so a bad client
// payload can never send an invalid period to the Sentry API.
setPeriod(next) {
if (typeof next === 'string' && PERIOD_VALUES.has(next)) period = next
return period
},
getProjects() {
return projects
},
getProjectsOrg() {
return projectsOrg
},
getProjectOptions() {
return projectOptions
},
setProjectOptions(list) {
projectOptions = Array.isArray(list)
? list
.filter((p) => p && typeof p === 'object' && typeof p.id === 'string' && p.id)
.map((p) => ({
id: p.id,
name: typeof p.name === 'string' && p.name ? p.name : p.id,
repo: typeof p.repo === 'string' ? p.repo : '',
defaultBranch: typeof p.defaultBranch === 'string' ? p.defaultBranch : '',
path: typeof p.path === 'string' ? p.path : '',
}))
: []
return projectOptions
},
getConnections() {
return connections
},
setConnections(next) {
if (next && typeof next === 'object') connections = next
return connections
},
getPrTargets() {
return prTargets
},
getAvailableModels() {
return availableModels
},
getIssueTrackers() {
return issueTrackers
},
getSelectedTracker() {
return selectedTracker
},
getPrSettingsOpen() {
return prSettingsOpen
},
getWorkByIssueKey() {
return workByIssueKey
},
getWorkStatus(key) {
return workByIssueKey[key]
},
setOrg(slug) {
org = typeof slug === 'string' ? slug.trim().toLowerCase() : ''
},
setProject(slug) {
project = typeof slug === 'string' ? slug.trim() : ''
},
setProjects(list, org) {
const seen = new Set()
const out = []
for (const item of Array.isArray(list) ? list : []) {
const slug = typeof item === 'string' ? item.trim() : ''
if (!slug || seen.has(slug)) continue
seen.add(slug)
out.push(slug)
}
projects = out
if (typeof org === 'string') projectsOrg = org.trim().toLowerCase()
return projects
},
setPrTargets(targets) {
const normalized = normalizePrTargets(targets)
prTargets.mode = normalized.mode
prTargets.model = normalized.model
prTargets.local.path = normalized.local.path
prTargets.local.baseBranch = normalized.local.baseBranch
prTargets.local.projectId = normalized.local.projectId
prTargets.local.projectName = normalized.local.projectName
prTargets.cloud.repo = normalized.cloud.repo
prTargets.cloud.baseBranch = normalized.cloud.baseBranch
return prTargets
},
setIssueTrackers(trackers) {
const normalized = normalizeIssueTrackers(trackers)
issueTrackers.length = 0
issueTrackers.push(...normalized)
if (!issueTrackers.some((tracker) => tracker.id === selectedTracker)) {
selectedTracker = issueTrackers[0]?.id || 'github'
}
return issueTrackers
},
setSelectedTracker(trackerId) {
if (typeof trackerId !== 'string') return selectedTracker
if (issueTrackers.some((tracker) => tracker.id === trackerId)) {
selectedTracker = trackerId
}
return selectedTracker
},
togglePrSettingsOpen() {
prSettingsOpen = !prSettingsOpen
return prSettingsOpen
},
setPrSettingsOpen(open) {
prSettingsOpen = Boolean(open)
return prSettingsOpen
},
getPlainEnglishView() {
return plainEnglishView
},
togglePlainEnglishView() {
plainEnglishView = !plainEnglishView
return plainEnglishView
},
setPlainEnglishView(on) {
plainEnglishView = Boolean(on)
return plainEnglishView
},
setCategories(cats) {
categories.length = 0
categories.push(...cats)
return categories
},
removeIssues(categoryId, issueKeys) {
const category = categories.find((c) => c.id === categoryId)
if (!category) return
// issueKeys arrives straight from a request body, so it may be any JSON
// type. Coerce to an array before `new Set(...)` — a non-iterable value
// (e.g. a number) would otherwise throw inside an unhandled promise and
// could take down the extension process.
const keys = Array.isArray(issueKeys) ? issueKeys : []
const keysToRemove = new Set(keys)
category.issues = category.issues.filter((i) => !keysToRemove.has(i.key))
},
setWorkStatus(key, status) {
if (!key || !status || typeof status !== 'object') return null
// `key` can originate from the agent's submit_tracking map, whose keys are
// derived from the untrusted Sentry issue list. Writing a special key like
// "__proto__" (or "constructor"/"prototype") into this plain object would
// invoke the prototype setter and poison every subsequent lookup for an
// untracked issue, mislabeling the whole board. Reject those keys outright.
if (isUnsafeStatusKey(key)) return null
workByIssueKey[key] = {
...(workByIssueKey[key] || {}),
...status,
}
return workByIssueKey[key]
},
// Start a fresh work attempt for a key, discarding any terminal artifacts from
// a prior run. setWorkStatus shallow-merges, so retrying a tracked/done/
// skipped/error card would otherwise carry its old issue/PR/session links (and
// the `existing*` tracking fields) into the new run, and the status renderer
// could show duplicate or stale links beside the new result. Replacing the
// entry outright guarantees the queued state is clean.
startWorkAttempt(key) {
if (!key || isUnsafeStatusKey(key)) return null
workByIssueKey[key] = { phase: 'queued' }
return workByIssueKey[key]
},
setSkippedWorkStatus(key, existing = {}) {
return this.setWorkStatus(key, {
phase: 'skipped',
existingIssueNumber: Number.isFinite(Number(existing.issueNumber)) ? Number(existing.issueNumber) : undefined,
existingIssueUrl: typeof existing.issueUrl === 'string' ? existing.issueUrl : undefined,
existingPrNumber: Number.isFinite(Number(existing.prNumber)) ? Number(existing.prNumber) : undefined,
existingPrUrl: typeof existing.prUrl === 'string' ? existing.prUrl : undefined,
existingPrState: typeof existing.prState === 'string' ? existing.prState.trim().toLowerCase() : undefined,
})
},
// Mark an issue as already tracked by a pre-existing GitHub issue/PR that was
// filed via the canvas convention (sentry-triage label + key in title). Unlike
// setSkippedWorkStatus this is detected proactively during a scan, so it must
// never clobber an active canvas-initiated status (working/done/handed-off).
setTrackedWorkStatus(key, tracking = {}) {
const current = workByIssueKey[key]
const activePhase = current && ['working', 'queued', 'done', 'handed-off', 'skipped'].includes(current.phase)
if (activePhase) return current
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined)
const str = (v) => (typeof v === 'string' && v ? v : undefined)
// The tracking ISSUE is the source of truth for "being worked on". As long as
// it is open, the work is still tracked — even if a PR attempt was closed or a
// PR merged without closing it yet. Only a closed tracking issue means the work
// is resolved/abandoned, so drop it (and any stale entry) back to the board.
// The tracking ISSUE is the primary source of truth, but a still-active PR
// (open/draft) is the AUTHORITATIVE work signal used by the work-start
// duplicate guard. So a closed tracking issue only drops back to the board
// when its linked PR is NOT still active — otherwise the board would offer
// work that the guard would immediately skip.
const issueState = (str(tracking.issueState) || '').toLowerCase()
const prState = (str(tracking.prState) || '').toLowerCase()
const prActive = prState === 'open' || prState === 'draft'
if (issueState === 'closed' && !prActive) {
if (current && current.phase === 'tracked') delete workByIssueKey[key]
return null
}
return this.setWorkStatus(key, {
phase: 'tracked',
existingIssueNumber: num(tracking.issueNumber),
existingIssueUrl: str(tracking.issueUrl),
existingIssueState: str(tracking.issueState),
existingPrNumber: num(tracking.prNumber),
existingPrUrl: str(tracking.prUrl),
existingPrState: str(tracking.prState),
})
},
// Drop only the proactively-detected 'tracked' entries before a fresh scan
// re-detects them, so stale tracking (e.g. a closed issue) doesn't linger,
// while canvas-initiated statuses are preserved.
clearTrackedWorkStatuses() {
for (const key of Object.keys(workByIssueKey)) {
if (workByIssueKey[key] && workByIssueKey[key].phase === 'tracked') {
delete workByIssueKey[key]
}
}
return workByIssueKey
},
// Strip the "possibly related" hints off every issue. These are searched in
// the currently selected repo, so a repo change makes them stale; drop them
// immediately rather than waiting for the next scan to rebuild fresh objects.
clearRelatedIncidents() {
for (const category of categories) {
for (const issue of category.issues || []) {
if (issue && issue.relatedIncidents) delete issue.relatedIncidents
}
}
return categories
},
// Advance the scope generation. Any in-flight operation that captured an
// older generation (a multi-item onWorkSelected loop, a pending PR callback)
// will fail its scopeCurrent() check and stop/reject rather than writing onto
// a scope that is no longer active.
advanceScopeGen() {
scopeGen += 1
return scopeGen
},
clearWorkStatuses() {
// A scope switch invalidates every in-flight operation bound to the old
// scope; bump the generation so their late callbacks are rejected.
scopeGen += 1
for (const key of Object.keys(workByIssueKey)) {
delete workByIssueKey[key]
}
return workByIssueKey
},
getScopeGen() {
return scopeGen
},
applyRepoDefaults({ repo = '', baseBranch = '', localPath = '' } = {}) {
if (!prTargets.cloud.repo && repo) prTargets.cloud.repo = repo
if (!prTargets.cloud.baseBranch && baseBranch) prTargets.cloud.baseBranch = baseBranch
if (!prTargets.local.path && localPath) prTargets.local.path = localPath
if (!prTargets.local.baseBranch && baseBranch) prTargets.local.baseBranch = baseBranch
return prTargets
},
}
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "sentry-triage",
"description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.",
"version": "1.0.0",
"author": {
"name": "Liz Tom",
"url": "https://github.com/liztom"
},
"repository": "https://github.com/github/awesome-copilot",
"license": "MIT",
"keywords": [
"canvas",
"copilot-canvas",
"copilot-extension",
"error-triage",
"incident-response",
"on-call",
"sentry"
],
"extensions": {
"com.github.copilot": {
"logo": "assets/preview.png"
}
}
}