mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-29 12:03:09 +00:00
Add one-click install/sign-in to sentry-triage setup gate (#2831)
The sentry-triage canvas's setup gate previously told users to ask
Copilot to install its optional `sentry` dependency and reload
extensions, and to run `sentry auth login` from a terminal. Both
flows were unreliable or high-friction in practice.
This adds real one-click buttons for both steps:
- sentryClient.mjs — installPackage() runs npm install (async, via
execFile so the shared extension process's event loop isn't
blocked) rooted at the file's own directory via import.meta.url, so
the path is never guessed. loadFactory() falls back to importing
the package's resolved entry file by absolute path when the bare
import('sentry') fails, since Node caches a negative resolution for
a bare specifier for the life of the process — while still
distinguishing a genuinely missing package from a transitive-
dependency defect so the latter isn't masked as "package missing."
login() fails fast with a clear message when SENTRY_AUTH_TOKEN/
SENTRY_TOKEN is active in the environment (which takes precedence
over the OAuth login this button drives), and treats an empty/
falsy auth.login() result as a failed sign-in instead of silently
re-probing.
- preflight.mjs — installDependencies() runs the install and
re-probes the connection; authenticate() classifies the new error
codes for gate messaging.
- server.mjs / extension.mjs — wire POST /api/install-dependencies
and POST /api/auth-login routes (through the existing CSRF/Host
gate) to the new preflight functions, publishing refreshed
connection state to the canvas.
- components/page.mjs / styles.mjs — the install and sign-in
buttons, with loading/success/failure states and accessible live-
region status updates; a live multi-org <select> dropdown that
rebuilds in place once org discovery completes post-signin,
without requiring a canvas reopen.
- README.md — documents the one-click flow; manual npm install /
sentry auth login kept as a fallback.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -179,11 +179,15 @@ export function Page({
|
||||
<h2 id="gate-title">${packageMissing ? 'Set up the Sentry CLI' : (signedOut ? 'Connect Sentry to start triaging' : 'Can’t reach Sentry right now')}</h2>
|
||||
<div class="gate-body gate-body-setup" id="gate-body-setup"${packageMissing ? '' : ' style="display:none;"'}>
|
||||
<p class="gate-lead">This canvas reads your live Sentry issues through the Sentry CLI, but its <code>sentry</code> package isn’t installed for this extension yet.</p>
|
||||
<p class="gate-steps">Ask Copilot to “install the sentry-triage dependencies and reload extensions,” then run <code>npx sentry auth login</code> from the extension folder to sign in and re-open this canvas.</p>
|
||||
<p class="gate-steps">Click below to install it. You'll then be prompted to sign in with Sentry.</p>
|
||||
<button id="install-deps-btn" class="gate-action-btn" type="button">📦 Install dependencies</button>
|
||||
<p class="gate-install-status" id="gate-install-status" role="status" aria-live="polite" style="display:none;"></p>
|
||||
</div>
|
||||
<div class="gate-body gate-body-auth" id="gate-body-auth"${signedOut && !packageMissing ? '' : ' style="display:none;"'}>
|
||||
<p class="gate-lead">This canvas reads your live Sentry issues through the Sentry CLI, but it isn't signed in yet.</p>
|
||||
<p class="gate-steps">Run <code>npx sentry auth login</code> from the extension folder to sign in, then re-open this canvas.</p>
|
||||
<p class="gate-steps">Sign in with Sentry — this opens your browser to approve access, then returns here automatically.</p>
|
||||
<button id="auth-login-btn" class="gate-action-btn" type="button">🔑 Sign in with Sentry</button>
|
||||
<p class="gate-install-status" id="gate-auth-status" role="status" aria-live="polite" style="display:none;"></p>
|
||||
</div>
|
||||
<div class="gate-body gate-body-conn" id="gate-body-conn"${signedOut || !transientConn || packageMissing ? ' style="display:none;"' : ''}>
|
||||
<p class="gate-lead">You’re signed in, but this canvas couldn’t reach Sentry — usually a temporary network blip.</p>
|
||||
@@ -193,7 +197,7 @@ export function Page({
|
||||
<p class="gate-lead">You’re signed in, but this canvas couldn’t reach Sentry.</p>
|
||||
<p class="gate-steps">See the details below, then re-open this canvas to try again.</p>
|
||||
</div>
|
||||
<p class="gate-error" id="gate-error" ${sentryReady || !gateError || packageMissing ? 'style="display:none;"' : ''}>${escapeHtml(gateError || '')}</p>
|
||||
<p class="gate-error" id="gate-error" ${sentryReady || !gateError || packageMissing || signedOut ? 'style="display:none;"' : ''}>${escapeHtml(gateError || '')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -489,27 +493,77 @@ export function Page({
|
||||
syncScanButtonState();
|
||||
|
||||
const orgSelectEl = document.getElementById("org-select");
|
||||
if (orgSelectEl) {
|
||||
const orgSelectMirror = orgSelectEl ? wireOrgSelect(orgSelectEl) : null;
|
||||
|
||||
// (Re)wire an org <select>'s change handler and, when it's freshly
|
||||
// created (post-signin discovery — see the orgOptions SSE handler
|
||||
// below), seed the input/state from its current value the same way the
|
||||
// initial server-render path does just below. Pulled out to a function
|
||||
// so both paths share one implementation instead of drifting.
|
||||
function wireOrgSelect(sel) {
|
||||
const orgInputEl = document.getElementById("org-input");
|
||||
const mirrorOrgSelect = () => {
|
||||
if (orgInputEl) orgInputEl.value = orgSelectEl.value;
|
||||
const mirrorOrgSelect = (clearProject = true) => {
|
||||
if (orgInputEl) orgInputEl.value = sel.value;
|
||||
// Clear any project slug carried over from the previously-selected org —
|
||||
// it won't exist under the new org's project list.
|
||||
const projInput = document.getElementById("project-input");
|
||||
if (projInput) projInput.value = "";
|
||||
if (clearProject && projInput) projInput.value = "";
|
||||
syncScanButtonState();
|
||||
// Load the new org's projects into the autocomplete: instant from cache
|
||||
// when we've seen it before, otherwise a "loading projects…" hint until
|
||||
// the fetched list arrives over SSE.
|
||||
requestProjectsForOrg(orgSelectEl.value);
|
||||
requestProjectsForOrg(sel.value);
|
||||
};
|
||||
orgSelectEl.addEventListener("change", mirrorOrgSelect);
|
||||
sel.addEventListener("change", () => mirrorOrgSelect());
|
||||
return mirrorOrgSelect;
|
||||
}
|
||||
if (orgSelectEl) {
|
||||
const orgInputEl = document.getElementById("org-input");
|
||||
// The <select> shows its first option as selected by default, but the
|
||||
// input starts empty when there's no detected default — so Scan looks
|
||||
// disabled and re-picking the already-shown org fires no change event.
|
||||
// Seed the input from the select's current value on load so the visible
|
||||
// selection is the effective one and Scan is enabled.
|
||||
if (orgInputEl && !orgInputEl.value.trim() && orgSelectEl.value) mirrorOrgSelect();
|
||||
if (orgInputEl && !orgInputEl.value.trim() && orgSelectEl.value) orgSelectMirror(false);
|
||||
}
|
||||
|
||||
// Rebuild the setup screen's org control as a <select> once 2+ orgs are
|
||||
// known (whether at initial render, or discovered later via SSE after a
|
||||
// post-load sign-in — see the orgOptions handler below). A single-org
|
||||
// account keeps the plain text input. No-ops if a <select> already
|
||||
// reflects the same options, so it's safe to call on every SSE update.
|
||||
function renderSetupOrgSelect() {
|
||||
const orgField = document.getElementById("org-input");
|
||||
if (!orgField) return; // already a <select>, or the setup screen isn't showing
|
||||
const orgSlugs = Array.isArray(currentOrgOptions) ? currentOrgOptions.filter(Boolean) : [];
|
||||
if (orgSlugs.length < 2) return;
|
||||
const current = orgField.value.trim();
|
||||
// Don't clobber an in-progress edit: if the user has typed something
|
||||
// that isn't (yet) one of the discovered orgs, replaceWith() below
|
||||
// would silently discard it and default to the first option. Defer
|
||||
// the rebuild — it'll retry on the next SSE update, and by then the
|
||||
// user will likely have finished typing or the org will be known.
|
||||
if (current && !orgSlugs.includes(current)) return;
|
||||
const sel = document.createElement("select");
|
||||
sel.id = "org-select";
|
||||
sel.className = "org-input org-select";
|
||||
sel.title = "Sentry organizations you can access";
|
||||
orgSlugs.forEach((slug) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = slug;
|
||||
opt.textContent = slug;
|
||||
if (slug === current) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
const wasFocused = document.activeElement === orgField;
|
||||
orgField.replaceWith(sel);
|
||||
const mirror = wireOrgSelect(sel);
|
||||
mirror(false);
|
||||
// orgField may have had keyboard focus (e.g. the empty org input's
|
||||
// autofocus on first load) — replaceWith() detaches it from the
|
||||
// document without moving focus anywhere, silently dropping the
|
||||
// keyboard user's position. Move focus onto its replacement.
|
||||
if (wasFocused) sel.focus();
|
||||
}
|
||||
|
||||
// The org slug currently chosen on the setup screen (typed input wins, else
|
||||
@@ -586,12 +640,14 @@ export function Page({
|
||||
// detected default into the still-empty slug input so the user doesn't have
|
||||
// to type it. Never clobber a value the user has started editing.
|
||||
function applyOrgDefault(def) {
|
||||
if (!def) return;
|
||||
if (!def) return false;
|
||||
const input = document.getElementById("org-input");
|
||||
if (input && !input.value) {
|
||||
input.value = def;
|
||||
syncScanButtonState();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const source = new EventSource("/api/events");
|
||||
@@ -635,6 +691,12 @@ export function Page({
|
||||
// project autocomplete; empty = fall back to a typed slug box.
|
||||
let currentSentryProjects = ${jsonForScript(projectSlugs)};
|
||||
let currentOrgOptions = ${jsonForScript(orgSlugs)};
|
||||
// Dedupe guard for the setup screen's auto project-fetch-on-org-discovery
|
||||
// (see the orgDefault SSE handler below): discoverProjects's own
|
||||
// notifyClients() re-broadcasts the same orgDefault on every streamed
|
||||
// project page, so without this a single org discovery would recursively
|
||||
// re-request its own project list forever instead of settling once.
|
||||
let lastAutoFetchedOrgDefault = "";
|
||||
let currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])};
|
||||
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
|
||||
let currentScanError = ${jsonForScript(scanError || '')};
|
||||
@@ -993,7 +1055,25 @@ export function Page({
|
||||
enterTriageChrome(msg.org);
|
||||
updateToolbarVisibility(hasRenderableIssues());
|
||||
} else if (typeof msg.orgDefault === "string") {
|
||||
applyOrgDefault(msg.orgDefault);
|
||||
// Org discovery can complete AFTER the setup screen already rendered
|
||||
// (e.g. the user signs in post-load) — there's no #org-select to bind a
|
||||
// change handler to yet, so nothing else would kick off a project fetch
|
||||
// for this org. Only fire when this call actually just populated the
|
||||
// (previously empty) org field, and dedupe per-org: discoverProjects's
|
||||
// own notifyClients() re-broadcasts this same orgDefault on every
|
||||
// streamed page, so firing unconditionally here would recursively
|
||||
// re-request the project list on every page and never settle.
|
||||
// Also skip it when this same snapshot is about to render a
|
||||
// multi-org <select> below (msg.orgOptions has 2+ entries) — that
|
||||
// selector's own mirror() already requests projects for the
|
||||
// selected org, so firing here too would kick off a duplicate,
|
||||
// serialized (and possibly slow, up to the paging budget) traversal.
|
||||
const willRenderOrgSelect = Array.isArray(msg.orgOptions) && msg.orgOptions.filter(Boolean).length >= 2;
|
||||
const justFilled = applyOrgDefault(msg.orgDefault);
|
||||
if (justFilled && !willRenderOrgSelect && !document.getElementById("org-select") && lastAutoFetchedOrgDefault !== msg.orgDefault) {
|
||||
lastAutoFetchedOrgDefault = msg.orgDefault;
|
||||
requestProjectsForOrg(msg.orgDefault);
|
||||
}
|
||||
}
|
||||
if ("project" in msg) {
|
||||
const nextProject = msg.project || "";
|
||||
@@ -1036,7 +1116,15 @@ export function Page({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(msg.orgOptions)) currentOrgOptions = msg.orgOptions;
|
||||
if (Array.isArray(msg.orgOptions)) {
|
||||
currentOrgOptions = msg.orgOptions;
|
||||
// Post-load org discovery (e.g. signing in without reloading) can
|
||||
// reveal 2+ orgs after the setup screen already rendered a plain
|
||||
// text input. Rebuild it as a <select> now so the multi-org flow
|
||||
// works without a reload. No-ops once already a <select>, and
|
||||
// no-ops off the setup screen (no #org-input present there).
|
||||
renderSetupOrgSelect();
|
||||
}
|
||||
if (typeof msg.savedDefaultOrg === "string" && msg.savedDefaultOrg !== currentSavedDefaultOrg) {
|
||||
currentSavedDefaultOrg = msg.savedDefaultOrg;
|
||||
refreshDefaultBtn();
|
||||
@@ -1124,7 +1212,7 @@ export function Page({
|
||||
const gateErrEl = document.getElementById("gate-error");
|
||||
if (gateErrEl) {
|
||||
const err = (c.sentry && c.sentry.error) || "";
|
||||
if (gatedNow && err && !packageMissing) {
|
||||
if (gatedNow && err && !packageMissing && !signedOut) {
|
||||
gateErrEl.textContent = err;
|
||||
gateErrEl.style.display = "";
|
||||
} else {
|
||||
@@ -1141,6 +1229,20 @@ export function Page({
|
||||
wasGatedPrev = gatedNow;
|
||||
}
|
||||
|
||||
function focusConnectionGateLead(sentryConn) {
|
||||
const panelId = sentryConn && sentryConn.transient ? "gate-body-conn" : "gate-body-unknown";
|
||||
const connLead = document.querySelector("#" + panelId + " .gate-lead");
|
||||
if (connLead) { connLead.setAttribute("tabindex", "-1"); connLead.focus(); }
|
||||
}
|
||||
|
||||
function focusActiveOrgControl() {
|
||||
const orgInput = document.getElementById("org-input");
|
||||
const orgSelect = document.getElementById("org-select");
|
||||
const orgSwitcher = document.getElementById("org-switcher");
|
||||
const focusTarget = orgInput || orgSelect || orgSwitcher;
|
||||
if (focusTarget) focusTarget.focus();
|
||||
}
|
||||
|
||||
function enterTriageChrome(org) {
|
||||
const picker = document.querySelector(".org-picker");
|
||||
if (picker) picker.style.display = "none";
|
||||
@@ -1887,6 +1989,123 @@ export function Page({
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const installBtn = e.target.closest("#install-deps-btn");
|
||||
if (installBtn) {
|
||||
const statusEl = document.getElementById("gate-install-status");
|
||||
installBtn.disabled = true;
|
||||
installBtn.textContent = "Installing…";
|
||||
if (statusEl) {
|
||||
statusEl.style.display = "";
|
||||
statusEl.textContent = "Running npm install — this can take a moment…";
|
||||
}
|
||||
fetch("/api/install-dependencies", { method: "POST" })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("install-dependencies " + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data && data.connections) {
|
||||
currentConnections = data.connections;
|
||||
applyConnectionState();
|
||||
}
|
||||
const stillMissing = data && data.connections && data.connections.sentry && data.connections.sentry.setup === "package-missing";
|
||||
if (stillMissing) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "Install didn't finish — check that npm is available, then try again.";
|
||||
}
|
||||
installBtn.disabled = false;
|
||||
installBtn.textContent = "📦 Install dependencies";
|
||||
} else if (statusEl) {
|
||||
// applyConnectionState() above already repainted the gate for
|
||||
// the fresh connection state — a successful install doesn't
|
||||
// always land on the sign-in panel: an existing stored
|
||||
// credential can make it immediately reachable (clearing the
|
||||
// gate entirely), or the re-probe can hit a transient network
|
||||
// blip (landing on the connectivity panel) instead. Announce
|
||||
// and focus based on what's actually visible now rather than
|
||||
// assuming sign-in is next.
|
||||
const sentryConn = (data.connections && data.connections.sentry) || {};
|
||||
if (sentryConn.reachable) {
|
||||
showToast("✅ Sentry connected — loading your issues");
|
||||
focusActiveOrgControl();
|
||||
} else if (sentryConn.configured) {
|
||||
// configured (has/had a credential) but not reachable and
|
||||
// not package-missing: connectivity panel is now showing.
|
||||
statusEl.textContent = "Installed, but couldn't reach Sentry just now — see details below.";
|
||||
focusConnectionGateLead(sentryConn);
|
||||
} else {
|
||||
statusEl.textContent = "Installed. Click below to sign in with Sentry.";
|
||||
// The install panel (and this now-hidden live region) was
|
||||
// just swapped for the sign-in panel, so focus is left on
|
||||
// the now-hidden, disabled install button — silent for a
|
||||
// screen reader user. Move focus to the sign-in button so
|
||||
// they're notified of the next actionable step.
|
||||
const nextBtn = document.getElementById("auth-login-btn");
|
||||
if (nextBtn) nextBtn.focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (statusEl) statusEl.textContent = "Install failed — the extension server may be unreachable. Please try again.";
|
||||
installBtn.disabled = false;
|
||||
installBtn.textContent = "📦 Install dependencies";
|
||||
});
|
||||
return;
|
||||
}
|
||||
const authBtn = e.target.closest("#auth-login-btn");
|
||||
if (authBtn) {
|
||||
const statusEl = document.getElementById("gate-auth-status");
|
||||
authBtn.disabled = true;
|
||||
authBtn.textContent = "Waiting for sign-in…";
|
||||
if (statusEl) {
|
||||
statusEl.style.display = "";
|
||||
statusEl.textContent = "Opening your browser to sign in with Sentry — approve access there, then this will continue automatically.";
|
||||
}
|
||||
fetch("/api/auth-login", { method: "POST" })
|
||||
.then((res) => res.json().then((data) => ({ res, data })))
|
||||
.then(({ res, data }) => {
|
||||
if (data && data.connections) {
|
||||
currentConnections = data.connections;
|
||||
applyConnectionState();
|
||||
}
|
||||
if (!res.ok || (data && data.ok === false)) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = (data && data.error) || "Sign-in didn't complete — please try again.";
|
||||
}
|
||||
authBtn.disabled = false;
|
||||
authBtn.textContent = "🔑 Sign in with Sentry";
|
||||
return;
|
||||
}
|
||||
const stillSignedOut = data && data.connections && data.connections.sentry && !data.connections.sentry.configured;
|
||||
if (stillSignedOut) {
|
||||
if (statusEl) statusEl.textContent = "Still not signed in — please try again.";
|
||||
authBtn.disabled = false;
|
||||
authBtn.textContent = "🔑 Sign in with Sentry";
|
||||
} else {
|
||||
// applyConnectionState() above just hid the entire auth gate
|
||||
// (signed in now) — this live region's own node may no longer
|
||||
// be in the accessibility tree, and focus is left on the
|
||||
// now-hidden, disabled sign-in button. Announce via the
|
||||
// always-visible toast instead, and move focus into whatever
|
||||
// view is now actually showing (reachable: the org picker;
|
||||
// otherwise: the connectivity gate that's left, if any).
|
||||
const sentryConn = (data.connections && data.connections.sentry) || {};
|
||||
if (sentryConn.reachable) {
|
||||
showToast("✅ Signed in — loading your issues");
|
||||
focusActiveOrgControl();
|
||||
} else {
|
||||
showToast("✅ Signed in — checking Sentry connectivity");
|
||||
focusConnectionGateLead(sentryConn);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (statusEl) statusEl.textContent = "Sign-in failed — the extension server may be unreachable. Please try again.";
|
||||
authBtn.disabled = false;
|
||||
authBtn.textContent = "🔑 Sign in with Sentry";
|
||||
});
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest("#refresh, #rescan");
|
||||
if (!btn) return;
|
||||
const subtitle = document.querySelector(".page-subtitle");
|
||||
|
||||
Reference in New Issue
Block a user