Add an exact-slug Sentry project resolver to the sentry-triage canvas (#2819)

* 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>
This commit is contained in:
Liz Tom
2026-08-27 11:36:09 +10:00
committed by GitHub
co-authored by Copilot App
parent c72fb21484
commit 36f9fe7883
9 changed files with 455 additions and 84 deletions
+1 -1
View File
@@ -1349,7 +1349,7 @@
"name": "sentry-triage", "name": "sentry-triage",
"source": "plugins/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.", "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", "name": "signals-dashboard",
+304 -25
View File
@@ -392,6 +392,21 @@ export function Page({
// requestProjectsForOrg while seeding the org select. Seeded at init. // requestProjectsForOrg while seeding the org select. Seeded at init.
const projectsByOrg = {}; 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() { function submitScan() {
const input = document.getElementById("org-input"); const input = document.getElementById("org-input");
const projectInput = document.getElementById("project-input"); const projectInput = document.getElementById("project-input");
@@ -406,15 +421,29 @@ export function Page({
const project = projectInput ? projectInput.value.trim() : ""; const project = projectInput ? projectInput.value.trim() : "";
const repo = repoInput ? repoInput.value.trim() : ""; const repo = repoInput ? repoInput.value.trim() : "";
if (!org) { if (input) input.focus(); return; } if (!org) { if (input) input.focus(); return; }
// 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 (input) { input.value = org; input.disabled = true; }
if (projectInput) projectInput.disabled = true; if (projectInput) projectInput.disabled = true;
const scanBtn = document.getElementById("scan-btn"); const scanBtn = document.getElementById("scan-btn");
if (scanBtn) { scanBtn.disabled = true; scanBtn.textContent = "Scanning…"; } if (scanBtn) { scanBtn.disabled = true; scanBtn.textContent = "Scanning…"; }
showScanOverlay(project ? "Scanning " + project + "…" : "Scanning all projects…"); showScanOverlay(proj ? "Scanning " + proj + "…" : "Scanning all projects…");
fetch("/api/set-org", { fetch("/api/set-org", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ org, project, repo }) body: JSON.stringify({ org, project: proj, repo })
}).then((res) => { }).then((res) => {
if (!res.ok) throw new Error("set-org " + res.status); if (!res.ok) throw new Error("set-org " + res.status);
}).catch(() => { }).catch(() => {
@@ -430,6 +459,7 @@ export function Page({
syncScanButtonState(); syncScanButtonState();
window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again."); window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again.");
}); });
});
} }
const orgForm = document.getElementById("org-form"); const orgForm = document.getElementById("org-form");
@@ -1166,6 +1196,22 @@ export function Page({
return currentProject && !list.includes(currentProject) ? [currentProject, ...list] : list; 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 project input on screen — the header switcher in triage chrome, else
// the setup-screen field. // the setup-screen field.
function activeProjectInput() { function activeProjectInput() {
@@ -1223,13 +1269,95 @@ export function Page({
}).catch(() => setProjectLoading(false)); }).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 <select> is unwieldy for orgs with many projects, and a <datalist> // (A <select> is unwieldy for orgs with many projects, and a <datalist>
// does not repaint in this webview.) onCommit(value) runs when the user // does not repaint in this webview.) onCommit(value) runs when the user
// picks a suggestion; an empty value means "all projects". The project list // 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 // is read live on each open, so newly-discovered projects appear without a
// rebuild. Idempotent per input. // rebuild. Idempotent per input.
function attachProjectAutocomplete(input, onCommit) { function attachProjectAutocomplete(input, onCommit, onVerifiedCommit) {
if (!input || input.dataset.acWired) return; if (!input || input.dataset.acWired) return;
input.dataset.acWired = "1"; input.dataset.acWired = "1";
// Ensure the input sits in a positioned wrapper with a menu container. // 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. // readers announce the popup and can follow the active option.
if (!menu.id) menu.id = (input.id ? input.id : "project-ac") + "-listbox"; if (!menu.id) menu.id = (input.id ? input.id : "project-ac") + "-listbox";
input.setAttribute("aria-controls", menu.id); 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; const optionId = (i) => menu.id + "-opt-" + i;
function syncActiveDescendant() { function syncActiveDescendant() {
if (active >= 0 && items[active]) input.setAttribute("aria-activedescendant", optionId(active)); if (active >= 0 && items[active]) input.setAttribute("aria-activedescendant", optionId(active));
@@ -1262,7 +1406,8 @@ export function Page({
const CAP = 50; const CAP = 50;
function render() { function render() {
const q = input.value.trim().toLowerCase(); 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(); let matches = q ? source.filter((s) => s.toLowerCase().includes(q)) : source.slice();
const truncated = matches.length > CAP; const truncated = matches.length > CAP;
matches = matches.slice(0, 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. // Offer an explicit "all projects" reset only when the box is empty.
if (!q) items.push({ value: "", label: "All projects", all: true }); if (!q) items.push({ value: "", label: "All projects", all: true });
matches.forEach((s) => items.push({ value: s, label: s })); 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) { if (!items.length) {
menu.innerHTML = '<div class="project-ac-empty">No matching projects</div>'; 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 = '<div class="project-ac-empty">' + empty + '</div>';
syncActiveDescendant(); syncActiveDescendant();
return; return;
} }
@@ -1281,31 +1473,93 @@ export function Page({
.map((it, i) => .map((it, i) =>
'<div id="' + optionId(i) + '" class="project-ac-item' + (i === active ? ' active' : '') + '" role="option" aria-selected="' + (i === active) + '" data-idx="' + i + '">' + '<div id="' + optionId(i) + '" class="project-ac-item' + (i === active ? ' active' : '') + '" role="option" aria-selected="' + (i === active) + '" data-idx="' + i + '">' +
(it.all ? '<span class="project-ac-all">' + escapeHtml(it.label) + '</span>' : escapeHtml(it.label)) + (it.all ? '<span class="project-ac-all">' + escapeHtml(it.label) + '</span>' : escapeHtml(it.label)) +
(it.verified ? '<span class="project-ac-verified" title="Verified in Sentry">\u2713</span>' : '') +
'</div>') '</div>')
.join('') + .join('') +
(truncated ? '<div class="project-ac-more">Keep typing to narrow…</div>' : ''); (resolveHint === "checking" ? '<div class="project-ac-more">Checking Sentry…</div>'
: resolveHint === "prompt" ? '<div class="project-ac-more">Press Enter to check Sentry for \u201C' + escapeHtml(q) + '\u201D</div>'
: resolveHint === "error" ? "<div class='project-ac-more'>Couldn't check Sentry — press Enter to retry</div>"
: resolveHint === "missing" ? '<div class="project-ac-more">No project \u201C' + escapeHtml(q) + '\u201D in ' + escapeHtml(org) + '</div>'
: truncated ? '<div class="project-ac-more">Keep typing to narrow…</div>' : '');
syncActiveDescendant(); syncActiveDescendant();
} }
function open() { render(); menu.hidden = false; input.setAttribute("aria-expanded", "true"); } 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 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; input.value = value;
close(); 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("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) => { input.addEventListener("keydown", (e) => {
if (menu.hidden) { if (menu.hidden) {
if (e.key === "ArrowDown") { e.preventDefault(); open(); } if (e.key === "ArrowDown") { e.preventDefault(); open(); return; }
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(); } 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 === "ArrowUp") { e.preventDefault(); active = Math.max(active - 1, 0); render(); }
else if (e.key === "Enter") { else if (e.key === "Enter") {
// Only intercept Enter when a suggestion is highlighted; otherwise let // A highlighted suggestion commits directly.
// it bubble to the header handler that applies a typed value. if (active >= 0 && items[active]) { e.preventDefault(); e.stopPropagation(); commit(items[active].value, { verified: !!items[active].verified }); return; }
if (active >= 0 && items[active]) { e.preventDefault(); e.stopPropagation(); commit(items[active].value); } // 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(); } } else if (e.key === "Escape") { e.stopPropagation(); close(); }
}); });
// mousedown (not click) so the selection beats the input's blur. // mousedown (not click) so the selection beats the input's blur.
@@ -1314,18 +1568,21 @@ export function Page({
if (!el) return; if (!el) return;
e.preventDefault(); e.preventDefault();
const idx = Number(el.dataset.idx); 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); }); input.addEventListener("blur", () => { setTimeout(close, 120); });
} }
// Ensure the setup-screen project field behaves as an autocomplete. The // 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 // 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 // the org's projects arrive. Picking a local suggestion just fills the box
// button reads #project-input on submit. // (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() { function renderSetupProjectField() {
const el = document.getElementById("project-input"); 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 // Ensure the project control exists in the header and reflects the current
@@ -1383,7 +1640,13 @@ export function Page({
label.appendChild(fetchBtn); label.appendChild(fetchBtn);
scope.insertBefore(label, scope.firstChild); 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()); attachProjectAutocomplete(input, () => fetchScoped());
// Org control, injected before the project switcher so the header reads // Org control, injected before the project switcher so the header reads
@@ -1506,7 +1769,22 @@ export function Page({
const org = orgInput ? orgInput.value.trim() : currentOrg; const org = orgInput ? orgInput.value.trim() : currentOrg;
const project = projectInput ? projectInput.value.trim() : ""; const project = projectInput ? projectInput.value.trim() : "";
if (!org) { if (orgInput) orgInput.focus(); return; } if (!org) { if (orgInput) orgInput.focus(); return; }
if (org !== currentOrg || project !== currentProject) resetPerCardState(); // 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 // Snapshot the last-scanned scope BEFORE the optimistic update so a failed
// rescan can roll back. Without this, a network/server failure leaves the // rescan can roll back. Without this, a network/server failure leaves the
// switchers showing the new org/project while the still-rendered cards // switchers showing the new org/project while the still-rendered cards
@@ -1516,14 +1794,14 @@ export function Page({
const subtitle = document.querySelector(".page-subtitle"); const subtitle = document.querySelector(".page-subtitle");
const prevSubtitle = subtitle ? subtitle.textContent : ""; const prevSubtitle = subtitle ? subtitle.textContent : "";
currentOrg = org; currentOrg = org;
currentProject = project; currentProject = proj;
updateTriageDesc(); updateTriageDesc();
if (subtitle) subtitle.textContent = "Scanning " + (project || "all projects") + "..."; if (subtitle) subtitle.textContent = "Scanning " + (proj || "all projects") + "...";
showScanOverlay("Scanning " + (project || "all projects") + "…"); showScanOverlay("Scanning " + (proj || "all projects") + "…");
fetch("/api/set-org", { fetch("/api/set-org", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ org, project }) body: JSON.stringify({ org, project: proj })
}).then((res) => { }).then((res) => {
if (!res.ok) throw new Error("set-org " + res.status); if (!res.ok) throw new Error("set-org " + res.status);
}).catch(() => { }).catch(() => {
@@ -1541,6 +1819,7 @@ export function Page({
if (subtitle) subtitle.textContent = prevSubtitle; if (subtitle) subtitle.textContent = prevSubtitle;
window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again."); window.alert("Couldn't start the scan — the triage server may have stopped responding. Please try again.");
}); });
});
} }
// Enter in the org or project input triggers the same fetch as clicking // Enter in the org or project input triggers the same fetch as clicking
+23 -1
View File
@@ -2,7 +2,7 @@ import { joinSession, createCanvas } from '@github/copilot-sdk/extension'
import { execSync } from 'node:child_process' import { execSync } from 'node:child_process'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { startServer } from './server.mjs' 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 { checkConnections, checkConnectionsOnce } from './preflight.mjs'
import { sanitizeForPrompt } from './escape.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) { async function runConnectionCheck(entry, isCurrent) {
try { try {
const connections = await checkConnections() const connections = await checkConnections()
@@ -1962,6 +1983,7 @@ const session = await joinSession({
onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot), onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot),
onRecheck: () => onRecheckConnections(entry), onRecheck: () => onRecheckConnections(entry),
onListProjects: (org) => discoverProjects(entry, org, { force: true }), onListProjects: (org) => discoverProjects(entry, org, { force: true }),
onResolveProject: (org, slug) => resolveProject(entry, org, slug),
onInvalidateEnrichment: () => { onInvalidateEnrichment: () => {
// Bump the scan generation so any enrichment turn still in flight // Bump the scan generation so any enrichment turn still in flight
// from the previous repo fails its isCurrent() guard and applies // from the previous repo fails its isCurrent() guard and applies
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sentry-triage", "name": "sentry-triage",
"version": "1.0.0", "version": "1.1.0",
"main": "extension.mjs", "main": "extension.mjs",
"author": "Liz Tom", "author": "Liz Tom",
"license": "MIT", "license": "MIT",
+20 -3
View File
@@ -284,8 +284,13 @@ async function listProjectsPaged(org, onPage) {
// Verify a specific project slug exists / is accessible. The org's project list // 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 // 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 // slug directly. Returns the canonical slug on success, or '' ONLY when the
// accessible. // 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) { export async function findProject(org, slug) {
const wanted = String(slug || '').trim() const wanted = String(slug || '').trim()
if (!wanted) return '' if (!wanted) return ''
@@ -294,11 +299,23 @@ export async function findProject(org, slug) {
const resolved = String(project?.slug || '').trim() const resolved = String(project?.slug || '').trim()
return resolved || wanted return resolved || wanted
} catch (err) { } catch (err) {
if (err instanceof SentryError) return '' if (err instanceof SentryError && isProjectNotFound(err)) return ''
throw err 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 // 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 // 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 // regression/escalation searches all pass an explicit bounded cap so an org with
+12 -2
View File
@@ -154,8 +154,18 @@ export async function projectListRaw(org, limit = 100, cursor) {
return asArray(await (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) })) return asArray(await (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
} }
// Verify a specific project exists / is accessible. Returns the raw project // Verify a specific project exists / is accessible via the SDK's O(1)
// object on success; throws SentryError when the slug is unknown or forbidden. // `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) { export async function projectView(org, slug) {
return runSerial(async () => (await getSdk()).project.view({ orgProject: `${org}/${slug}` })) return runSerial(async () => (await getSdk()).project.view({ orgProject: `${org}/${slug}` }))
} }
+35 -1
View File
@@ -77,7 +77,7 @@ function hasValidToken(req, token) {
return value === 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. // Per-instance state + SSE clients — never shared across canvas instances.
const state = createState() const state = createState()
state.applyRepoDefaults(defaults) state.applyRepoDefaults(defaults)
@@ -241,6 +241,40 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
return 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 // Set org endpoint
if (req.method === 'POST' && req.url === '/api/set-org') { if (req.method === 'POST' && req.url === '/api/set-org') {
readBody(req, res).then((body) => { readBody(req, res).then((body) => {
+9
View File
@@ -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 .project-ac-all { color: var(--text-color-muted, #8b949e); font-style: italic; }
.project-ac-item.active .project-ac-all, .project-ac-item.active .project-ac-all,
.project-ac-item:hover .project-ac-all { color: var(--color-fg-on-emphasis, #ffffff); } .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-empty,
.project-ac-more { .project-ac-more {
padding: 6px 8px; padding: 6px 8px;
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "sentry-triage", "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.", "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": { "author": {
"name": "Liz Tom", "name": "Liz Tom",
"url": "https://github.com/liztom" "url": "https://github.com/liztom"