mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 02:35:00 +00:00
chore: publish from main
This commit is contained in:
@@ -1339,6 +1339,12 @@
|
||||
"description": "Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "sentry-triage",
|
||||
"source": "plugins/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"
|
||||
},
|
||||
{
|
||||
"name": "signals-dashboard",
|
||||
"source": "plugins/signals-dashboard",
|
||||
|
||||
@@ -104,6 +104,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t
|
||||
| [rust-mcp-development](../plugins/rust-mcp-development/README.md) | Build high-performance Model Context Protocol servers in Rust using the official rmcp SDK with async/await, procedural macros, and type-safe implementations. | 0 items | rust, mcp, model-context-protocol, server-development, sdk, tokio, async, macros, rmcp |
|
||||
| [salesforce-development](../plugins/salesforce-development/README.md) | Complete Salesforce agentic development environment covering Apex & Triggers, Flow automation, Lightning Web Components, Aura components, and Visualforce pages. | 0 items | salesforce, apex, triggers, lwc, aura, flow, visualforce, crm, salesforce-dx |
|
||||
| [security-best-practices](../plugins/security-best-practices/README.md) | Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications. | 0 items | security, accessibility, performance, code-quality, owasp, a11y, optimization, best-practices |
|
||||
| [sentry-triage](../plugins/sentry-triage/README.md) | Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR. | 1 items | canvas, copilot-canvas, copilot-extension, error-triage, incident-response, on-call, sentry |
|
||||
| [signals-dashboard](../plugins/signals-dashboard/README.md) | Real-time Workshop dashboard with agent signals, honesty calibration, cost-aware desk profiles, and fail-closed Local Delegation. | 1 items | agent-signals, dashboard, multi-agent, coordination, canvas, local-delegation |
|
||||
| [site-studio](../plugins/site-studio/README.md) | Plan, draft, and track a personal website section by section — a shared canvas where you and your agent author content, watch progress, and review every change. | 1 items | agent-collaboration, content-authoring, personal-website, progress-tracking, section-planning, site-builder |
|
||||
| [skill-image-gen](../plugins/skill-image-gen/README.md) | Generate images using AI directly from your coding workflow. Supports OpenAI (gpt-image-2) and Google Gemini. BYO API key — the skill guides you through setup on first use. | 0 items | image-generation, openai, gemini, ai, art, sprites, textures, icons |
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
|
||||
/** 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 isn’t 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 'Couldn’t 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
@@ -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 |
+115
@@ -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>`
|
||||
}
|
||||
+29
@@ -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>`
|
||||
}
|
||||
+2035
File diff suppressed because it is too large
Load Diff
+4
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
|
||||
/** 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 isn’t 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 'Couldn’t 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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user