mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 18:45:12 +00:00
chore: publish from main
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user