Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -2211,7 +2211,7 @@
"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.2.0"
"version": "1.3.0"
},
{
"name": "signals-dashboard",
Expand Down
55 changes: 38 additions & 17 deletions extensions/sentry-triage/components/card.mjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import { escapeHtml, safeHref } from '../escape.mjs'

// KEEP IN SYNC with compactNumber in page.mjs's client script.
function compactNumber(value) {
const n = Number(value) || 0
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + 'M'
if (n >= 1e4) return Math.round(n / 1e3) + 'k'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
return String(n)
}

function statusLabel(workStatus) {
if (!workStatus || typeof workStatus !== 'object') return ''
if (workStatus.phase === 'working' || workStatus.phase === 'queued') {
return workStatus.copilotFix ? '⏳ starting Copilot fix session…' : '⏳ filing tracking issue…'
return workStatus.copilotFix ? 'Starting Copilot fix…' : 'Filing tracking issue…'
}
if (workStatus.phase === 'done') {
// Numbers move into clickable links (statusLinks); keep the label a plain badge.
return 'created ✓'
return 'Created'
}
if (workStatus.phase === 'handed-off') {
const sessionPart = workStatus.sessionName ? `🧵 ${workStatus.sessionName}` : '🧵 session started'
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'}`
if (workStatus.phase === 'skipped') return 'Already in progress'
if (workStatus.phase === 'tracked') return 'Tracked'
if (workStatus.phase === 'error') return workStatus.error || 'Work failed'
return ''
}

Expand Down Expand Up @@ -60,7 +69,21 @@ function statusLinks(workStatus) {
return links.join(' · ')
}

export function Card({ key, summary, plainEnglish, reason, events, users, url, workStatus, plainEnglishView, availableModels = [] }) {
// KEEP IN SYNC with sparklineSvg in page.mjs's client script.
function sparklineSvg(trend) {
const values = (Array.isArray(trend) ? trend : []).map((v) => Number(v) || 0)
if (values.length < 2) return ''
const w = 72, h = 24, max = Math.max(1, ...values)
const pts = values.map((v, i) => `${((i / (values.length - 1)) * w).toFixed(1)},${(h - 2 - (v / max) * (h - 4)).toFixed(1)}`)
return `<svg class="card-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Event trend" preserveAspectRatio="none"><polygon class="card-spark-fill" points="0,${h} ${pts.join(' ')} ${w},${h}" /><polyline class="card-spark-line" points="${pts.join(' ')}" /></svg>`
}

function trendAttrs(trend, trendStart, trendStep) {
const values = (Array.isArray(trend) ? trend : []).map((v) => Number(v) || 0)
return values.length ? ` data-trend="${values.join(',')}" data-trend-start="${Number(trendStart) || 0}" data-trend-step="${Number(trendStep) || 0}"` : ''
}

export function Card({ key, category, categoryLabel, summary, plainEnglish, reason, events, users, url, workStatus, plainEnglishView, availableModels = [], trend, trendStart, trendStep }) {
const href = safeHref(url)
const safeKey = escapeHtml(key)
const title = plainEnglishView ? (plainEnglish || summary) : summary
Expand All @@ -80,9 +103,10 @@ export function Card({ key, summary, plainEnglish, reason, events, users, url, w
? 'working'
: 'idle'
const meta = [
events != null ? `${Number(events).toLocaleString()} events` : null,
users != null ? `${Number(users).toLocaleString()} users` : null
].filter(Boolean).join(' · ')
sparklineSvg(trend) || null,
events != null ? `<span class="metric metric-events" title="${Number(events).toLocaleString()} events"><b>${compactNumber(events)}</b><small>events</small></span>` : null,
users != null ? `<span class="metric metric-users" title="${Number(users).toLocaleString()} users"><b>${compactNumber(users)}</b><small>users</small></span>` : null
].filter(Boolean).join('')

// Per-card model override. Empty value means "use the toolbar/batch default".
const cardModelOptions = ['<option value="">Default</option>']
Expand All @@ -93,22 +117,19 @@ export function Card({ key, summary, plainEnglish, reason, events, users, url, w
)
.join('')

return `<div class="card" data-key="${safeKey}">
return `<div class="card" data-key="${safeKey}"${category ? ` data-category="${escapeHtml(category)}"` : ''} data-title="${escapeHtml(summary)}" data-events="${Number(events) || 0}" data-users="${Number(users) || 0}"${trendAttrs(trend, trendStart, trendStep)}>
<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>` : ''}
<button type="button" class="card-summary card-open" data-key="${safeKey}" title="View issue details">${escapeHtml(title)}</button>
<span class="card-sub">${categoryLabel ? `<span class="card-cat">${escapeHtml(categoryLabel)}</span>` : ''}<a href="${href}" target="_blank" rel="noopener noreferrer" class="card-key" title="Open in Sentry">${safeKey}</a><span class="card-reason">${escapeHtml(reason)}</span></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>
${meta ? `<span class="card-meta">${meta}</span>` : ''}
<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>
Expand Down
45 changes: 27 additions & 18 deletions extensions/sentry-triage/components/category.mjs
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
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.'
}
// KEEP IN SYNC with ISSUE_TABS in page.mjs's client script.
export const ISSUE_TABS = [
{ id: 'regressions', label: 'Regressions' },
{ id: 'escalating', label: 'Escalating' },
{ id: 'new-critical', label: 'New' },
]

export function IssueTable({ categories, plainEnglishView, availableModels = [] }) {
const total = categories.reduce((sum, c) => sum + c.issues.length, 0)
if (!total) return ''

export function Category({ id, name, issues, plainEnglishView, availableModels = [] }) {
if (!issues.length) return ''
const countFor = (id) => categories.filter((c) => c.id === id).reduce((sum, c) => sum + c.issues.length, 0)
const labelFor = (id) => (ISSUE_TABS.find((t) => t.id === id) || {}).label || id
const tab = (id, label, count) =>
`<button type="button" role="tab" class="issue-tab" data-tab="${escapeHtml(id)}" aria-selected="${id === 'all'}"${count ? '' : ' disabled'}>${escapeHtml(label)} <span class="badge">${count}</span></button>`

const description = CATEGORY_DESCRIPTIONS[id] || ''
const safeId = escapeHtml(id)
const rows = categories.flatMap((c) =>
c.issues.map((issue) => Card({ ...issue, category: c.id, categoryLabel: labelFor(c.id), plainEnglishView, availableModels }))
)

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
return `<section class="issue-table" data-tab="all">
<div class="issue-tabs">
<label class="category-select-all" title="Select all">
<input type="checkbox" class="category-check" aria-label="Select all visible issues" />
</label>
${description ? `<p class="category-description">${description}</p>` : ''}
<div class="issue-tablist" role="tablist" aria-label="Filter issues">
${tab('all', 'View all', total)}
${ISSUE_TABS.map((t) => tab(t.id, t.label, countFor(t.id))).join('')}
</div>
</div>
<div class="card-list">
${issues.map((issue) => Card({ ...issue, plainEnglishView, availableModels })).join('')}
<div class="card-list" role="tabpanel">
${rows.join('')}
</div>
</section>`
}
Loading
Loading