diff --git a/extensions/sentry-triage/README.md b/extensions/sentry-triage/README.md index 73f9ec86..020dd168 100644 --- a/extensions/sentry-triage/README.md +++ b/extensions/sentry-triage/README.md @@ -40,17 +40,11 @@ that drafts a fix pull request. - **Node.js 22 or newer.** - The GitHub Copilot app canvas / UI-extensions experiment enabled. -- A Sentry sign-in. This canvas reads issues through the +- **A Sentry sign-in.** This canvas reads issues through the [Sentry CLI](https://cli.sentry.dev) in library mode — there is no MCP server - to configure. After installing the dependency (see **Install** below), sign in - once with the package-local CLI, run from the extension folder: - - ```sh - npx sentry auth login - ``` - - This stores an OAuth credential the canvas auto-detects. For non-interactive - environments, export a token instead: + to configure. The setup gate walks you through installing the dependency and + signing in with one-click buttons (see **Install** below); no terminal + required. For non-interactive environments, you can instead export a token: ```sh export SENTRY_AUTH_TOKEN= @@ -67,30 +61,17 @@ repository at `.github/extensions/sentry-triage/` for project scope. This canvas depends on the [`sentry`](https://cli.sentry.dev) npm package at runtime, which isn't bundled with the extension source. If it's missing, the canvas -still **opens** and shows a setup gate explaining what to do instead of crashing. +still **opens** and shows a setup gate — click its **Install dependencies** button +and the extension runs `npm install` in its own directory (wherever it's actually +installed, so there's no path to guess). Once installed, if you aren't signed in +yet the gate shows a **Sign in with Sentry** button that opens your browser to +approve access and returns automatically — no terminal step required. The gate +clears once both finish; no reload or Copilot involvement needed. -### Let Copilot set it up (recommended) +### Or set it up manually -Because the canvas runs inside GitHub Copilot, the agent can install the dependency -for you. Paste this into Copilot: - -> Locate the loaded `sentry-triage` canvas extension folder — the directory that -> contains its `package.json` — run `npm install` there, then reload extensions. - -(That folder is `~/.copilot/extensions/sentry-triage/` for user scope, -`.github/extensions/sentry-triage/` for project scope, or, if you installed the -published plugin, `com.github.copilot/extensions/sentry-triage` inside the -installed plugin.) - -Then finish the one interactive step yourself — sign in so Copilot never handles a -raw secret. Run this from the same extension folder (`npx` resolves the CLI the -local `npm install` just placed in `node_modules`): - -```sh -npx sentry auth login -``` - -### Or install it manually +If you'd rather not use the buttons (or `npm`/a browser isn't available to the +extension process), you can run the same steps yourself: ```sh # User scope diff --git a/extensions/sentry-triage/components/page.mjs b/extensions/sentry-triage/components/page.mjs index b3fe8895..46460a66 100644 --- a/extensions/sentry-triage/components/page.mjs +++ b/extensions/sentry-triage/components/page.mjs @@ -179,11 +179,15 @@ export function Page({

${packageMissing ? 'Set up the Sentry CLI' : (signedOut ? 'Connect Sentry to start triaging' : 'Can’t reach Sentry right now')}

-

${escapeHtml(gateError || '')}

+

${escapeHtml(gateError || '')}

@@ -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 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 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 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 , 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"); diff --git a/extensions/sentry-triage/extension.mjs b/extensions/sentry-triage/extension.mjs index a0f9cba4..a6553594 100644 --- a/extensions/sentry-triage/extension.mjs +++ b/extensions/sentry-triage/extension.mjs @@ -3,7 +3,7 @@ import { execSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { startServer } from './server.mjs' import { scanIssues, listOrgs, listProjects, findProject } from './sentry.mjs' -import { checkConnections, checkConnectionsOnce } from './preflight.mjs' +import { checkConnections, checkConnectionsOnce, installDependencies, authenticate } from './preflight.mjs' import { sanitizeForPrompt } from './escape.mjs' // A Sentry-derived URL is safe to pass through only if it PARSES as a real @@ -1309,6 +1309,55 @@ async function onRecheckConnections(entry) { return connections } +// "Install dependencies" button handler on the package-missing setup gate. +// Delegates to preflight's installDependencies (npm install + re-probe, rooted +// at the extension's own directory) and publishes the fresh connection state so +// the gate updates live. On success (Sentry now reachable and an org already +// committed) kicks a scan, mirroring onRecheckConnections's post-recovery path. +async function onInstallDependencies(entry) { + if (entry.closed) return entry.state.getConnections?.() || { sentry: { reachable: false } } + const connections = await installDependencies() + if (entry.closed) return connections + entry.state.setConnections(connections) + entry.notifyClients() + if (connections.sentry.reachable && entry.state.getOrg()) { + triageSentry(entry).catch((err) => { + console.error('[sentry-triage] post-install scan failed:', err instanceof Error ? err.message : err) + }) + } else if (connections.sentry.reachable) { + discoverOrgs(entry).catch((err) => { + console.error('[sentry-triage] post-install org discovery failed:', err instanceof Error ? err.message : err) + }) + } + return connections +} + +// "Sign in with Sentry" button handler on the not-authenticated setup gate +// (shown only once the package is installed — see components/page.mjs). Runs +// the SDK's OAuth device-code login (opens the user's browser directly, no +// terminal) and publishes the fresh connection state so the gate updates +// live, mirroring onInstallDependencies's post-recovery path. Unlike install, +// a failed/cancelled login is allowed to propagate so the server route can +// report the specific reason instead of a generic "still signed out". +async function onAuthenticate(entry) { + if (entry.closed) return entry.state.getConnections?.() || { sentry: { reachable: false } } + const connections = await authenticate() + if (entry.closed) return connections + entry.state.setConnections(connections) + entry.notifyClients() + if (connections.sentry.reachable && entry.state.getOrg()) { + triageSentry(entry).catch((err) => { + console.error('[sentry-triage] post-auth scan failed:', err instanceof Error ? err.message : err) + }) + } else if (connections.sentry.reachable) { + discoverOrgs(entry).catch((err) => { + console.error('[sentry-triage] post-auth org discovery failed:', err instanceof Error ? err.message : err) + }) + } + return connections +} + + async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) { if (entry.closed) return const uniqueKeys = [...new Set(issueKeys)].filter(Boolean) @@ -1982,6 +2031,8 @@ const session = await joinSession({ onRefresh: () => refreshAll(entry), onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot), onRecheck: () => onRecheckConnections(entry), + onInstallDependencies: () => onInstallDependencies(entry), + onAuthenticate: () => onAuthenticate(entry), onListProjects: (org) => discoverProjects(entry, org, { force: true }), onResolveProject: (org, slug) => resolveProject(entry, org, slug), onInvalidateEnrichment: () => { diff --git a/extensions/sentry-triage/preflight.mjs b/extensions/sentry-triage/preflight.mjs index a563f300..0d683935 100644 --- a/extensions/sentry-triage/preflight.mjs +++ b/extensions/sentry-triage/preflight.mjs @@ -15,7 +15,7 @@ // buildConnections() is pure so it can be unit-tested offline; checkConnections() // is the thin wrapper that talks to Sentry. -import { whoami, SentryError } from './sentryClient.mjs' +import { whoami, installPackage, login, SentryError } from './sentryClient.mjs' // Shape the raw signals into the connection status the UI consumes. Pure. export function buildConnections({ @@ -60,6 +60,12 @@ const msg = (err) => (err instanceof Error ? err.message : String(err)) // network blip and not an auth failure, so it gets its own gate branch. const isPackageMissing = (err) => Boolean(err) && err.code === 'SENTRY_PACKAGE_MISSING' +// An active SENTRY_AUTH_TOKEN/SENTRY_TOKEN in the environment takes precedence +// over any OAuth login, so the "Sign in" button can't do anything useful while +// one is set — sentryClient's login() detects this up front and fails fast +// with this code instead of running an OAuth flow that can't take effect. +const isEnvTokenActive = (err) => Boolean(err) && err.code === 'SENTRY_ENV_TOKEN_ACTIVE' + // Text used for classification: the message plus any CLI stderr, since a rejected // credential (HTTP 401/403) often surfaces its status in stderr rather than the // Error message. @@ -95,11 +101,14 @@ const humanizeSentryError = (err) => { if (isPackageMissing(err)) { return 'The Sentry CLI isn’t installed for this canvas yet. Ask Copilot to “install the sentry-triage dependencies and reload extensions,” then run `npx sentry auth login` from the extension folder and re-open this canvas.' } + if (isEnvTokenActive(err)) { + return t + } if (isNotAuthenticated(err)) { - return 'Sentry isn’t connected yet. Run `npx sentry auth login` from the extension folder, then re-open this canvas.' + return 'Sentry isn’t connected yet.' } if (isAuthFailure(err)) { - return 'Sentry rejected your credential (expired or invalid). Run `npx sentry auth login` from the extension folder, then re-open this canvas.' + return 'Sentry rejected your credential (expired or invalid). Sign in again below.' } if (isTransient(err)) { return 'Couldn’t reach Sentry just now (network). It should recover on the next check.' @@ -174,3 +183,35 @@ export async function checkConnections() { } return shape(result) } + +// One-click fix for the package-missing gate: run `npm install` in the +// extension's own directory (via sentryClient's installPackage, so the path is +// never guessed by an agent or user) and immediately re-probe. Returns the fresh +// connection state either way so the gate/setup UI can render the outcome — +// success clears the gate, and a failed install surfaces as a normal probe error +// (e.g. still package-missing, or an npm/network failure) rather than throwing. +export async function installDependencies() { + try { + await installPackage() + } catch (err) { + console.error('[sentry-triage] npm install failed:', err instanceof Error ? err.message : err) + } + const { connections } = await checkConnectionsOnce() + return connections +} + +// One-click fix for the "not authenticated" setup gate: run the SDK's own +// OAuth device-code login (sentryClient's login(), the in-process equivalent +// of `sentry auth login`) and immediately re-probe. Only ever called for a +// package-present, not-signed-in state — the gate never shows this button +// while the package itself is missing (see components/page.mjs) — so unlike +// installDependencies() a thrown login error (user closed the browser tab, +// denied consent, or the device code expired) is left to propagate: the +// caller (extension.mjs onAuthenticate) surfaces it to the gate rather than +// silently falling back to a generic "still signed out" re-probe, since the +// specific reason (denied vs. expired vs. cancelled) is worth showing. +export async function authenticate() { + await login() + const { connections } = await checkConnectionsOnce() + return connections +} diff --git a/extensions/sentry-triage/sentryClient.mjs b/extensions/sentry-triage/sentryClient.mjs index 26e4ba75..7cba248f 100644 --- a/extensions/sentry-triage/sentryClient.mjs +++ b/extensions/sentry-triage/sentryClient.mjs @@ -13,6 +13,12 @@ // canvas's internal issue model lives in sentry.mjs so this file stays a thin, // swappable transport. +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' + +const execFileAsync = promisify(execFile) + // The heavyweight `sentry` CLI package is an OPTIONAL, lazily-loaded dependency. // awesome-copilot ships extension *source* only (no node_modules), so an installed // plugin may not have it. A top-level `import ... from 'sentry'` would throw @@ -27,10 +33,11 @@ // SentryError class, so existing `instanceof` + `.exitCode` checks keep matching // the errors the SDK actually throws. let SentryError = class SentryError extends Error { - constructor(message, opts = {}) { + constructor(message, exitCode = 0, stderr = '') { super(message) this.name = 'SentryError' - if (opts.code) this.code = opts.code + this.exitCode = exitCode + this.stderr = stderr } } @@ -45,26 +52,68 @@ const PACKAGE_MISSING_MESSAGE = let sdk = null let sdkFactory = null +let installPromise = null +let loginPromise = null + +// One-click install for the missing `sentry` package, driven by the setup +// gate's "Install dependencies" button (see extension.mjs installDependencies / +// server.mjs POST /api/install-dependencies). Runs `npm install` rooted at THIS +// file's own directory — i.e. the extension's actual on-disk location, whatever +// that is (repo source, a user/project extensions folder, or an installed +// plugin's materialized copy) — so it never depends on a user or agent guessing +// the right path (the earlier manual "ask Copilot to install" flow's failure +// mode). Serialized on the same sdkQueue as every other SDK call so it can't run +// concurrently with an in-flight probe. Concurrent install clicks share one +// in-flight install instead of queueing redundant npm installs. +export function installPackage() { + if (installPromise) return installPromise + installPromise = runSerial(async () => { + // Windows can't exec the `npm.cmd` shim directly without a shell, and a raw + // `new URL('.', import.meta.url).pathname` yields a URL-style path like + // "/C:/..." rather than a native Windows path — fileURLToPath handles both + // platforms correctly. + const cwd = fileURLToPath(new URL('.', import.meta.url)) + // Async execFile (not execFileSync): this module and its loopback HTTP/SSE + // servers are shared by every canvas instance in the process, so a + // synchronous, up-to-120s install would freeze all of them. runSerial's + // queue already prevents this from overlapping with other SDK/install + // calls, so there's no concurrency downside to awaiting it instead. + await execFileAsync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { + cwd, + timeout: 120_000, + shell: process.platform === 'win32', + }) + sdkFactory = null + sdk = null + }).finally(() => { + installPromise = null + }) + return installPromise +} // Import the optional `sentry` package exactly once. Throws a SentryError tagged // SENTRY_PACKAGE_MISSING when it can't be resolved, and swaps in the SDK's real // SentryError class (live ESM binding) when it can. +// async function loadFactory() { if (sdkFactory) return sdkFactory let mod try { mod = await import('sentry') } catch (err) { - // Only translate "the `sentry` package itself isn't installed" into the setup - // gate. Node reports that as ERR_MODULE_NOT_FOUND naming the `sentry` package. - // Any OTHER failure — a missing TRANSITIVE dependency, or the package's own - // entrypoint throwing at import — is a real defect we must surface, not mask - // behind a misleading "reinstall sentry" message. Rethrow those unchanged. + if (!(err && err.code === 'ERR_MODULE_NOT_FOUND')) throw err + // ERR_MODULE_NOT_FOUND is also what Node throws when `sentry` itself + // resolves fine but one of ITS OWN dependencies is missing (e.g. a + // corrupt or partial install) — that's a real defect, not a "not + // installed" state, and must not be masked as package-missing. Only the + // "Cannot find package/module 'sentry'" message means the bare specifier + // itself failed to resolve. const message = String((err && err.message) || '') - const sentryPackageMissing = - err && err.code === 'ERR_MODULE_NOT_FOUND' && /Cannot find (?:package|module) 'sentry'/.test(message) - if (!sentryPackageMissing) throw err - throw new SentryError(PACKAGE_MISSING_MESSAGE, { code: 'SENTRY_PACKAGE_MISSING' }) + const sentryItselfMissing = /Cannot find (?:package|module) 'sentry'/.test(message) + if (!sentryItselfMissing) throw err + const missing = new SentryError(PACKAGE_MISSING_MESSAGE, 0, '') + missing.code = 'SENTRY_PACKAGE_MISSING' + throw missing } if (mod.SentryError) SentryError = mod.SentryError sdkFactory = mod.default @@ -135,6 +184,95 @@ export async function whoami() { return runSerial(async () => (await getSdk()).auth.whoami()) } +// One-click sign-in for the "not authenticated" setup gate, driven by the +// gate's "Sign in with Sentry" button (see preflight.mjs authenticate() / +// server.mjs POST /api/auth-login). Runs the SDK's own OAuth device-code +// flow — the exact same flow `npx sentry auth login` drives from a +// terminal — which opens the user's default browser and waits for them to +// approve. Serialized on the same sdkQueue as every other SDK call (this +// module's whole reason for existing single-flights everything): the CLI +// keeps global auth state, so a concurrent whoami()/scan while login() is +// mid-flow would race the same on-disk credential login() is about to write. +// Any caller queued behind this one simply waits for the user to finish +// approving in their browser, same as a real terminal `sentry auth login` +// would block the shell. +// +// A closed tab or an ignored prompt would otherwise wait forever (the SDK +// default timeout is 900s / 15 minutes) and wedge the sdkQueue for every +// other Sentry call behind it — so we bound it here to something the setup +// gate can reasonably ask a user to wait through. On timeout the device code +// is left stale server-side; the caller can just click "Sign in" again to +// mint a fresh one. +// +// NOTE: the SDK's `timeout` is in SECONDS, not milliseconds (see +// AuthLoginParams in the sentry package's type defs) — do not multiply by +// 1000 here. +// +// `force: true` because this button is also how a signed-in-but-invalid +// credential (expired/revoked token) re-authenticates: in this non-TTY +// extension process, auth.login() silently declines to replace an existing +// credential unless forced, which would otherwise leave "Sign in again" +// wired to a no-op. `readOnly: true` requests only the read-only OAuth +// scopes (project:read, org:read, event:read, member:read, team:read) since +// this canvas only ever reads Sentry data — no need for the default +// write/admin scopes. +const LOGIN_TIMEOUT_SECONDS = 120 + +export function login() { + if (loginPromise) return loginPromise + loginPromise = runSerial(async () => { + // SENTRY_AUTH_TOKEN / SENTRY_TOKEN take precedence over the stored OAuth + // credential (see the module header). If one is set, auth.login() can + // still "succeed" and write a fresh OAuth login, but every subsequent + // call (including the whoami() probe authenticate() runs right after) + // keeps using the env token instead — so an invalid/expired env token + // would make this button look like it worked while leaving the gate + // signed out. Fail fast with actionable guidance instead of running an + // OAuth flow that can't actually take effect. + const envToken = process.env.SENTRY_AUTH_TOKEN || process.env.SENTRY_TOKEN + if (envToken) { + const envVar = process.env.SENTRY_AUTH_TOKEN ? 'SENTRY_AUTH_TOKEN' : 'SENTRY_TOKEN' + const err = new SentryError( + `${envVar} is set in this environment and takes precedence over signing in here. Unset it (or replace it with a valid token) and try again.`, + 0, + '' + ) + err.code = 'SENTRY_ENV_TOKEN_ACTIVE' + throw err + } + // sentry@0.42.2's login command sets the SHARED extension process's + // process.exitCode (not just its own in-process return value) when the + // device flow is denied, cancelled, or expires — a signal meant for a + // one-shot CLI process exiting non-zero, not for this long-lived host + // that keeps running other extensions after the call returns. Save and + // restore it around the call so a failed sign-in here doesn't leave the + // whole extension host marked to exit unsuccessfully. + const savedExitCode = process.exitCode + let result + try { + result = await (await getSdk()).auth.login({ timeout: LOGIN_TIMEOUT_SECONDS, force: true, readOnly: true }) + } finally { + process.exitCode = savedExitCode + } + // sentry@0.42.2's login command does not reliably reject when the device + // flow is denied, cancelled, or expires — it can resolve with an empty/ + // falsy result after only setting its own CLI exit code, which this SDK + // wrapper doesn't surface. Left unchecked, authenticate() would silently + // re-probe and the caller (the auth-login route) would report ok:true for + // what was actually a failed sign-in. Treat an empty result as a failure + // so the specific "still not signed in" path in the gate is reachable. + if (!result) { + const err = new SentryError('Sign-in was not completed (denied, cancelled, or the code expired).', 0, '') + err.code = 'SENTRY_LOGIN_INCOMPLETE' + throw err + } + return result + }).finally(() => { + loginPromise = null + }) + return loginPromise +} + // All organizations the stored credential can see. Raw org objects (each has a // `slug`). export async function orgList(limit = 100) { diff --git a/extensions/sentry-triage/server.mjs b/extensions/sentry-triage/server.mjs index 42ecde07..d4aef00e 100644 --- a/extensions/sentry-triage/server.mjs +++ b/extensions/sentry-triage/server.mjs @@ -77,7 +77,7 @@ function hasValidToken(req, token) { return value === token } -export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onListProjects, onResolveProject, onInvalidateEnrichment, defaults } = {}) { +export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onInstallDependencies, onAuthenticate, onListProjects, onResolveProject, onInvalidateEnrichment, defaults } = {}) { // Per-instance state + SSE clients — never shared across canvas instances. const state = createState() state.applyRepoDefaults(defaults) @@ -225,6 +225,49 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR return } + // One-click install for the "package-missing" setup gate. Runs `npm install` + // in the extension's own directory (via onInstallDependencies) and returns + // the freshly re-probed connection state, so the gate updates without + // requiring the canvas to be reopened. + if (req.method === 'POST' && req.url === '/api/install-dependencies') { + Promise.resolve(onInstallDependencies ? onInstallDependencies() : null) + .then((connections) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true, connections: connections || state.getConnections() })) + }) + .catch((err) => { + console.error('[sentry-triage] install-dependencies failed:', err instanceof Error ? err.message : err) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true, connections: state.getConnections() })) + }) + return + } + + // One-click sign-in for the "not authenticated" setup gate (only shown once + // the package is installed — see components/page.mjs). Runs the SDK's own + // OAuth device-code login, which opens the user's browser directly, and + // returns the freshly re-probed connection state. Unlike install, a failed + // login (denied consent, expired code, closed tab) is reported back as + // `ok:false` with a message instead of silently falling back to the stale + // connection state — the specific reason is worth surfacing in the gate. + if (req.method === 'POST' && req.url === '/api/auth-login') { + Promise.resolve(onAuthenticate ? onAuthenticate() : null) + .then((connections) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ ok: true, connections: connections || state.getConnections() })) + }) + .catch((err) => { + console.error('[sentry-triage] auth-login failed:', err instanceof Error ? err.message : err) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + ok: false, + error: err instanceof Error ? err.message : String(err), + connections: state.getConnections(), + })) + }) + return + } + // Fetch the Sentry project list for an org so the project field can render // as a dropdown. Fire-and-forget from the client's perspective: the fetched // list is broadcast to all clients over SSE by the handler. Used when the diff --git a/extensions/sentry-triage/styles.mjs b/extensions/sentry-triage/styles.mjs index ce20a910..f310901d 100644 --- a/extensions/sentry-triage/styles.mjs +++ b/extensions/sentry-triage/styles.mjs @@ -1033,6 +1033,25 @@ export function styles() { padding: 8px 10px; text-align: left; } + .gate-action-btn { + margin-top: 4px; + padding: 6px 14px; + font-size: 12.5px; + font-weight: var(--font-weight-semibold, 600); + color: var(--text-color-default, #e6edf3); + background: var(--background-color-default, #21262d); + border: 1px solid var(--border-color-default, #30363d); + border-radius: 6px; + cursor: pointer; + } + .gate-action-btn:hover:not(:disabled) { border-color: var(--color-focus-outline, #58a6ff); } + .gate-action-btn:disabled { opacity: 0.6; cursor: default; } + .gate-install-status { + margin: 8px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--text-color-muted, #8b949e); + } .toast { position: fixed;