mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-27 11:05:11 +00:00
* Harden sentry-triage repo/URL metadata validation and split issue/PR repos The canvas relays model-reported GitHub artifact URLs and numbers (from a turn that also ingests untrusted Sentry text) into tracking badges and the "Fix with Copilot" flow. Validate that data against trusted, outside-the-model anchors so injected text can't mint misleading badges or steer a write to the wrong repo, and support issue/PR repos that legitimately differ. - Add path-type-aware URL validation (repoRefNumber/urlInRepo): a URL must be http(s), on the trusted host, in the expected owner/repo, and match the exact artifact kind (/issues/<n> vs /pull/<n>) with a path-segment boundary after the id. The id must be a positive safe integer. This rejects look-alikes like /pull/123evil, cross-kind spoofs (an issue URL minting a "PR #N" badge), and overflow/zero ids. - Split the issue anchor from the PR anchor (deriveRepoAnchors): the tracking issue and the fix PR can live in different repos. Cloud mode anchors the PR on the issue/cloud repo; an explicitly selected local project anchors it on that project's config-time repo (frozen at save time); "Current project" anchors it on the trusted current-project repo from the git remote — never the issue repo. A missing/malformed PR repo fails closed with a clear, actionable error. - Derive tracking issue/PR numbers from the validated URLs (not the model), and strip every pr* field when no concrete /pull/<n> in the PR repo is present, so a badge can't point at a different artifact than the one verified. - Freeze the selected project's repo into PR-target config at save time (page.mjs/state.mjs/server.mjs) and include mode + local.repo in the repo-change identity so switching projects re-derives annotations. - Roll the optimistic rescan overlay back on a failed /api/refresh POST instead of leaving a blocking overlay up until the fallback timer expires. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Derive displayed numbers from validated URLs; isolate project enumeration Address Copilot review on #2815. Comment #2: every displayed issue/PR number is now re-extracted from the same URL that was validated against the expected repo, instead of trusting the model-reported *Number field. Fixes the skipped/dedup, done, and submit_work_pr paths so a reply pairing /pull/1 with prNumber 999 can no longer render "PR #999" linking to PR 1. Numbers with no concrete anchor (placeholder repo / non-GitHub tracker) keep the model value. Comment #1: the projectId->repo mapping is a security anchor (it decides which repo fix-session PRs are validated against), so it must not be produced in a turn that also ingests untrusted Sentry titles. Enumeration now runs in its own Sentry-free turn (loadProjectOptions), and the save path re-binds the repo/name server-side from the selected projectId against trusted project state; the browser no longer supplies the auth repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Demote sentry-triage project map from an authorization anchor The projectId->repo map returned by the model's list_projects tool was being used as the trust anchor for fix-session PR validation, and was isolated into a separate "project enumeration" turn to keep untrusted Sentry titles out of the prompt that produced it. But that turn joins the same foreground conversation, so it is not a real trust boundary. The SDK exposes no isolated model context and no trusted project- enumeration API, so instead stop treating the model-relayed repo as an authorization anchor at all. An explicitly selected project is authorized by its host-resolved project_id (a trusted handle at create_session time), so deriveRepoAnchors now returns prExpectedRepo='' + prProjectSelected for that case and the preflight/URL gates skip the unbackable repo check. The map is now display/dedup-search only, so enumeration is folded back into the triage round-trip and the separate turn is removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix fallout from demoting the sentry-triage project map Follow-ups from review after the project-map demotion: - Reject stale/forged local project IDs in set-pr-config. An unknown non-empty projectId left boundProject null but was still stored, and deriveRepoAnchors treats any non-empty id as an explicit selection — which skips the missing-repo/PR-URL preflight. Store the id only when it resolves to a known project, so an unresolvable id fails closed to '' (Current-project mode, which requires a trusted git remote). - Restore tracked-PR badges for split-repo selected projects. The tracked badge validated the model-reported PR URL against the now-empty trusted PR anchor, so repoRefNumber always returned null and every discovered PR was stripped. The badge is read-only display, not a write gate, so anchor its search + URL/number validation on the model-declared project repo when there is no trusted anchor (still pins number->URL and host). - Update set-pr-config and page.mjs comments: the bound repo/name are display + dedup-search metadata, not PR authorization, and drop the stale loadProjectOptions / "Sentry-free turn" references. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fail closed on an unconfirmable dedup skip for selected projects After demoting the project map, prExpectedRepo is '' for an explicitly selected project, so the dedup "already being worked on" skip accepted a bare model-reported PR number as authoritative. Because the dedup result and the project list come from the same Sentry-bearing model turn, injected text could forge a skip to both suppress the trusted host-resolved create_session flow and surface an arbitrary PR link. An authoritative skip suppresses the trusted flow and surfaces a PR link, so it now requires a trusted PR-repo anchor: a URL-bearing PR validated against a concrete prExpectedRepo. A selected project has no trusted anchor, so its skip is unconfirmable and fails closed to a retryable error instead of being honored. The downstream dedup validation is now only reached with a concrete PR anchor, so its dead no-anchor branches are removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Anchor Current-project PR repo to the checkout cwd, not GITHUB_REPOSITORY runtimeDefaults.repo prefers GITHUB_REPOSITORY, which is the configured ISSUE target and may point at a separate cloud repo; seedDefaultsFromSession() does not overwrite an explicit GITHUB_REPOSITORY. All three deriveRepoAnchors calls passed that value as currentProjectRepo, so when the issue repo differed from the driving checkout, Current-project mode gated PR validation and dedup on the issue repo and rejected legitimate PRs from the current project. Derive the Current-project anchor unconditionally from the resolved session cwd via currentCheckoutRepo(defaults) = repoFromPath(localPath), which co-derives from the same git remote as the trusted host, and use it at all three anchor derivations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Restrict local Copilot handoff to the current project Remove the model-relayed "selected project" local write-handoff and its whole plumbing (the Settings project dropdown, submit_projects/list_projects enumeration, projectOptions state, and the server-side projectId re-bind). The dropdown was the only thing feeding a model-supplied project_id into create_session, and the only local mode whose PR repo came solely from the untrusted Sentry triage turn. That made it both the write-steering vector the reviewer flagged (server.mjs set-pr-config / deriveRepoAnchors) and the one local mode where a dedup "skip" could never be verified against a trusted repo. Local "Work on selected" now always runs the fix session in the CURRENT project (the canvas's own checkout, host-trusted from its git remote), so the PR repo is always a trusted anchor and dedup is always verifiable. Cross-repo work goes through Cloud mode, whose repo the user types directly in Settings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
509 lines
19 KiB
JavaScript
509 lines
19 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 = ''
|
|
// 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: '' },
|
|
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),
|
|
},
|
|
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
|
|
},
|
|
|
|
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.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
|
|
},
|
|
}
|
|
}
|