From 36f9fe788355ee16babf992c46697148eb65108f Mon Sep 17 00:00:00 2001 From: Liz Tom Date: Wed, 26 Aug 2026 18:36:09 -0700 Subject: [PATCH] Add an exact-slug Sentry project resolver to the sentry-triage canvas (#2819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix sentry-triage canvas crash when the `sentry` package isn't bundled Published awesome-copilot plugins ship extension source only, so the optional `sentry` npm package the canvas depends on at runtime may be absent. Previously that made the canvas crash on open instead of guiding the user through setup. - Load the optional `sentry` package lazily and translate only the top-level ERR_MODULE_NOT_FOUND for `sentry` into a package-missing setup state; any other import failure (missing transitive dep, entrypoint throwing) is rethrown so a real defect isn't masked behind a misleading "reinstall" message. - Add a dedicated package-missing branch to the connection preflight and a matching setup gate, kept distinct from the auth and transient-network gates so the user never sees contradictory guidance. The canvas now opens and explains what to do rather than crashing. - Clear `configured` for the package-missing state so the status is no longer the contradictory `configured:true` + `setup:'package-missing'`. - Tell users to sign in with the package-local CLI via `npx sentry auth login` run from the extension folder — the only form that resolves after a local `npm install`, since a package-local binary isn't on the shell PATH. - Update the README so the sign-in step and install guidance cover the published-plugin layout (`com.github.copilot/extensions/sentry-triage`), not just the standalone user/project extension paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add an exact-slug Sentry project resolver to the sentry-triage canvas The project picker previously only offered projects from the paged list the canvas had already loaded. Teams with many projects (or a project outside the first page) had no way to target one by slug. This adds a verify-on-commit resolver: when a user types a slug that isn't a local match, pressing Enter checks it against Sentry and only commits the canonical slug once verified, so a scan never runs against an unverified or wrong-org project. Canvas / UX (components/page.mjs, styles.mjs): - Autocomplete accepts an exact slug not in the local list; Enter is the explicit commit that triggers resolution (never an as-typed lookup). - A visually-hidden aria-live region announces checking / verified / not found / couldn't-check state, and the resolved state is rendered before commit so screen readers hear the outcome. - Footer/menu surfaces checking, prompt, missing, and error states, including when local partial matches are present. - Project choices are read from an org-keyed cache so a slug from a previously selected org can never be treated as local after a free-text org switch; the stale-completion guard also compares the org captured for the request. Server / resolution (server.mjs, sentry.mjs, sentryClient.mjs, extension.mjs): - CSRF-gated /api/resolve-project verifies a single slug against Sentry. - Resolution runs on the shared serial request chain and is hardened against Sentry outages and queue contention (transient errors are retryable, a confirmed miss is cached as "missing"). Also bumps sentry-triage to 1.1.0 (package.json, plugin.json, marketplace.json). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .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 + plugins/sentry-triage/plugin.json | 2 +- 9 files changed, 455 insertions(+), 84 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