From a5b1385cc0928ee27cbcad85da66fe1778957cd1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:36:44 +0000 Subject: [PATCH] chore: publish from main --- .github/plugin/marketplace.json | 2 +- extensions/sentry-triage/components/page.mjs | 427 +++++++++++++++--- extensions/sentry-triage/extension.mjs | 24 +- extensions/sentry-triage/package.json | 2 +- extensions/sentry-triage/sentry.mjs | 23 +- extensions/sentry-triage/sentryClient.mjs | 14 +- extensions/sentry-triage/server.mjs | 36 +- extensions/sentry-triage/styles.mjs | 9 + .../sentry-triage/components/page.mjs | 427 +++++++++++++++--- .../extensions/sentry-triage/extension.mjs | 24 +- .../extensions/sentry-triage/package.json | 2 +- .../extensions/sentry-triage/sentry.mjs | 23 +- .../extensions/sentry-triage/sentryClient.mjs | 14 +- .../extensions/sentry-triage/server.mjs | 36 +- .../extensions/sentry-triage/styles.mjs | 9 + plugins/sentry-triage/plugin.json | 2 +- 16 files changed, 908 insertions(+), 166 deletions(-) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index d831229e..f08731cc 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1349,7 +1349,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.0.0" + "version": "1.1.0" }, { "name": "signals-dashboard", diff --git a/extensions/sentry-triage/components/page.mjs b/extensions/sentry-triage/components/page.mjs index e44fa7ca..b3fe8895 100644 --- a/extensions/sentry-triage/components/page.mjs +++ b/extensions/sentry-triage/components/page.mjs @@ -392,6 +392,21 @@ export function Page({ // requestProjectsForOrg while seeding the org select. Seeded at init. const projectsByOrg = {}; + // Cache of exact-slug resolutions (org "/" slug -> "checking" | "found" | + // "missing" | "error", plus the canonical slug when found). The paged + // project list can't reach every project in a mega-org, so a valid typed + // slug may show "No matching projects"; a live project.view lookup (via + // /api/resolve-project) confirms it exists and lets the user select it. + const projectResolveCache = {}; + // Callbacks waiting on an in-flight lookup, keyed the same way. When a check + // is already running for a slug, later callers (e.g. the Scan/Fetch gate) + // queue here and are flushed when it settles, instead of being dropped. + const projectResolveWaiters = {}; + function projectResolveKey(org, slug) { + return String(org || "").trim().toLowerCase() + "/" + String(slug || "").trim().toLowerCase(); + } + + function submitScan() { const input = document.getElementById("org-input"); const projectInput = document.getElementById("project-input"); @@ -406,29 +421,44 @@ export function Page({ const project = projectInput ? projectInput.value.trim() : ""; const repo = repoInput ? repoInput.value.trim() : ""; if (!org) { if (input) input.focus(); return; } - if (input) { input.value = org; input.disabled = true; } - if (projectInput) projectInput.disabled = true; - const scanBtn = document.getElementById("scan-btn"); - if (scanBtn) { scanBtn.disabled = true; scanBtn.textContent = "Scanning…"; } - showScanOverlay(project ? "Scanning " + project + "…" : "Scanning all projects…"); - fetch("/api/set-org", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ org, project, repo }) - }).then((res) => { - if (!res.ok) throw new Error("set-org " + res.status); - }).catch(() => { - // The loopback POST failed (server stopped, rejected, or a transport - // blip). Without this, the overlay would linger until its 250s safety - // timer while the form stayed disabled — a dead end with no retry path - // short of reopening the canvas. Restore the controls and tell the user - // so they can try again. - hideScanOverlay(); - if (input) input.disabled = false; - if (projectInput) projectInput.disabled = false; - if (scanBtn) { scanBtn.disabled = false; scanBtn.textContent = "Scan"; } - syncScanButtonState(); - window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again."); + // Gate: a typed project must be verified against Sentry before the scan + // starts, so clicking Scan (or submitting the form) can't start a scan on + // an unverified slug the way Enter in the autocomplete already prevents. + // An empty project scans all projects and needs no lookup. + verifyProjectForScan(org, project).then((v) => { + if (!v.ok) { + showToast(v.reason === "missing" + ? "No project \u201C" + project + "\u201D in " + org + " — check the slug." + : "Couldn't verify that project with Sentry — try again."); + if (projectInput) projectInput.focus(); + return; + } + const proj = v.slug; + if (projectInput && proj !== project) projectInput.value = proj; + if (input) { input.value = org; input.disabled = true; } + if (projectInput) projectInput.disabled = true; + const scanBtn = document.getElementById("scan-btn"); + if (scanBtn) { scanBtn.disabled = true; scanBtn.textContent = "Scanning…"; } + showScanOverlay(proj ? "Scanning " + proj + "…" : "Scanning all projects…"); + fetch("/api/set-org", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ org, project: proj, repo }) + }).then((res) => { + if (!res.ok) throw new Error("set-org " + res.status); + }).catch(() => { + // The loopback POST failed (server stopped, rejected, or a transport + // blip). Without this, the overlay would linger until its 250s safety + // timer while the form stayed disabled — a dead end with no retry path + // short of reopening the canvas. Restore the controls and tell the user + // so they can try again. + hideScanOverlay(); + if (input) input.disabled = false; + if (projectInput) projectInput.disabled = false; + if (scanBtn) { scanBtn.disabled = false; scanBtn.textContent = "Scan"; } + syncScanButtonState(); + window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again."); + }); }); } @@ -1166,6 +1196,22 @@ export function Page({ return currentProject && !list.includes(currentProject) ? [currentProject, ...list] : list; } + // The project slugs known to belong to a specific org. Always read from the + // org-keyed cache (populated by the initial seed and every SSE broadcast under + // the list's own org), never from the painted currentSentryProjects list: on a + // free-text header org switch, fetchScoped() sets currentOrg to the new org + // before its project-list broadcast arrives, so the painted list still holds + // the previous org's slugs. Trusting currentOrg === slug there would treat + // those stale slugs as local and let Enter bypass the exact-slug resolver. The + // cache only ever holds each org's own list, so an org with no entry yet + // returns [] and every typed slug is verified — the safe direction. + function localProjectChoicesForOrg(org) { + const slug = String(org || "").trim().toLowerCase(); + if (!slug) return []; + const cached = projectsByOrg[slug]; + return Array.isArray(cached) ? cached.filter(Boolean) : []; + } + // The project input on screen — the header switcher in triage chrome, else // the setup-screen field. function activeProjectInput() { @@ -1223,13 +1269,95 @@ export function Page({ }).catch(() => setProjectLoading(false)); } - // Wire a text input as a project autocomplete: a filtered suggestion menu. + // Resolve one exact typed slug against Sentry (project.view) and cache the + // outcome. Called ONLY from an explicit commit (Enter), never on keystroke, + // so at most one lookup per committed slug is ever issued — no per-prefix + // queue of stale lookups. onDone runs after the cache is updated + // (found/missing/error) so the caller can commit the canonical slug or + // repaint an open menu. In-flight and settled lookups are not repeated; a + // prior "error" is cleared by the caller before an explicit retry. + function resolveProjectSlug(org, slug, onDone) { + const o = String(org || "").trim().toLowerCase(); + const s = String(slug || "").trim(); + if (!o || !s) return; + const key = projectResolveKey(o, s); + const cached = projectResolveCache[key]; + // A settled entry (found/missing/error) fires immediately. A check that's + // already in flight can't fire yet, so queue this callback to be flushed + // when it settles — otherwise a second caller during "checking" is dropped. + if (cached) { + if (cached.status === "checking") { + if (onDone) (projectResolveWaiters[key] || (projectResolveWaiters[key] = [])).push(onDone); + } else if (onDone) { + onDone(cached); + } + return; + } + projectResolveCache[key] = { status: "checking", slug: "" }; + // Record the settled entry, notify this caller, then flush anyone who + // queued while the request was in flight. + const settle = (entry) => { + projectResolveCache[key] = entry; + if (onDone) onDone(entry); + const waiters = projectResolveWaiters[key]; + if (waiters) { + delete projectResolveWaiters[key]; + for (const fn of waiters) { try { fn(entry); } catch (_) {} } + } + }; + fetch("/api/resolve-project", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ org: o, slug: s }) + }) + .then((r) => r.json()) + .then((d) => { + if (!d || d.ok === false) settle({ status: "error", slug: "" }); + else if (d.found) settle({ status: "found", slug: String(d.slug || s) }); + else settle({ status: "missing", slug: "" }); + }) + .catch(() => { + settle({ status: "error", slug: "" }); + }); + } + + // Ensure a typed project is a real project in the org before any scan starts, + // so the "scans never run against an unverified project" invariant holds for + // pointer users too — the setup Scan button/form and the header Fetch button, + // not just Enter in the autocomplete. Resolves to the canonical slug. An empty + // project is an all-projects scan (always allowed); a locally-known project + // needs no lookup; anything else is verified via the shared resolver, reusing + // its cache and in-flight de-duplication. + function verifyProjectForScan(org, project) { + return new Promise((resolve) => { + const o = String(org || "").trim().toLowerCase(); + const raw = String(project || "").trim(); + if (!raw) { resolve({ ok: true, slug: "" }); return; } + if (!o) { resolve({ ok: true, slug: raw }); return; } + const p = raw.toLowerCase(); + if (localProjectChoicesForOrg(o).some((s) => s.toLowerCase() === p)) { + resolve({ ok: true, slug: raw }); + return; + } + const key = projectResolveKey(o, p); + const rc = projectResolveCache[key]; + if (rc && rc.status === "found") { resolve({ ok: true, slug: rc.slug || raw }); return; } + if (rc && rc.status === "missing") { resolve({ ok: false, reason: "missing", slug: raw }); return; } + if (rc && rc.status === "error") delete projectResolveCache[key]; + resolveProjectSlug(o, p, (res) => { + if (res && res.status === "found") resolve({ ok: true, slug: res.slug || raw }); + else resolve({ ok: false, reason: (res && res.status) || "error", slug: raw }); + }); + }); + } + + // (A is unwieldy for orgs with many projects, and a // does not repaint in this webview.) onCommit(value) runs when the user // picks a suggestion; an empty value means "all projects". The project list // is read live on each open, so newly-discovered projects appear without a // rebuild. Idempotent per input. - function attachProjectAutocomplete(input, onCommit) { + function attachProjectAutocomplete(input, onCommit, onVerifiedCommit) { if (!input || input.dataset.acWired) return; input.dataset.acWired = "1"; // Ensure the input sits in a positioned wrapper with a menu container. @@ -1252,6 +1380,22 @@ export function Page({ // readers announce the popup and can follow the active option. if (!menu.id) menu.id = (input.id ? input.id : "project-ac") + "-listbox"; input.setAttribute("aria-controls", menu.id); + // A visually-hidden live region announces exact-slug resolution state + // (checking / verified / not found / couldn't check) to screen readers, + // because the listbox itself is not a live region and its contents are + // replaced silently. Associated with the combobox via aria-describedby. + let statusEl = wrap.querySelector(".project-ac-status"); + if (!statusEl) { + statusEl = document.createElement("div"); + statusEl.className = "project-ac-status"; + statusEl.setAttribute("aria-live", "polite"); + statusEl.setAttribute("role", "status"); + statusEl.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;"; + wrap.appendChild(statusEl); + } + if (!statusEl.id) statusEl.id = menu.id + "-status"; + const describedBy = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean); + if (!describedBy.includes(statusEl.id)) { describedBy.push(statusEl.id); input.setAttribute("aria-describedby", describedBy.join(" ")); } const optionId = (i) => menu.id + "-opt-" + i; function syncActiveDescendant() { if (active >= 0 && items[active]) input.setAttribute("aria-activedescendant", optionId(active)); @@ -1262,7 +1406,8 @@ export function Page({ const CAP = 50; function render() { const q = input.value.trim().toLowerCase(); - const source = sentryProjectChoices(); + const org = selectedOrgSlug(); + const source = localProjectChoicesForOrg(org); let matches = q ? source.filter((s) => s.toLowerCase().includes(q)) : source.slice(); const truncated = matches.length > CAP; matches = matches.slice(0, CAP); @@ -1270,8 +1415,55 @@ export function Page({ // Offer an explicit "all projects" reset only when the box is empty. if (!q) items.push({ value: "", label: "All projects", all: true }); matches.forEach((s) => items.push({ value: s, label: s })); + + // Exact-slug resolution: when the user has typed something with no + // exact local match, an explicit commit (Enter) asks Sentry directly + // whether that project exists (the paged list can't reach every project + // in a mega-org). This turns a misleading "No matching projects" into a + // selectable verified option — or an honest "not found" — without paging + // thousands of projects. + let resolveHint = ""; + let statusText = ""; + const hasExactLocal = source.some((s) => s.toLowerCase() === q); + if (q && org && !hasExactLocal) { + const key = projectResolveKey(org, q); + const rc = projectResolveCache[key]; + // Resolution is triggered ONLY on an explicit commit (Enter / click), + // never as a side effect of typing. So render() just REFLECTS whatever + // the cache already holds: it neither schedules a lookup nor cancels + // one. That removes the per-prefix queue of stale lookups AND the + // completion-repaint retry loop the earlier keystroke-debounced version + // could spin during an outage. + if (!rc) { + resolveHint = "prompt"; // offer an explicit check on Enter + } else if (rc.status === "checking") { + resolveHint = "checking"; + statusText = "Checking Sentry for \u201C" + q + "\u201D\u2026"; + } else if (rc.status === "found") { + // Surface the canonical slug as a verified, selectable option at the + // top — deduped against any local match with the same slug. + const canon = rc.slug || q; + const dup = items.some((it) => it.value && it.value.toLowerCase() === canon.toLowerCase()); + if (!dup) items.unshift({ value: canon, label: canon, verified: true }); + statusText = "Verified project \u201C" + canon + "\u201D in " + org; + } else if (rc.status === "missing") { + resolveHint = "missing"; + statusText = "No project \u201C" + q + "\u201D in " + org; + } else if (rc.status === "error") { + resolveHint = "error"; + statusText = "Couldn't check Sentry for \u201C" + q + "\u201D"; + } + } + if (statusEl && statusEl.textContent !== statusText) statusEl.textContent = statusText; + if (!items.length) { - menu.innerHTML = '
No matching projects
'; + const empty = + resolveHint === "checking" ? "Checking Sentry…" + : resolveHint === "prompt" ? "Press Enter to check Sentry for \u201C" + escapeHtml(q) + "\u201D" + : resolveHint === "missing" ? "No project \u201C" + escapeHtml(q) + "\u201D in " + escapeHtml(org) + : resolveHint === "error" ? "Couldn't check Sentry — press Enter to retry" + : "No matching projects"; + menu.innerHTML = '
' + empty + '
'; syncActiveDescendant(); return; } @@ -1281,31 +1473,93 @@ export function Page({ .map((it, i) => '
' + (it.all ? '' + escapeHtml(it.label) + '' : escapeHtml(it.label)) + + (it.verified ? '\u2713' : '') + '
') .join('') + - (truncated ? '
Keep typing to narrow…
' : ''); + (resolveHint === "checking" ? '
Checking Sentry…
' + : resolveHint === "prompt" ? '
Press Enter to check Sentry for \u201C' + escapeHtml(q) + '\u201D
' + : resolveHint === "error" ? "
Couldn't check Sentry — press Enter to retry
" + : resolveHint === "missing" ? '
No project \u201C' + escapeHtml(q) + '\u201D in ' + escapeHtml(org) + '
' + : truncated ? '
Keep typing to narrow…
' : ''); syncActiveDescendant(); } function open() { render(); menu.hidden = false; input.setAttribute("aria-expanded", "true"); } function close() { menu.hidden = true; active = -1; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); } - function commit(value) { + function commit(value, opts) { input.value = value; close(); - if (onCommit) onCommit(value); + // Committing the exact-slug *verified* option (an off-list project Sentry + // confirmed) uses onVerifiedCommit when provided, so the setup field can + // start the scan on it even though its onCommit is null (plain local list + // picks there just fill the box). Every other commit uses onCommit. On the + // header both callbacks are the same fetchScoped, so behavior is unchanged. + const cb = (opts && opts.verified && onVerifiedCommit) ? onVerifiedCommit : onCommit; + if (cb) cb(value); } input.addEventListener("focus", open); - input.addEventListener("input", () => { active = -1; open(); }); + input.addEventListener("input", () => { + active = -1; + // Editing the query clears a stale transient-failure marker for exactly + // this slug, so the menu drops back to the "Press Enter to check" prompt + // instead of showing a leftover error. The actual (re)check happens only + // when the user presses Enter — never as a side effect of typing. + const q = input.value.trim().toLowerCase(); + const org = selectedOrgSlug(); + if (q && org) { + const key = projectResolveKey(org, q); + const rc = projectResolveCache[key]; + if (rc && rc.status === "error") delete projectResolveCache[key]; + } + open(); + }); input.addEventListener("keydown", (e) => { if (menu.hidden) { - if (e.key === "ArrowDown") { e.preventDefault(); open(); } - return; + if (e.key === "ArrowDown") { e.preventDefault(); open(); return; } + // Enter still needs the exact-slug lookup below even when the menu is + // closed (e.g. after Escape); otherwise a typed slug bubbles to the scan + // handler and is sent to /api/set-org unverified. Short-circuit the rest. + if (e.key !== "Enter") return; } if (e.key === "ArrowDown") { e.preventDefault(); active = Math.min(active + 1, items.length - 1); render(); } else if (e.key === "ArrowUp") { e.preventDefault(); active = Math.max(active - 1, 0); render(); } else if (e.key === "Enter") { - // Only intercept Enter when a suggestion is highlighted; otherwise let - // it bubble to the header handler that applies a typed value. - if (active >= 0 && items[active]) { e.preventDefault(); e.stopPropagation(); commit(items[active].value); } + // A highlighted suggestion commits directly. + if (active >= 0 && items[active]) { e.preventDefault(); e.stopPropagation(); commit(items[active].value, { verified: !!items[active].verified }); return; } + // Otherwise, if the user typed a slug with no exact local match, Enter is + // the EXPLICIT commit that triggers exact-slug resolution — we never look + // up as they type. Verify it once, then commit the canonical slug on + // success, or surface an honest "not found" / "couldn't check" in place. + const q = input.value.trim().toLowerCase(); + const org = selectedOrgSlug(); + if (q && org && !localProjectChoicesForOrg(org).some((s) => s.toLowerCase() === q)) { + e.preventDefault(); e.stopPropagation(); + const key = projectResolveKey(org, q); + const rc = projectResolveCache[key]; + if (rc && rc.status === "found") { commit(rc.slug || q, { verified: true }); return; } + if (rc && rc.status === "checking") { open(); return; } + // Fresh check, or an explicit retry after a prior transient error. + if (rc && rc.status === "error") delete projectResolveCache[key]; + resolveProjectSlug(org, q, (res) => { + // Ignore a stale completion if the box has since moved on — either + // the project text changed, or the org was edited while the lookup + // was pending (a result verified for the old org must not commit and + // let fetchScoped() scan a different, unverified org). + if (input.value.trim().toLowerCase() !== q || selectedOrgSlug() !== org) return; + // Render the resolved state before acting so the aria-live region + // publishes the completion (found/missing/error) to screen readers. + // commit() closes the menu without rendering, so a "found" result + // would otherwise leave the live region stuck at "Checking Sentry…"; + // render() first sets it to "Verified project …", then commit closes. + render(); + if (res && res.status === "found") commit(res.slug || q, { verified: true }); + }); + // open() (not render()) so the "Checking Sentry…" hint is visible even + // when this commit came from a closed menu (e.g. Enter after Escape). + open(); + return; + } + // Exact local match or empty box: let Enter bubble to the header handler + // that applies the typed value. } else if (e.key === "Escape") { e.stopPropagation(); close(); } }); // mousedown (not click) so the selection beats the input's blur. @@ -1314,18 +1568,21 @@ export function Page({ if (!el) return; e.preventDefault(); const idx = Number(el.dataset.idx); - if (items[idx]) commit(items[idx].value); + if (items[idx]) commit(items[idx].value, { verified: !!items[idx].verified }); }); input.addEventListener("blur", () => { setTimeout(close, 120); }); } // Ensure the setup-screen project field behaves as an autocomplete. The // suggestion list is read live on each open, so no rebuild is needed when - // the org's projects arrive. Picking here just fills the box; the Scan - // button reads #project-input on submit. + // the org's projects arrive. Picking a local suggestion just fills the box + // (the Scan button reads #project-input on submit) — but committing a + // Sentry-verified off-list slug starts the scan directly, so keyboard-only + // users aren't trapped re-verifying a project that never joins the local + // list. submitScan re-reads #project-input, so the committed slug is used. function renderSetupProjectField() { const el = document.getElementById("project-input"); - if (el) attachProjectAutocomplete(el, null); + if (el) attachProjectAutocomplete(el, null, () => submitScan()); } // Ensure the project control exists in the header and reflects the current @@ -1383,7 +1640,13 @@ export function Page({ label.appendChild(fetchBtn); scope.insertBefore(label, scope.firstChild); } - input.value = currentProject || ""; + // Reflect the current project, but never while the user is editing this + // field: a streamed project page repaints the switcher (renderProjectSwitcher + // runs per SSE page), and clobbering the value mid-type would erase an + // in-progress slug — and with it the query captured for exact-slug + // resolution, so the first Enter's lookup would be silently dropped by the + // stale-completion guard. Only sync when the field isn't focused. + if (document.activeElement !== input) input.value = currentProject || ""; attachProjectAutocomplete(input, () => fetchScoped()); // Org control, injected before the project switcher so the header reads @@ -1506,40 +1769,56 @@ export function Page({ const org = orgInput ? orgInput.value.trim() : currentOrg; const project = projectInput ? projectInput.value.trim() : ""; if (!org) { if (orgInput) orgInput.focus(); return; } - if (org !== currentOrg || project !== currentProject) resetPerCardState(); - // Snapshot the last-scanned scope BEFORE the optimistic update so a failed - // rescan can roll back. Without this, a network/server failure leaves the - // switchers showing the new org/project while the still-rendered cards - // belong to the old scope — stale data under a selection we never scanned. - const prevOrg = currentOrg; - const prevProject = currentProject; - const subtitle = document.querySelector(".page-subtitle"); - const prevSubtitle = subtitle ? subtitle.textContent : ""; - currentOrg = org; - currentProject = project; - updateTriageDesc(); - if (subtitle) subtitle.textContent = "Scanning " + (project || "all projects") + "..."; - showScanOverlay("Scanning " + (project || "all projects") + "…"); - fetch("/api/set-org", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ org, project }) - }).then((res) => { - if (!res.ok) throw new Error("set-org " + res.status); - }).catch(() => { - // Loopback POST failed (server stopped/rejected, or a transport blip). - // Roll the optimistic selection back to the last-scanned scope so the - // switchers stay consistent with the cards still on screen, clear the - // overlay (which would otherwise linger until its safety timer), and - // tell the user so they can retry. - hideScanOverlay(); - currentOrg = prevOrg; - currentProject = prevProject; - if (orgInput) orgInput.value = prevOrg; - if (projectInput) projectInput.value = prevProject; + // Gate: verify a typed project against Sentry before any optimistic update + // or scan, so the Fetch button and header-Enter can't scan an unverified + // slug. (The autocomplete's own Enter resolves before committing; this + // covers the pointer path and Enter on the raw org/project inputs.) An + // empty project scans all projects and needs no lookup. + verifyProjectForScan(org, project).then((v) => { + if (!v.ok) { + showToast(v.reason === "missing" + ? "No project \u201C" + project + "\u201D in " + org + " — check the slug." + : "Couldn't verify that project with Sentry — try again."); + if (projectInput) projectInput.focus(); + return; + } + const proj = v.slug; + if (projectInput && proj !== project) projectInput.value = proj; + if (org !== currentOrg || proj !== currentProject) resetPerCardState(); + // Snapshot the last-scanned scope BEFORE the optimistic update so a failed + // rescan can roll back. Without this, a network/server failure leaves the + // switchers showing the new org/project while the still-rendered cards + // belong to the old scope — stale data under a selection we never scanned. + const prevOrg = currentOrg; + const prevProject = currentProject; + const subtitle = document.querySelector(".page-subtitle"); + const prevSubtitle = subtitle ? subtitle.textContent : ""; + currentOrg = org; + currentProject = proj; updateTriageDesc(); - if (subtitle) subtitle.textContent = prevSubtitle; - window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again."); + if (subtitle) subtitle.textContent = "Scanning " + (proj || "all projects") + "..."; + showScanOverlay("Scanning " + (proj || "all projects") + "…"); + fetch("/api/set-org", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ org, project: proj }) + }).then((res) => { + if (!res.ok) throw new Error("set-org " + res.status); + }).catch(() => { + // Loopback POST failed (server stopped/rejected, or a transport blip). + // Roll the optimistic selection back to the last-scanned scope so the + // switchers stay consistent with the cards still on screen, clear the + // overlay (which would otherwise linger until its safety timer), and + // tell the user so they can retry. + hideScanOverlay(); + currentOrg = prevOrg; + currentProject = prevProject; + if (orgInput) orgInput.value = prevOrg; + if (projectInput) projectInput.value = prevProject; + updateTriageDesc(); + if (subtitle) subtitle.textContent = prevSubtitle; + window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again."); + }); }); } diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/extension.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/extension.mjs index 1f5f74e3..a0f9cba4 100644 --- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/extension.mjs +++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/extension.mjs @@ -2,7 +2,7 @@ import { joinSession, createCanvas } from '@github/copilot-sdk/extension' import { execSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { startServer } from './server.mjs' -import { scanIssues, listOrgs, listProjects } from './sentry.mjs' +import { scanIssues, listOrgs, listProjects, findProject } from './sentry.mjs' import { checkConnections, checkConnectionsOnce } from './preflight.mjs' import { sanitizeForPrompt } from './escape.mjs' @@ -1160,6 +1160,27 @@ async function discoverProjects(entry, org, { force = false } = {}) { } } +// Resolve a single exact project slug the user typed, via the SDK's O(1) +// project.view (findProject) — the counterpart to discoverProjects's paged, +// budget-capped list. For a mega-org the list can't reach every project, so a +// valid typed slug may never appear as a suggestion; this confirms it directly. +// Returns { found, slug } where slug is the canonical spelling. Throws on a +// genuine lookup failure so the server route can answer "could not check" +// (ok:false) rather than a misleading "not found". +async function resolveProject(entry, org, slug) { + if (entry.closed) return { found: false, slug: '' } + const orgSlug = String(org || entry.state.getOrg() || entry.state.getOrgDefault() || '').trim().toLowerCase() + const wanted = String(slug || '').trim() + if (!orgSlug || !wanted) return { found: false, slug: '' } + const conn = entry.state.getConnections() + // Not reachable means we CANNOT check right now — it is not evidence the project + // is missing. Throw so the server route answers "couldn't check" (ok:false) + // rather than a false "no such project". + if (!conn || !conn.sentry || !conn.sentry.reachable) throw new Error('sentry-unreachable') + const resolved = await findProject(orgSlug, wanted) + return { found: !!resolved, slug: resolved || '' } +} + async function runConnectionCheck(entry, isCurrent) { try { const connections = await checkConnections() @@ -1962,6 +1983,7 @@ const session = await joinSession({ onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot), onRecheck: () => onRecheckConnections(entry), onListProjects: (org) => discoverProjects(entry, org, { force: true }), + onResolveProject: (org, slug) => resolveProject(entry, org, slug), onInvalidateEnrichment: () => { // Bump the scan generation so any enrichment turn still in flight // from the previous repo fails its isCurrent() guard and applies diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/package.json b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/package.json index 8a677036..f98cb2f0 100644 --- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/package.json +++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/package.json @@ -1,6 +1,6 @@ { "name": "sentry-triage", - "version": "1.0.0", + "version": "1.1.0", "main": "extension.mjs", "author": "Liz Tom", "license": "MIT", diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentry.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentry.mjs index 9cc27b1b..b294ed47 100644 --- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentry.mjs +++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentry.mjs @@ -284,8 +284,13 @@ async function listProjectsPaged(org, onPage) { // 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. +// slug directly. Returns the canonical slug on success, or '' ONLY when the +// project is confirmed not to exist. A transient/permission failure (network, +// rate limit, 403, 5xx) is NOT a "not found": it re-throws so the caller can +// report "couldn't check" instead of a false "no such project" that would train +// the user to distrust a correct slug. Runs on the shared serial SDK chain +// (projectView); interactive callers invoke it only on an explicit commit, so it +// never floods that queue. export async function findProject(org, slug) { const wanted = String(slug || '').trim() if (!wanted) return '' @@ -294,11 +299,23 @@ export async function findProject(org, slug) { const resolved = String(project?.slug || '').trim() return resolved || wanted } catch (err) { - if (err instanceof SentryError) return '' + if (err instanceof SentryError && isProjectNotFound(err)) return '' throw err } } +// Decide whether a failed project.view means the project genuinely does not +// exist (a confirmed 404 / "not found"), as opposed to a failure that merely +// prevented the check. A definite non-404 HTTP status (403/429/5xx) is never a +// not-found; only an explicit 404 or an unambiguous not-found message counts. +function isProjectNotFound(err) { + const info = sentryErrorInfo(err) + if (info && info.code) return info.code === 404 + const t = `${err?.message || ''}\n${err?.stderr || ''}` + if (/permission|forbidden|not authorized|unauthorized/i.test(t)) return false + return /\bnot found\b|no such project|does(?:n't| not) exist|unknown project/i.test(t) +} + // 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 diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentryClient.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentryClient.mjs index b3ebf2c4..26e4ba75 100644 --- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentryClient.mjs +++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/sentryClient.mjs @@ -154,8 +154,18 @@ export async function projectListRaw(org, limit = 100, cursor) { return asArray(await (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. +// Verify a specific project exists / is accessible via the SDK's O(1) +// `project.view`. Returns the raw project object on success; throws SentryError +// when the slug is unknown or forbidden. +// +// This runs on the SAME shared `runSerial` chain as every other SDK call — there +// is deliberately NO separate "fast lane". `sentry@0.42.2` keeps its per-command +// and pagination state in MODULE-GLOBAL SDK state (see the runSerial note above), +// so a second SDK instance would NOT be a concurrency-isolation boundary: it would +// still race the paged list / scan and corrupt that shared state. `project.view` +// is a single, cursor-free call, so funneling it through the one FIFO chain is +// both correct and cheap. Interactive callers trigger it only on an explicit +// commit (not per keystroke), so a real user never floods this queue. export async function projectView(org, slug) { return runSerial(async () => (await getSdk()).project.view({ orgProject: `${org}/${slug}` })) } diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/server.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/server.mjs index 18a373d7..42ecde07 100644 --- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/server.mjs +++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/server.mjs @@ -77,7 +77,7 @@ function hasValidToken(req, token) { return value === token } -export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onListProjects, onInvalidateEnrichment, defaults } = {}) { +export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onListProjects, onResolveProject, onInvalidateEnrichment, defaults } = {}) { // Per-instance state + SSE clients — never shared across canvas instances. const state = createState() state.applyRepoDefaults(defaults) @@ -241,6 +241,40 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR return } + // Resolve a single exact "org/slug" via the SDK's project.view — an O(1) + // lookup that confirms a project the paged list may never reach. For a + // mega-org (e.g. "github" has thousands of projects) list discovery is + // budget-capped, so a valid slug the user types can be absent from the + // autocomplete; this endpoint tells the client "yes, that project exists" + // (with its canonical slug) without paging. Distinguishes three outcomes so + // the UI never shows a false negative: found, genuinely-missing, and + // could-not-check (network/permission) — the latter returns ok:false. + if (req.method === 'POST' && req.url === '/api/resolve-project') { + readBody(req, res).then(async (body) => { + if (body === null) return + const { org, slug } = parseJson(body) + const wantedOrg = typeof org === 'string' ? org : '' + const wantedSlug = typeof slug === 'string' ? slug.trim() : '' + let result = { ok: true, found: false, slug: '' } + try { + if (onResolveProject && wantedSlug) { + const r = await onResolveProject(wantedOrg, wantedSlug) + const resolved = r && typeof r.slug === 'string' ? r.slug : '' + result = { ok: true, found: !!(r && r.found && resolved), slug: resolved } + } + } catch (err) { + // A lookup failure (transient network / rate limit / permission) must + // NOT be reported as "project doesn't exist" — that would train the + // user to distrust a correct slug. Signal indeterminate with ok:false. + console.error('[sentry-triage] resolve-project failed:', err instanceof Error ? err.message : err) + result = { ok: false, found: false, slug: '' } + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result)) + }) + return + } + // Set org endpoint if (req.method === 'POST' && req.url === '/api/set-org') { readBody(req, res).then((body) => { diff --git a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/styles.mjs b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/styles.mjs index 1b9a9e8a..ce20a910 100644 --- a/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/styles.mjs +++ b/plugins/sentry-triage/com.github.copilot/extensions/sentry-triage/styles.mjs @@ -236,6 +236,15 @@ export function styles() { .project-ac-item .project-ac-all { color: var(--text-color-muted, #8b949e); font-style: italic; } .project-ac-item.active .project-ac-all, .project-ac-item:hover .project-ac-all { color: var(--color-fg-on-emphasis, #ffffff); } + /* Checkmark on a slug confirmed to exist via a live project.view lookup + (a project the paged autocomplete list never reached). */ + .project-ac-item .project-ac-verified { + margin-left: 6px; + color: var(--color-success-fg, #3fb950); + font-weight: 600; + } + .project-ac-item.active .project-ac-verified, + .project-ac-item:hover .project-ac-verified { color: var(--color-fg-on-emphasis, #ffffff); } .project-ac-empty, .project-ac-more { padding: 6px 8px; diff --git a/plugins/sentry-triage/plugin.json b/plugins/sentry-triage/plugin.json index 2f75a49b..1e2791e3 100644 --- a/plugins/sentry-triage/plugin.json +++ b/plugins/sentry-triage/plugin.json @@ -2,7 +2,7 @@ "$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", + "version": "1.1.0", "author": { "name": "Liz Tom", "url": "https://github.com/liztom"