mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-28 19:45:16 +00:00
chore: publish from main
This commit is contained in:
@@ -40,17 +40,11 @@ that drafts a fix pull request.
|
|||||||
|
|
||||||
- **Node.js 22 or newer.**
|
- **Node.js 22 or newer.**
|
||||||
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
|
- 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
|
[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
|
to configure. The setup gate walks you through installing the dependency and
|
||||||
once with the package-local CLI, run from the extension folder:
|
signing in with one-click buttons (see **Install** below); no terminal
|
||||||
|
required. For non-interactive environments, you can instead export a token:
|
||||||
```sh
|
|
||||||
npx sentry auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
This stores an OAuth credential the canvas auto-detects. For non-interactive
|
|
||||||
environments, export a token instead:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export SENTRY_AUTH_TOKEN=<your-token>
|
export SENTRY_AUTH_TOKEN=<your-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
|
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
|
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
|
If you'd rather not use the buttons (or `npm`/a browser isn't available to the
|
||||||
for you. Paste this into Copilot:
|
extension process), you can run the same steps yourself:
|
||||||
|
|
||||||
> 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
|
```sh
|
||||||
# User scope
|
# User scope
|
||||||
|
|||||||
@@ -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>
|
<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;"'}>
|
<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-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>
|
||||||
<div class="gate-body gate-body-auth" id="gate-body-auth"${signedOut && !packageMissing ? '' : ' style="display:none;"'}>
|
<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-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>
|
||||||
<div class="gate-body gate-body-conn" id="gate-body-conn"${signedOut || !transientConn || packageMissing ? ' style="display:none;"' : ''}>
|
<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>
|
<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-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>
|
<p class="gate-steps">See the details below, then re-open this canvas to try again.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -489,27 +493,77 @@ export function Page({
|
|||||||
syncScanButtonState();
|
syncScanButtonState();
|
||||||
|
|
||||||
const orgSelectEl = document.getElementById("org-select");
|
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 orgInputEl = document.getElementById("org-input");
|
||||||
const mirrorOrgSelect = () => {
|
const mirrorOrgSelect = (clearProject = true) => {
|
||||||
if (orgInputEl) orgInputEl.value = orgSelectEl.value;
|
if (orgInputEl) orgInputEl.value = sel.value;
|
||||||
// Clear any project slug carried over from the previously-selected org —
|
// Clear any project slug carried over from the previously-selected org —
|
||||||
// it won't exist under the new org's project list.
|
// it won't exist under the new org's project list.
|
||||||
const projInput = document.getElementById("project-input");
|
const projInput = document.getElementById("project-input");
|
||||||
if (projInput) projInput.value = "";
|
if (clearProject && projInput) projInput.value = "";
|
||||||
syncScanButtonState();
|
syncScanButtonState();
|
||||||
// Load the new org's projects into the autocomplete: instant from cache
|
// Load the new org's projects into the autocomplete: instant from cache
|
||||||
// when we've seen it before, otherwise a "loading projects…" hint until
|
// when we've seen it before, otherwise a "loading projects…" hint until
|
||||||
// the fetched list arrives over SSE.
|
// 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
|
// The <select> shows its first option as selected by default, but the
|
||||||
// input starts empty when there's no detected default — so Scan looks
|
// input starts empty when there's no detected default — so Scan looks
|
||||||
// disabled and re-picking the already-shown org fires no change event.
|
// 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
|
// Seed the input from the select's current value on load so the visible
|
||||||
// selection is the effective one and Scan is enabled.
|
// 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
|
// 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
|
// 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.
|
// to type it. Never clobber a value the user has started editing.
|
||||||
function applyOrgDefault(def) {
|
function applyOrgDefault(def) {
|
||||||
if (!def) return;
|
if (!def) return false;
|
||||||
const input = document.getElementById("org-input");
|
const input = document.getElementById("org-input");
|
||||||
if (input && !input.value) {
|
if (input && !input.value) {
|
||||||
input.value = def;
|
input.value = def;
|
||||||
syncScanButtonState();
|
syncScanButtonState();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = new EventSource("/api/events");
|
const source = new EventSource("/api/events");
|
||||||
@@ -635,6 +691,12 @@ export function Page({
|
|||||||
// project autocomplete; empty = fall back to a typed slug box.
|
// project autocomplete; empty = fall back to a typed slug box.
|
||||||
let currentSentryProjects = ${jsonForScript(projectSlugs)};
|
let currentSentryProjects = ${jsonForScript(projectSlugs)};
|
||||||
let currentOrgOptions = ${jsonForScript(orgSlugs)};
|
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 currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])};
|
||||||
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
|
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
|
||||||
let currentScanError = ${jsonForScript(scanError || '')};
|
let currentScanError = ${jsonForScript(scanError || '')};
|
||||||
@@ -993,7 +1055,25 @@ export function Page({
|
|||||||
enterTriageChrome(msg.org);
|
enterTriageChrome(msg.org);
|
||||||
updateToolbarVisibility(hasRenderableIssues());
|
updateToolbarVisibility(hasRenderableIssues());
|
||||||
} else if (typeof msg.orgDefault === "string") {
|
} 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) {
|
if ("project" in msg) {
|
||||||
const nextProject = msg.project || "";
|
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) {
|
if (typeof msg.savedDefaultOrg === "string" && msg.savedDefaultOrg !== currentSavedDefaultOrg) {
|
||||||
currentSavedDefaultOrg = msg.savedDefaultOrg;
|
currentSavedDefaultOrg = msg.savedDefaultOrg;
|
||||||
refreshDefaultBtn();
|
refreshDefaultBtn();
|
||||||
@@ -1124,7 +1212,7 @@ export function Page({
|
|||||||
const gateErrEl = document.getElementById("gate-error");
|
const gateErrEl = document.getElementById("gate-error");
|
||||||
if (gateErrEl) {
|
if (gateErrEl) {
|
||||||
const err = (c.sentry && c.sentry.error) || "";
|
const err = (c.sentry && c.sentry.error) || "";
|
||||||
if (gatedNow && err && !packageMissing) {
|
if (gatedNow && err && !packageMissing && !signedOut) {
|
||||||
gateErrEl.textContent = err;
|
gateErrEl.textContent = err;
|
||||||
gateErrEl.style.display = "";
|
gateErrEl.style.display = "";
|
||||||
} else {
|
} else {
|
||||||
@@ -1141,6 +1229,20 @@ export function Page({
|
|||||||
wasGatedPrev = gatedNow;
|
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) {
|
function enterTriageChrome(org) {
|
||||||
const picker = document.querySelector(".org-picker");
|
const picker = document.querySelector(".org-picker");
|
||||||
if (picker) picker.style.display = "none";
|
if (picker) picker.style.display = "none";
|
||||||
@@ -1887,6 +1989,123 @@ export function Page({
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
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");
|
const btn = e.target.closest("#refresh, #rescan");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const subtitle = document.querySelector(".page-subtitle");
|
const subtitle = document.querySelector(".page-subtitle");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process'
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { startServer } from './server.mjs'
|
import { startServer } from './server.mjs'
|
||||||
import { scanIssues, listOrgs, listProjects, findProject } from './sentry.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'
|
import { sanitizeForPrompt } from './escape.mjs'
|
||||||
|
|
||||||
// A Sentry-derived URL is safe to pass through only if it PARSES as a real
|
// 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
|
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) {
|
async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
|
||||||
if (entry.closed) return
|
if (entry.closed) return
|
||||||
const uniqueKeys = [...new Set(issueKeys)].filter(Boolean)
|
const uniqueKeys = [...new Set(issueKeys)].filter(Boolean)
|
||||||
@@ -1982,6 +2031,8 @@ const session = await joinSession({
|
|||||||
onRefresh: () => refreshAll(entry),
|
onRefresh: () => refreshAll(entry),
|
||||||
onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot),
|
onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot),
|
||||||
onRecheck: () => onRecheckConnections(entry),
|
onRecheck: () => onRecheckConnections(entry),
|
||||||
|
onInstallDependencies: () => onInstallDependencies(entry),
|
||||||
|
onAuthenticate: () => onAuthenticate(entry),
|
||||||
onListProjects: (org) => discoverProjects(entry, org, { force: true }),
|
onListProjects: (org) => discoverProjects(entry, org, { force: true }),
|
||||||
onResolveProject: (org, slug) => resolveProject(entry, org, slug),
|
onResolveProject: (org, slug) => resolveProject(entry, org, slug),
|
||||||
onInvalidateEnrichment: () => {
|
onInvalidateEnrichment: () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// buildConnections() is pure so it can be unit-tested offline; checkConnections()
|
// buildConnections() is pure so it can be unit-tested offline; checkConnections()
|
||||||
// is the thin wrapper that talks to Sentry.
|
// 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.
|
// Shape the raw signals into the connection status the UI consumes. Pure.
|
||||||
export function buildConnections({
|
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.
|
// network blip and not an auth failure, so it gets its own gate branch.
|
||||||
const isPackageMissing = (err) => Boolean(err) && err.code === 'SENTRY_PACKAGE_MISSING'
|
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
|
// 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
|
// credential (HTTP 401/403) often surfaces its status in stderr rather than the
|
||||||
// Error message.
|
// Error message.
|
||||||
@@ -95,11 +101,14 @@ const humanizeSentryError = (err) => {
|
|||||||
if (isPackageMissing(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.'
|
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)) {
|
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)) {
|
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)) {
|
if (isTransient(err)) {
|
||||||
return 'Couldn’t reach Sentry just now (network). It should recover on the next check.'
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
// canvas's internal issue model lives in sentry.mjs so this file stays a thin,
|
// canvas's internal issue model lives in sentry.mjs so this file stays a thin,
|
||||||
// swappable transport.
|
// 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.
|
// The heavyweight `sentry` CLI package is an OPTIONAL, lazily-loaded dependency.
|
||||||
// awesome-copilot ships extension *source* only (no node_modules), so an installed
|
// 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
|
// 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
|
// SentryError class, so existing `instanceof` + `.exitCode` checks keep matching
|
||||||
// the errors the SDK actually throws.
|
// the errors the SDK actually throws.
|
||||||
let SentryError = class SentryError extends Error {
|
let SentryError = class SentryError extends Error {
|
||||||
constructor(message, opts = {}) {
|
constructor(message, exitCode = 0, stderr = '') {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'SentryError'
|
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 sdk = null
|
||||||
let sdkFactory = 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
|
// 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
|
// SENTRY_PACKAGE_MISSING when it can't be resolved, and swaps in the SDK's real
|
||||||
// SentryError class (live ESM binding) when it can.
|
// SentryError class (live ESM binding) when it can.
|
||||||
|
//
|
||||||
async function loadFactory() {
|
async function loadFactory() {
|
||||||
if (sdkFactory) return sdkFactory
|
if (sdkFactory) return sdkFactory
|
||||||
let mod
|
let mod
|
||||||
try {
|
try {
|
||||||
mod = await import('sentry')
|
mod = await import('sentry')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Only translate "the `sentry` package itself isn't installed" into the setup
|
if (!(err && err.code === 'ERR_MODULE_NOT_FOUND')) throw err
|
||||||
// gate. Node reports that as ERR_MODULE_NOT_FOUND naming the `sentry` package.
|
// ERR_MODULE_NOT_FOUND is also what Node throws when `sentry` itself
|
||||||
// Any OTHER failure — a missing TRANSITIVE dependency, or the package's own
|
// resolves fine but one of ITS OWN dependencies is missing (e.g. a
|
||||||
// entrypoint throwing at import — is a real defect we must surface, not mask
|
// corrupt or partial install) — that's a real defect, not a "not
|
||||||
// behind a misleading "reinstall sentry" message. Rethrow those unchanged.
|
// 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 message = String((err && err.message) || '')
|
||||||
const sentryPackageMissing =
|
const sentryItselfMissing = /Cannot find (?:package|module) 'sentry'/.test(message)
|
||||||
err && err.code === 'ERR_MODULE_NOT_FOUND' && /Cannot find (?:package|module) 'sentry'/.test(message)
|
if (!sentryItselfMissing) throw err
|
||||||
if (!sentryPackageMissing) throw err
|
const missing = new SentryError(PACKAGE_MISSING_MESSAGE, 0, '')
|
||||||
throw new SentryError(PACKAGE_MISSING_MESSAGE, { code: 'SENTRY_PACKAGE_MISSING' })
|
missing.code = 'SENTRY_PACKAGE_MISSING'
|
||||||
|
throw missing
|
||||||
}
|
}
|
||||||
if (mod.SentryError) SentryError = mod.SentryError
|
if (mod.SentryError) SentryError = mod.SentryError
|
||||||
sdkFactory = mod.default
|
sdkFactory = mod.default
|
||||||
@@ -135,6 +184,95 @@ export async function whoami() {
|
|||||||
return runSerial(async () => (await getSdk()).auth.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
|
// All organizations the stored credential can see. Raw org objects (each has a
|
||||||
// `slug`).
|
// `slug`).
|
||||||
export async function orgList(limit = 100) {
|
export async function orgList(limit = 100) {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function hasValidToken(req, token) {
|
|||||||
return value === 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.
|
// Per-instance state + SSE clients — never shared across canvas instances.
|
||||||
const state = createState()
|
const state = createState()
|
||||||
state.applyRepoDefaults(defaults)
|
state.applyRepoDefaults(defaults)
|
||||||
@@ -225,6 +225,49 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
|
|||||||
return
|
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
|
// 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
|
// 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
|
// list is broadcast to all clients over SSE by the handler. Used when the
|
||||||
|
|||||||
@@ -1033,6 +1033,25 @@ export function styles() {
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
text-align: left;
|
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 {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -40,17 +40,11 @@ that drafts a fix pull request.
|
|||||||
|
|
||||||
- **Node.js 22 or newer.**
|
- **Node.js 22 or newer.**
|
||||||
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
|
- 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
|
[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
|
to configure. The setup gate walks you through installing the dependency and
|
||||||
once with the package-local CLI, run from the extension folder:
|
signing in with one-click buttons (see **Install** below); no terminal
|
||||||
|
required. For non-interactive environments, you can instead export a token:
|
||||||
```sh
|
|
||||||
npx sentry auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
This stores an OAuth credential the canvas auto-detects. For non-interactive
|
|
||||||
environments, export a token instead:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export SENTRY_AUTH_TOKEN=<your-token>
|
export SENTRY_AUTH_TOKEN=<your-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
|
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
|
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
|
If you'd rather not use the buttons (or `npm`/a browser isn't available to the
|
||||||
for you. Paste this into Copilot:
|
extension process), you can run the same steps yourself:
|
||||||
|
|
||||||
> 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
|
```sh
|
||||||
# User scope
|
# User scope
|
||||||
|
|||||||
+233
-14
@@ -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>
|
<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;"'}>
|
<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-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>
|
||||||
<div class="gate-body gate-body-auth" id="gate-body-auth"${signedOut && !packageMissing ? '' : ' style="display:none;"'}>
|
<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-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>
|
||||||
<div class="gate-body gate-body-conn" id="gate-body-conn"${signedOut || !transientConn || packageMissing ? ' style="display:none;"' : ''}>
|
<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>
|
<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-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>
|
<p class="gate-steps">See the details below, then re-open this canvas to try again.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -489,27 +493,77 @@ export function Page({
|
|||||||
syncScanButtonState();
|
syncScanButtonState();
|
||||||
|
|
||||||
const orgSelectEl = document.getElementById("org-select");
|
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 orgInputEl = document.getElementById("org-input");
|
||||||
const mirrorOrgSelect = () => {
|
const mirrorOrgSelect = (clearProject = true) => {
|
||||||
if (orgInputEl) orgInputEl.value = orgSelectEl.value;
|
if (orgInputEl) orgInputEl.value = sel.value;
|
||||||
// Clear any project slug carried over from the previously-selected org —
|
// Clear any project slug carried over from the previously-selected org —
|
||||||
// it won't exist under the new org's project list.
|
// it won't exist under the new org's project list.
|
||||||
const projInput = document.getElementById("project-input");
|
const projInput = document.getElementById("project-input");
|
||||||
if (projInput) projInput.value = "";
|
if (clearProject && projInput) projInput.value = "";
|
||||||
syncScanButtonState();
|
syncScanButtonState();
|
||||||
// Load the new org's projects into the autocomplete: instant from cache
|
// Load the new org's projects into the autocomplete: instant from cache
|
||||||
// when we've seen it before, otherwise a "loading projects…" hint until
|
// when we've seen it before, otherwise a "loading projects…" hint until
|
||||||
// the fetched list arrives over SSE.
|
// 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
|
// The <select> shows its first option as selected by default, but the
|
||||||
// input starts empty when there's no detected default — so Scan looks
|
// input starts empty when there's no detected default — so Scan looks
|
||||||
// disabled and re-picking the already-shown org fires no change event.
|
// 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
|
// Seed the input from the select's current value on load so the visible
|
||||||
// selection is the effective one and Scan is enabled.
|
// 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
|
// 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
|
// 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.
|
// to type it. Never clobber a value the user has started editing.
|
||||||
function applyOrgDefault(def) {
|
function applyOrgDefault(def) {
|
||||||
if (!def) return;
|
if (!def) return false;
|
||||||
const input = document.getElementById("org-input");
|
const input = document.getElementById("org-input");
|
||||||
if (input && !input.value) {
|
if (input && !input.value) {
|
||||||
input.value = def;
|
input.value = def;
|
||||||
syncScanButtonState();
|
syncScanButtonState();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = new EventSource("/api/events");
|
const source = new EventSource("/api/events");
|
||||||
@@ -635,6 +691,12 @@ export function Page({
|
|||||||
// project autocomplete; empty = fall back to a typed slug box.
|
// project autocomplete; empty = fall back to a typed slug box.
|
||||||
let currentSentryProjects = ${jsonForScript(projectSlugs)};
|
let currentSentryProjects = ${jsonForScript(projectSlugs)};
|
||||||
let currentOrgOptions = ${jsonForScript(orgSlugs)};
|
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 currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])};
|
||||||
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
|
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
|
||||||
let currentScanError = ${jsonForScript(scanError || '')};
|
let currentScanError = ${jsonForScript(scanError || '')};
|
||||||
@@ -993,7 +1055,25 @@ export function Page({
|
|||||||
enterTriageChrome(msg.org);
|
enterTriageChrome(msg.org);
|
||||||
updateToolbarVisibility(hasRenderableIssues());
|
updateToolbarVisibility(hasRenderableIssues());
|
||||||
} else if (typeof msg.orgDefault === "string") {
|
} 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) {
|
if ("project" in msg) {
|
||||||
const nextProject = msg.project || "";
|
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) {
|
if (typeof msg.savedDefaultOrg === "string" && msg.savedDefaultOrg !== currentSavedDefaultOrg) {
|
||||||
currentSavedDefaultOrg = msg.savedDefaultOrg;
|
currentSavedDefaultOrg = msg.savedDefaultOrg;
|
||||||
refreshDefaultBtn();
|
refreshDefaultBtn();
|
||||||
@@ -1124,7 +1212,7 @@ export function Page({
|
|||||||
const gateErrEl = document.getElementById("gate-error");
|
const gateErrEl = document.getElementById("gate-error");
|
||||||
if (gateErrEl) {
|
if (gateErrEl) {
|
||||||
const err = (c.sentry && c.sentry.error) || "";
|
const err = (c.sentry && c.sentry.error) || "";
|
||||||
if (gatedNow && err && !packageMissing) {
|
if (gatedNow && err && !packageMissing && !signedOut) {
|
||||||
gateErrEl.textContent = err;
|
gateErrEl.textContent = err;
|
||||||
gateErrEl.style.display = "";
|
gateErrEl.style.display = "";
|
||||||
} else {
|
} else {
|
||||||
@@ -1141,6 +1229,20 @@ export function Page({
|
|||||||
wasGatedPrev = gatedNow;
|
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) {
|
function enterTriageChrome(org) {
|
||||||
const picker = document.querySelector(".org-picker");
|
const picker = document.querySelector(".org-picker");
|
||||||
if (picker) picker.style.display = "none";
|
if (picker) picker.style.display = "none";
|
||||||
@@ -1887,6 +1989,123 @@ export function Page({
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
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");
|
const btn = e.target.closest("#refresh, #rescan");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const subtitle = document.querySelector(".page-subtitle");
|
const subtitle = document.querySelector(".page-subtitle");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process'
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { startServer } from './server.mjs'
|
import { startServer } from './server.mjs'
|
||||||
import { scanIssues, listOrgs, listProjects, findProject } from './sentry.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'
|
import { sanitizeForPrompt } from './escape.mjs'
|
||||||
|
|
||||||
// A Sentry-derived URL is safe to pass through only if it PARSES as a real
|
// 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
|
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) {
|
async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
|
||||||
if (entry.closed) return
|
if (entry.closed) return
|
||||||
const uniqueKeys = [...new Set(issueKeys)].filter(Boolean)
|
const uniqueKeys = [...new Set(issueKeys)].filter(Boolean)
|
||||||
@@ -1982,6 +2031,8 @@ const session = await joinSession({
|
|||||||
onRefresh: () => refreshAll(entry),
|
onRefresh: () => refreshAll(entry),
|
||||||
onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot),
|
onWorkSelected: (keys, modelByKey, assignCopilot) => onWorkSelected(entry, keys, modelByKey, assignCopilot),
|
||||||
onRecheck: () => onRecheckConnections(entry),
|
onRecheck: () => onRecheckConnections(entry),
|
||||||
|
onInstallDependencies: () => onInstallDependencies(entry),
|
||||||
|
onAuthenticate: () => onAuthenticate(entry),
|
||||||
onListProjects: (org) => discoverProjects(entry, org, { force: true }),
|
onListProjects: (org) => discoverProjects(entry, org, { force: true }),
|
||||||
onResolveProject: (org, slug) => resolveProject(entry, org, slug),
|
onResolveProject: (org, slug) => resolveProject(entry, org, slug),
|
||||||
onInvalidateEnrichment: () => {
|
onInvalidateEnrichment: () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// buildConnections() is pure so it can be unit-tested offline; checkConnections()
|
// buildConnections() is pure so it can be unit-tested offline; checkConnections()
|
||||||
// is the thin wrapper that talks to Sentry.
|
// 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.
|
// Shape the raw signals into the connection status the UI consumes. Pure.
|
||||||
export function buildConnections({
|
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.
|
// network blip and not an auth failure, so it gets its own gate branch.
|
||||||
const isPackageMissing = (err) => Boolean(err) && err.code === 'SENTRY_PACKAGE_MISSING'
|
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
|
// 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
|
// credential (HTTP 401/403) often surfaces its status in stderr rather than the
|
||||||
// Error message.
|
// Error message.
|
||||||
@@ -95,11 +101,14 @@ const humanizeSentryError = (err) => {
|
|||||||
if (isPackageMissing(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.'
|
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)) {
|
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)) {
|
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)) {
|
if (isTransient(err)) {
|
||||||
return 'Couldn’t reach Sentry just now (network). It should recover on the next check.'
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
+149
-11
@@ -13,6 +13,12 @@
|
|||||||
// canvas's internal issue model lives in sentry.mjs so this file stays a thin,
|
// canvas's internal issue model lives in sentry.mjs so this file stays a thin,
|
||||||
// swappable transport.
|
// 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.
|
// The heavyweight `sentry` CLI package is an OPTIONAL, lazily-loaded dependency.
|
||||||
// awesome-copilot ships extension *source* only (no node_modules), so an installed
|
// 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
|
// 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
|
// SentryError class, so existing `instanceof` + `.exitCode` checks keep matching
|
||||||
// the errors the SDK actually throws.
|
// the errors the SDK actually throws.
|
||||||
let SentryError = class SentryError extends Error {
|
let SentryError = class SentryError extends Error {
|
||||||
constructor(message, opts = {}) {
|
constructor(message, exitCode = 0, stderr = '') {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'SentryError'
|
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 sdk = null
|
||||||
let sdkFactory = 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
|
// 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
|
// SENTRY_PACKAGE_MISSING when it can't be resolved, and swaps in the SDK's real
|
||||||
// SentryError class (live ESM binding) when it can.
|
// SentryError class (live ESM binding) when it can.
|
||||||
|
//
|
||||||
async function loadFactory() {
|
async function loadFactory() {
|
||||||
if (sdkFactory) return sdkFactory
|
if (sdkFactory) return sdkFactory
|
||||||
let mod
|
let mod
|
||||||
try {
|
try {
|
||||||
mod = await import('sentry')
|
mod = await import('sentry')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Only translate "the `sentry` package itself isn't installed" into the setup
|
if (!(err && err.code === 'ERR_MODULE_NOT_FOUND')) throw err
|
||||||
// gate. Node reports that as ERR_MODULE_NOT_FOUND naming the `sentry` package.
|
// ERR_MODULE_NOT_FOUND is also what Node throws when `sentry` itself
|
||||||
// Any OTHER failure — a missing TRANSITIVE dependency, or the package's own
|
// resolves fine but one of ITS OWN dependencies is missing (e.g. a
|
||||||
// entrypoint throwing at import — is a real defect we must surface, not mask
|
// corrupt or partial install) — that's a real defect, not a "not
|
||||||
// behind a misleading "reinstall sentry" message. Rethrow those unchanged.
|
// 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 message = String((err && err.message) || '')
|
||||||
const sentryPackageMissing =
|
const sentryItselfMissing = /Cannot find (?:package|module) 'sentry'/.test(message)
|
||||||
err && err.code === 'ERR_MODULE_NOT_FOUND' && /Cannot find (?:package|module) 'sentry'/.test(message)
|
if (!sentryItselfMissing) throw err
|
||||||
if (!sentryPackageMissing) throw err
|
const missing = new SentryError(PACKAGE_MISSING_MESSAGE, 0, '')
|
||||||
throw new SentryError(PACKAGE_MISSING_MESSAGE, { code: 'SENTRY_PACKAGE_MISSING' })
|
missing.code = 'SENTRY_PACKAGE_MISSING'
|
||||||
|
throw missing
|
||||||
}
|
}
|
||||||
if (mod.SentryError) SentryError = mod.SentryError
|
if (mod.SentryError) SentryError = mod.SentryError
|
||||||
sdkFactory = mod.default
|
sdkFactory = mod.default
|
||||||
@@ -135,6 +184,95 @@ export async function whoami() {
|
|||||||
return runSerial(async () => (await getSdk()).auth.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
|
// All organizations the stored credential can see. Raw org objects (each has a
|
||||||
// `slug`).
|
// `slug`).
|
||||||
export async function orgList(limit = 100) {
|
export async function orgList(limit = 100) {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function hasValidToken(req, token) {
|
|||||||
return value === 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.
|
// Per-instance state + SSE clients — never shared across canvas instances.
|
||||||
const state = createState()
|
const state = createState()
|
||||||
state.applyRepoDefaults(defaults)
|
state.applyRepoDefaults(defaults)
|
||||||
@@ -225,6 +225,49 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
|
|||||||
return
|
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
|
// 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
|
// 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
|
// list is broadcast to all clients over SSE by the handler. Used when the
|
||||||
|
|||||||
@@ -1033,6 +1033,25 @@ export function styles() {
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
text-align: left;
|
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 {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user