mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 18:45:12 +00:00
537 lines
20 KiB
JavaScript
537 lines
20 KiB
JavaScript
// 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
|
|
},
|
|
}
|
|
}
|