diff --git a/extensions/sentry-triage/README.md b/extensions/sentry-triage/README.md index 85a3cbcb..3aa80277 100644 --- a/extensions/sentry-triage/README.md +++ b/extensions/sentry-triage/README.md @@ -42,10 +42,11 @@ that drafts a fix pull request. - The GitHub Copilot app canvas / UI-extensions experiment enabled. - 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. Sign in once from your terminal: + to configure. After installing the dependency (see **Install** below), sign in + once with the package-local CLI, run from the extension folder: ```sh - sentry auth login + npx sentry auth login ``` This stores an OAuth credential the canvas auto-detects. For non-interactive @@ -61,10 +62,35 @@ that drafts a fix pull request. ## Install -Drop this folder at `~/.copilot/extensions/sentry-triage/` for user scope, or in -a repository at `.github/extensions/sentry-triage/` for project scope. Then -install dependencies from inside the copied folder (this canvas depends on the -`sentry` npm package at runtime): +Drop this folder at `~/.copilot/extensions/sentry-triage/` for user scope, or in a +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. + +### Let Copilot set it up (recommended) + +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 ```sh # User scope @@ -73,11 +99,15 @@ cd ~/.copilot/extensions/sentry-triage # Or project scope, from the repository root cd .github/extensions/sentry-triage +# Or, if you installed the published plugin (`copilot plugin install`), the source +# lives inside the installed plugin — cd into its extension folder: +cd /com.github.copilot/extensions/sentry-triage + npm install +npx sentry auth login ``` -Reload extensions in the GitHub Copilot app, then open the `sentry-triage` -canvas. +Reload extensions in the GitHub Copilot app, then open the `sentry-triage` canvas. ## Open the canvas in the correct repository scope diff --git a/extensions/sentry-triage/components/page.mjs b/extensions/sentry-triage/components/page.mjs index e390ace1..5353227a 100644 --- a/extensions/sentry-triage/components/page.mjs +++ b/extensions/sentry-triage/components/page.mjs @@ -66,6 +66,10 @@ export function Page({ // required, …). Shown in the gate so users see the real reason instead of a // generic "connect" message for problems reconnecting won't fix. const gateError = (conn.sentry && conn.sentry.error) || '' + // A dedicated setup reason (currently only the optional `sentry` package being + // absent) that takes precedence over the auth/connectivity gate variants, so the + // install prompt is never shown beneath contradictory "sign in" guidance. + const packageMissing = (conn.sentry && conn.sentry.setup) === 'package-missing' // Distinguish "not signed in" (needs `sentry auth login`) from a transient // connectivity failure where a credential likely exists. probeSentry() reports // configured:false only for auth failures, so a gated-but-configured state means @@ -184,20 +188,24 @@ export function Page({ @@ -1108,25 +1116,30 @@ export function Page({ // sentry auth login command, nor promise recovery for what won't self-heal. const signedOut = !(c.sentry && c.sentry.configured); const transientConn = Boolean(c.sentry && c.sentry.transient); + const packageMissing = (c.sentry && c.sentry.setup) === "package-missing"; const gateTitleEl = document.getElementById("gate-title"); if (gateTitleEl) { - gateTitleEl.textContent = signedOut + gateTitleEl.textContent = packageMissing + ? "Set up the Sentry CLI" + : signedOut ? "Connect Sentry to start triaging" : "Can’t reach Sentry right now"; } + const setupBody = document.getElementById("gate-body-setup"); const authBody = document.getElementById("gate-body-auth"); const connBody = document.getElementById("gate-body-conn"); const unknownBody = document.getElementById("gate-body-unknown"); - if (authBody) authBody.style.display = signedOut ? "" : "none"; - if (connBody) connBody.style.display = !signedOut && transientConn ? "" : "none"; - if (unknownBody) unknownBody.style.display = !signedOut && !transientConn ? "" : "none"; + if (setupBody) setupBody.style.display = packageMissing ? "" : "none"; + if (authBody) authBody.style.display = signedOut && !packageMissing ? "" : "none"; + if (connBody) connBody.style.display = !signedOut && !packageMissing && transientConn ? "" : "none"; + if (unknownBody) unknownBody.style.display = !signedOut && !packageMissing && !transientConn ? "" : "none"; // Surface the specific preflight failure in the gate (or hide it when the // error clears / there's nothing actionable beyond the sign-in steps). const gateErrEl = document.getElementById("gate-error"); if (gateErrEl) { const err = (c.sentry && c.sentry.error) || ""; - if (gatedNow && err) { + if (gatedNow && err && !packageMissing) { gateErrEl.textContent = err; gateErrEl.style.display = ""; } else { diff --git a/extensions/sentry-triage/preflight.mjs b/extensions/sentry-triage/preflight.mjs index 95c8c6c6..a563f300 100644 --- a/extensions/sentry-triage/preflight.mjs +++ b/extensions/sentry-triage/preflight.mjs @@ -23,6 +23,7 @@ export function buildConnections({ sentryReachable = false, sentryError = '', sentryTransient = false, + sentrySetup = '', } = {}) { return { checked: true, @@ -34,6 +35,10 @@ export function buildConnections({ // recovery for blips but show neutral guidance for unknown failures that // won't self-heal, instead of routing every failure to a "network" message. transient: sentryReachable ? false : Boolean(sentryTransient), + // Explicit setup reason (currently only 'package-missing') for gate variants + // that must NOT be inferred from the auth boolean. Keeps a missing-dependency + // install prompt from rendering beneath contradictory "sign in" guidance. + setup: sentryReachable ? '' : String(sentrySetup || ''), error: sentryReachable ? '' : String(sentryError || ''), }, } @@ -44,12 +49,17 @@ export function buildConnections({ export function unknownConnections() { return { checked: false, - sentry: { configured: false, reachable: false, transient: false, error: '' }, + sentry: { configured: false, reachable: false, transient: false, setup: '', error: '' }, } } const msg = (err) => (err instanceof Error ? err.message : String(err)) +// The optional `sentry` package isn't installed for this extension (a published +// awesome-copilot plugin ships source only). This is a one-time setup step, not a +// network blip and not an auth failure, so it gets its own gate branch. +const isPackageMissing = (err) => Boolean(err) && err.code === 'SENTRY_PACKAGE_MISSING' + // 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. @@ -82,11 +92,14 @@ const isTransient = (err) => // Turn the raw error into something a human can act on. const humanizeSentryError = (err) => { const t = msg(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 (isNotAuthenticated(err)) { - return 'Sentry isn’t connected yet. Run `sentry auth login` in your terminal, then re-open this canvas.' + return 'Sentry isn’t connected yet. Run `npx sentry auth login` from the extension folder, then re-open this canvas.' } if (isAuthFailure(err)) { - return 'Sentry rejected your credential (expired or invalid). Run `sentry auth login` in your terminal, then re-open this canvas.' + return 'Sentry rejected your credential (expired or invalid). Run `npx sentry auth login` from the extension folder, then re-open this canvas.' } if (isTransient(err)) { return 'Couldn’t reach Sentry just now (network). It should recover on the next check.' @@ -97,14 +110,16 @@ const humanizeSentryError = (err) => { // Classify a failed Sentry probe into the two decisions the caller cares about, // plus a human message. Pure and exported so the gate/retry branching can be // unit-tested without mocking the SDK: -// - configured: does a usable credential exist? Auth failures (no login OR a -// rejected/expired credential) clear it so the gate shows sign-in guidance; +// - configured: is the canvas actually set up to reach Sentry? Auth failures +// (no login OR a rejected/expired credential) clear it so the gate shows +// sign-in guidance, and a missing `sentry` package clears it too so the gate +// shows install guidance instead of a contradictory configured:true state; // everything else keeps it so the gate shows connectivity guidance. // - transient: worth retrying? Only network blips — never auth failures, even // when their text happens to mention a network keyword. export function classifySentryError(err) { return { - configured: !isAuthFailure(err), + configured: !isAuthFailure(err) && !isPackageMissing(err), transient: isTransient(err), message: humanizeSentryError(err), } @@ -135,6 +150,7 @@ const shape = (result) => sentryConfigured: result.configured, sentryReachable: result.reachable, sentryTransient: result.transient, + sentrySetup: !result.reachable && isPackageMissing(result.error) ? 'package-missing' : '', sentryError: result.reachable ? '' : humanizeSentryError(result.error), }) diff --git a/extensions/sentry-triage/sentryClient.mjs b/extensions/sentry-triage/sentryClient.mjs index 794e6502..b3ebf2c4 100644 --- a/extensions/sentry-triage/sentryClient.mjs +++ b/extensions/sentry-triage/sentryClient.mjs @@ -13,16 +13,71 @@ // canvas's internal issue model lives in sentry.mjs so this file stays a thin, // swappable transport. -import createSentrySDK, { SentryError } from 'sentry' +// 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 +// `Cannot find package 'sentry'` at module load and take the whole canvas down +// before any UI renders. Instead we import it dynamically on first use and, when +// it's absent, throw a clear SentryError (tagged SENTRY_PACKAGE_MISSING) that the +// setup gate turns into actionable guidance — Copilot can install it for you. + +// A stand-in SentryError so callers can `import { SentryError }` at load time and +// `instanceof`-check even when the package never loads. When the real package IS +// present we replace this binding (a live ESM export) with the SDK's own +// SentryError class, so existing `instanceof` + `.exitCode` checks keep matching +// the errors the SDK actually throws. +let SentryError = class SentryError extends Error { + constructor(message, opts = {}) { + super(message) + this.name = 'SentryError' + if (opts.code) this.code = opts.code + } +} export { SentryError } +// Fallback message when the optional `sentry` package can't be resolved. The +// setup gate (preflight.mjs) rewrites this into fuller guidance; this is what +// surfaces anywhere the raw error is shown. +const PACKAGE_MISSING_MESSAGE = + 'The Sentry CLI (the `sentry` npm package) is not installed for this extension. ' + + 'Ask Copilot to set it up, or run `npm install` in the extension folder.' + let sdk = null +let sdkFactory = null + +// 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. + 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' }) + } + if (mod.SentryError) SentryError = mod.SentryError + sdkFactory = mod.default + return sdkFactory +} // Lazily construct the SDK once. cwd affects the CLI's project-root / DSN // detection; we anchor it to the extension's cwd for determinism. -function getSdk() { - if (!sdk) sdk = createSentrySDK({ cwd: process.cwd() }) +async function getSdk() { + if (!sdk) { + const create = await loadFactory() + sdk = create({ cwd: process.cwd() }) + } return sdk } @@ -77,13 +132,13 @@ function asArray(res) { // credential is present and valid; throws SentryError ("Not authenticated…") // otherwise. Used by preflight to gate the board. export async function whoami() { - return runSerial(() => getSdk().auth.whoami()) + return runSerial(async () => (await getSdk()).auth.whoami()) } // All organizations the stored credential can see. Raw org objects (each has a // `slug`). export async function orgList(limit = 100) { - return runSerial(async () => asArray(await getSdk().org.list({ limit }))) + return runSerial(async () => asArray(await (await getSdk()).org.list({ limit }))) } // Single-page project fetch within an org. Deliberately NOT wrapped in @@ -96,13 +151,13 @@ export async function orgList(limit = 100) { // (see listProjects in sentry.mjs). export async function projectListRaw(org, limit = 100, cursor) { const orgProject = `${String(org || '').replace(/\/+$/, '')}/` - return asArray(await getSdk().project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) })) + return asArray(await (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) })) } // Verify a specific project exists / is accessible. Returns the raw project // object on success; throws SentryError when the slug is unknown or forbidden. export async function projectView(org, slug) { - return runSerial(() => getSdk().project.view({ orgProject: `${org}/${slug}` })) + return runSerial(async () => (await getSdk()).project.view({ orgProject: `${org}/${slug}` })) } // Search issues. `orgProject` is "org/project" (or the trailing-slash "org/" form @@ -113,7 +168,7 @@ export async function projectView(org, slug) { export async function issueList({ orgProject, query, sort = 'date', limit = 100, period } = {}) { return runSerial(async () => asArray( - await getSdk().issue.list({ + await (await getSdk()).issue.list({ ...(orgProject ? { orgProject } : {}), ...(query ? { query } : {}), ...(period ? { period } : {}),