mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-27 19:15:15 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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 <select> is unwieldy for orgs with many projects, and a <datalist>
|
||||
// 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 = '<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();
|
||||
return;
|
||||
}
|
||||
@@ -1281,31 +1473,93 @@ export function Page({
|
||||
.map((it, 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.verified ? '<span class="project-ac-verified" title="Verified in Sentry">\u2713</span>' : '') +
|
||||
'</div>')
|
||||
.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();
|
||||
}
|
||||
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.");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sentry-triage",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"main": "extension.mjs",
|
||||
"author": "Liz Tom",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` }))
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user