From 8f80d4bb851a0610fda23c34886fbdc933065433 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Fri, 24 Jul 2026 18:03:01 -0400 Subject: [PATCH 1/2] feat(github-actions-grafana-jump): make the jump button fully generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the Grafana jump button so no Grafana instance, dashboard UID, or template-variable name is baked into the script. This also removes the reason the script's header previously said it was intentionally NOT published to GreasyFork (baked-in internal Oura infra links) - that constraint is gone now that it's fully user-configured, though whether/how to actually publish is a separate decision. Behavior: - Not configured yet -> the jump button opens an in-page config panel (not a native prompt) to set a Grafana base URL and one or more dashboards, each with whichever template-variable names it uses for filtering by branch/PR/workflow/runner. - Configured -> jumps straight to the first applicable dashboard for the current page, with a small "▾" menu to jump to a different configured dashboard or reopen the config panel. - Config persists via GM.setValue/GM.getValue (@grant changed from `none` to GM.setValue/GM.getValue accordingly), scoped to this script/browser. The existing context-detection logic (parsePrContext, parseBranchContext, parseRunnerContext, parseWorkflowContext, resolveJumpContext, extractBranchFromQuery) is untouched. New pure, unit-tested logic: contextVarKey/contextFilterValue (map a context to its filter field/value), applicableDashboards (which configured dashboards can handle this context), buildJumpUrl (per-dashboard URL building), defaultConfig/normalizeConfig/isConfigured (config lifecycle). test/grafana-jump.test.js updated accordingly - all 22 tests pass against the built dist/. tsconfig.json: added an explicit "types": ["node", "greasemonkey"] override. Under this repo's moduleResolution setting, tsc doesn't reliably auto-discover @types packages from node_modules/@types the way it does under other resolution modes, and this is now the one package that references both @types/node's `module` (existing test-export guard) and @types/greasemonkey's `GM` global. Deliberately left alone per the task: the still-unconfirmed TODO(nathan) about the two Oura dashboards' real template-variable names is moot for the shipped script now (no dashboard config ships by default at all - users fill in their own), but is not being resolved as a general finding one way or the other; it just no longer applies to this file's own contents. Not touched, pending a separate decision: @downloadURL/@updateURL and the changeset/publish wiring. Generic now, but publishing is a follow-up call, not assumed here. --- .../github-actions-grafana-jump/package.json | 2 +- .../github-actions-grafana-jump/src/index.ts | 605 +++++++++++++++--- .../test/grafana-jump.test.js | 193 ++++-- 3 files changed, 672 insertions(+), 128 deletions(-) diff --git a/packages/github-actions-grafana-jump/package.json b/packages/github-actions-grafana-jump/package.json index 5381507..55a558c 100644 --- a/packages/github-actions-grafana-jump/package.json +++ b/packages/github-actions-grafana-jump/package.json @@ -1,6 +1,6 @@ { "name": "@nsheaps/gm-github-actions-grafana-jump", - "version": "0.1.0", + "version": "0.2.0", "main": "dist/index.js", "private": true, "scripts": { diff --git a/packages/github-actions-grafana-jump/src/index.ts b/packages/github-actions-grafana-jump/src/index.ts index bceac06..7ee390e 100644 --- a/packages/github-actions-grafana-jump/src/index.ts +++ b/packages/github-actions-grafana-jump/src/index.ts @@ -1,62 +1,111 @@ // ==UserScript== // @name GitHub Actions => Grafana jump button -// @description Add a button on github.com Actions pages (PR checks, branch-filtered runs, a single workflow's runs, and runner detail pages) that jumps to the matching Grafana drill-down dashboard +// @description Add a button on github.com Actions pages (PR checks, branch-filtered runs, a single workflow's runs, and runner detail pages) that jumps to a matching Grafana drill-down dashboard you configure yourself // @match http*://www.github.com/* // @match http*://github.com/* -// @version 0.1.0 +// @version 0.2.0 // @run-at document-start // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== -// @grant none +// @grant GM.setValue +// @grant GM.getValue // @license MIT // @namespace https://www.github.com // ==/UserScript== // -// NOTE: This script is internal to Oura's infrastructure (it links to an internal -// Grafana instance) and is intentionally NOT published to GreasyFork. Do not add -// @downloadURL/@updateURL or wire this package into the changeset/publish flow. +// Fully generic: no Grafana instance, dashboard UID, or template-variable name is +// baked in. On first use (or whenever nothing configured applies to the current +// page) the jump button opens an in-page configuration panel where you enter your +// own Grafana base URL and one or more dashboards, each with the template-variable +// names it uses for filtering by branch / PR number / workflow file / runner. Once +// configured, the button jumps straight to the matching dashboard, with a small +// "▾" menu to pick among multiple configured dashboards or to reopen the config +// panel. Config is persisted via GM.setValue/GM.getValue, scoped to this script. +// +// The `var-=` query-param convention used to preset a Grafana +// dashboard's template variables from a URL is a genuine, documented Grafana +// feature (see +// https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard-url-variables/); +// what's dashboard-specific is only the variable *name* each dashboard happens to +// use, which you can find in the Grafana UI (dashboard settings -> Variables) or +// by exporting the dashboard JSON (e.g. with the `gcx` CLI). + +// --------------------------------------------------------------------------- +// Config types. A GrafanaJumpConfig is entirely user-supplied (see the config +// panel below) and persisted as-is; there is no shipped default. +// --------------------------------------------------------------------------- + +interface DashboardVarNames { + branch?: string; + prNumber?: string; + workflowName?: string; + runnerName?: string; +} + +interface DashboardConfig { + name: string; + uid: string; + slug: string; + varNames: DashboardVarNames; +} + +interface GrafanaJumpConfig { + baseUrl: string; + dashboards: DashboardConfig[]; +} + +function defaultConfig(): GrafanaJumpConfig { + return { baseUrl: "", dashboards: [] }; +} /** - * Grafana jump-button configuration. - * - * `baseUrl` and the dashboard `uid`s below are REAL and confirmed: they come from - * live links recorded in existing internal docs that catalog the "App Developers" - * Grafana folder (ADX-89 research/planning notes), not guesses. - * - `ciDevxReport` ("CI/DevX report dashboard") is built on Tempo/TraceQL GitHub - * Actions workflow+job spans and has per-run PR CI runtime, so it's the best fit - * for drilling into one PR/branch or one workflow's runs across branches. - * - `androidIosCi` ("Android & iOS CI") covers runner health and queue time, so - * it's the best fit for a single runner's activity. - * - * `varNames` is NOT confirmed. Nobody has pulled the dashboard JSON to verify the - * actual Grafana template-variable names these dashboards use for filtering by - * branch / PR number / workflow / runner - the values below are reasonable-looking - * placeholders only. The `var-` query-param prefix itself is a genuine, documented - * Grafana URL convention (see https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard-url-variables/); - * what's unverified is just the variable *name* string for each of these three - * dashboards. Confirm via the Grafana UI (dashboard settings -> Variables) or by - * exporting the dashboard JSON (e.g. with the `gcx` CLI) and drop this comment + - * the TODOs below once confirmed. + * Defensively reshapes a value loaded from storage (or pasted/hand-edited) into + * a well-formed GrafanaJumpConfig, dropping anything malformed rather than + * throwing. Keeps the rest of the script free of null/undefined-shape checks. */ -const GRAFANA_CONFIG = { - baseUrl: "https://monitoring.oura.cloud", - dashboards: { - ciDevxReport: { uid: "pagrf6j", slug: "ci-devx-report-dashboard" }, - androidIosCi: { uid: "pap5g6z", slug: "android-ios-ci" }, - }, - // TODO(nathan): confirm these against the live dashboards' actual template - // variable names - these are placeholders, not verified. - varNames: { - branch: "branch", - prNumber: "pr_number", - workflowName: "workflow_name", - runnerName: "runner_name", - }, -}; +function normalizeConfig(raw: unknown): GrafanaJumpConfig { + if (typeof raw !== "object" || raw === null) return defaultConfig(); + const obj = raw as Record; + + const baseUrl = typeof obj.baseUrl === "string" ? obj.baseUrl.trim() : ""; + + const dashboardsRaw = Array.isArray(obj.dashboards) ? obj.dashboards : []; + const dashboards: DashboardConfig[] = dashboardsRaw + .filter((d): d is Record => typeof d === "object" && d !== null) + .map((d) => { + const varNamesRaw = + typeof d.varNames === "object" && d.varNames !== null + ? (d.varNames as Record) + : {}; + const varNames: DashboardVarNames = {}; + for (const key of ["branch", "prNumber", "workflowName", "runnerName"] as const) { + const value = varNamesRaw[key]; + if (typeof value === "string" && value.trim() !== "") { + varNames[key] = value.trim(); + } + } + return { + name: typeof d.name === "string" ? d.name.trim() : "", + uid: typeof d.uid === "string" ? d.uid.trim() : "", + slug: typeof d.slug === "string" ? d.slug.trim() : "", + varNames, + }; + }) + // A dashboard with no uid can't be jumped to; drop it rather than emit a + // broken link. + .filter((d) => d.uid !== ""); + + return { baseUrl, dashboards }; +} + +/** True once there's at least a base URL and one dashboard to jump to. */ +function isConfigured(config: GrafanaJumpConfig): boolean { + return config.baseUrl !== "" && config.dashboards.length > 0; +} // --------------------------------------------------------------------------- // Pure logic: parsing the current location into a jump context, and building -// the resulting Grafana URL. Kept free of DOM access so it can be unit tested -// directly (see test/grafana-jump.test.js). +// the resulting Grafana URL. Kept free of DOM/GM access so it can be unit +// tested directly (see test/grafana-jump.test.js). // --------------------------------------------------------------------------- interface PrContext { @@ -185,11 +234,51 @@ function resolveJumpContext(pathname: string, search: string): JumpContext | nul ); } +/** Which DashboardVarNames key a given context kind is filtered by. */ +function contextVarKey(kind: JumpContext["kind"]): keyof DashboardVarNames { + switch (kind) { + case "pr": + return "prNumber"; + case "branch": + return "branch"; + case "workflow": + return "workflowName"; + case "runner": + return "runnerName"; + } +} + +/** The raw filter value (PR number, branch name, etc.) carried by a context. */ +function contextFilterValue(context: JumpContext): string { + switch (context.kind) { + case "pr": + return context.prNumber; + case "branch": + return context.branch; + case "workflow": + return context.workflowFile; + case "runner": + return context.runnerId; + } +} + +/** + * Which of the configured dashboards can actually be jumped to for this + * context - i.e. have a template-variable name configured for the field this + * context kind filters by. A dashboard with no matching varName is left out + * rather than linked to with no filter applied. + */ +function applicableDashboards(config: GrafanaJumpConfig, context: JumpContext): DashboardConfig[] { + const key = contextVarKey(context.kind); + return config.dashboards.filter((dashboard) => Boolean(dashboard.varNames[key])); +} + /** * Builds a Grafana dashboard URL with one or more template variables preset via * the `var-=` query convention. */ function buildDashboardUrl( + baseUrl: string, dashboard: { uid: string; slug: string }, vars: Record, ): string { @@ -197,45 +286,56 @@ function buildDashboardUrl( .map(([name, value]) => `var-${encodeURIComponent(name)}=${encodeURIComponent(value)}`) .join("&"); const query = params ? `?${params}` : ""; - return `${GRAFANA_CONFIG.baseUrl}/d/${dashboard.uid}/${dashboard.slug}${query}`; + return `${baseUrl}/d/${dashboard.uid}/${dashboard.slug}${query}`; } -/** Builds the Grafana jump URL for a resolved context. */ -function buildGrafanaJumpUrl(context: JumpContext): string { - switch (context.kind) { - case "pr": - return buildDashboardUrl(GRAFANA_CONFIG.dashboards.ciDevxReport, { - [GRAFANA_CONFIG.varNames.prNumber]: context.prNumber, - }); - case "branch": - return buildDashboardUrl(GRAFANA_CONFIG.dashboards.ciDevxReport, { - [GRAFANA_CONFIG.varNames.branch]: context.branch, - }); - case "workflow": - return buildDashboardUrl(GRAFANA_CONFIG.dashboards.ciDevxReport, { - [GRAFANA_CONFIG.varNames.workflowName]: context.workflowFile, - }); - case "runner": - return buildDashboardUrl(GRAFANA_CONFIG.dashboards.androidIosCi, { - [GRAFANA_CONFIG.varNames.runnerName]: context.runnerId, - }); - } +/** + * Builds the Grafana jump URL for one dashboard against a resolved context. + * Assumes the dashboard is applicable (see applicableDashboards) - callers that + * skip that check will just get a link with no var- filter applied. + */ +function buildJumpUrl(baseUrl: string, dashboard: DashboardConfig, context: JumpContext): string { + const key = contextVarKey(context.kind); + const varName = dashboard.varNames[key]; + const vars = varName ? { [varName]: contextFilterValue(context) } : {}; + return buildDashboardUrl(baseUrl, dashboard, vars); } /** Human-readable label for the jump button, specific to the matched context. */ function labelForContext(context: JumpContext): string { switch (context.kind) { case "pr": - return `Grafana: PR #${context.prNumber} CI ↗️`; + return `Grafana: PR #${context.prNumber} CI`; case "branch": - return `Grafana: ${context.branch} CI ↗️`; + return `Grafana: ${context.branch} CI`; case "workflow": - return `Grafana: ${context.workflowFile} runs ↗️`; + return `Grafana: ${context.workflowFile} runs`; case "runner": - return `Grafana: runner ${context.runnerId} ↗️`; + return `Grafana: runner ${context.runnerId}`; } } +// --------------------------------------------------------------------------- +// Config persistence. Wrapped so the rest of the script only ever deals with a +// GrafanaJumpConfig object, never the raw JSON-string storage format. +// --------------------------------------------------------------------------- + +const CONFIG_STORAGE_KEY = "grafanaJumpConfig.v1"; + +async function loadConfig(): Promise { + const raw = await GM.getValue(CONFIG_STORAGE_KEY, ""); + if (typeof raw !== "string" || raw === "") return defaultConfig(); + try { + return normalizeConfig(JSON.parse(raw)); + } catch { + return defaultConfig(); + } +} + +async function saveConfig(config: GrafanaJumpConfig): Promise { + await GM.setValue(CONFIG_STORAGE_KEY, JSON.stringify(config)); +} + // --------------------------------------------------------------------------- // DOM injection. // @@ -251,55 +351,373 @@ function labelForContext(context: JumpContext): string { // left to fix) if GitHub reshuffles the page layout again. // --------------------------------------------------------------------------- -const BUTTON_ID = "grafanaJumpButton"; +const CONTAINER_ID = "grafanaJumpContainer"; + +const BUTTON_STYLE = + "display: inline-block; background: #F55F0E; color: #fff; padding: 8px 12px; " + + "border: none; border-radius: 6px 0 0 6px; font-size: 12px; font-weight: 600; " + + "font-family: inherit; text-decoration: none; cursor: pointer; vertical-align: top;"; + +const TOGGLE_STYLE = + "display: inline-block; background: #c94c0a; color: #fff; padding: 8px 8px; " + + "border: none; border-left: 1px solid rgba(255,255,255,0.3); border-radius: 0 6px 6px 0; " + + "font-size: 12px; font-family: inherit; cursor: pointer; vertical-align: top;"; + +const SOLO_BUTTON_STYLE = + "display: inline-block; background: #57606a; color: #fff; padding: 8px 12px; " + + "border: none; border-radius: 6px; font-size: 12px; font-weight: 600; " + + "font-family: inherit; text-decoration: none; cursor: pointer;"; + +const MENU_STYLE = + "position: absolute; bottom: 100%; right: 0; margin-bottom: 4px; background: #fff; " + + "color: #24292f; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.25); " + + "min-width: 180px; overflow: hidden; font-family: inherit;"; + +const MENU_ITEM_STYLE = + "display: block; padding: 8px 12px; font-size: 12px; text-decoration: none; " + + "color: inherit; white-space: nowrap; cursor: pointer; background: none; " + + "border: none; width: 100%; text-align: left; box-sizing: border-box;"; + +let currentConfig: GrafanaJumpConfig = defaultConfig(); + +function closeMenu(): void { + document.getElementById(`${CONTAINER_ID}-menu`)?.remove(); +} + +function openMenu( + anchorContainer: HTMLElement, + items: Array<{ label: string; onClick: () => void }>, +): void { + closeMenu(); + const menu = document.createElement("div"); + menu.id = `${CONTAINER_ID}-menu`; + menu.setAttribute("style", MENU_STYLE); + + for (const item of items) { + const entry = document.createElement("button"); + entry.type = "button"; + entry.textContent = item.label; + entry.setAttribute("style", MENU_ITEM_STYLE); + entry.addEventListener("mouseenter", () => { + entry.style.background = "#f6f8fa"; + }); + entry.addEventListener("mouseleave", () => { + entry.style.background = "none"; + }); + entry.addEventListener("click", (event) => { + event.stopPropagation(); + closeMenu(); + item.onClick(); + }); + menu.appendChild(entry); + } + + anchorContainer.appendChild(menu); + + // Close on next outside click. Deferred so this listener doesn't also catch + // the very click that opened the menu. + setTimeout(() => { + document.addEventListener("click", closeMenu, { once: true }); + }, 0); +} + +function openConfigModal(): void { + closeMenu(); + + // Work on a deep-ish draft copy so Cancel leaves the saved config untouched. + const draft: GrafanaJumpConfig = { + baseUrl: currentConfig.baseUrl, + dashboards: currentConfig.dashboards.map((d) => ({ ...d, varNames: { ...d.varNames } })), + }; + + const overlay = document.createElement("div"); + overlay.setAttribute( + "style", + "position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 2147483646; " + + "display: flex; align-items: center; justify-content: center; font-family: sans-serif;", + ); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) document.body.removeChild(overlay); + }); + + const panel = document.createElement("div"); + panel.setAttribute( + "style", + "background: #fff; color: #24292f; border-radius: 8px; padding: 20px; " + + "width: 520px; max-width: 90vw; max-height: 85vh; overflow-y: auto; " + + "box-shadow: 0 8px 24px rgba(0,0,0,0.4);", + ); + panel.addEventListener("click", (event) => event.stopPropagation()); + + const title = document.createElement("h2"); + title.textContent = "Configure Grafana jump"; + title.setAttribute("style", "margin: 0 0 12px; font-size: 16px;"); + panel.appendChild(title); + + const help = document.createElement("p"); + help.textContent = + "Set your Grafana base URL and the dashboards to jump to. For each dashboard, " + + "fill in whichever template-variable names it uses (dashboard settings -> " + + "Variables) - leave the rest blank. A dashboard only shows up as a jump target " + + "on pages matching a variable name you've filled in."; + help.setAttribute("style", "margin: 0 0 16px; font-size: 12px; color: #57606a;"); + panel.appendChild(help); + + const baseUrlLabel = document.createElement("label"); + baseUrlLabel.textContent = "Grafana base URL"; + baseUrlLabel.setAttribute("style", "display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;"); + panel.appendChild(baseUrlLabel); + + const baseUrlInput = document.createElement("input"); + baseUrlInput.type = "text"; + baseUrlInput.placeholder = "https://grafana.example.com"; + baseUrlInput.value = draft.baseUrl; + baseUrlInput.setAttribute( + "style", + "display: block; width: 100%; box-sizing: border-box; padding: 6px 8px; " + + "margin-bottom: 16px; font-size: 13px; border: 1px solid #d0d7de; border-radius: 6px;", + ); + baseUrlInput.addEventListener("input", () => { + draft.baseUrl = baseUrlInput.value.trim(); + }); + panel.appendChild(baseUrlInput); + + const dashboardsHeading = document.createElement("div"); + dashboardsHeading.textContent = "Dashboards"; + dashboardsHeading.setAttribute("style", "font-size: 12px; font-weight: 600; margin-bottom: 8px;"); + panel.appendChild(dashboardsHeading); + + const rowsContainer = document.createElement("div"); + panel.appendChild(rowsContainer); + + const textField = ( + parent: HTMLElement, + labelText: string, + value: string, + onInput: (value: string) => void, + ): void => { + const wrapper = document.createElement("div"); + wrapper.setAttribute("style", "margin-bottom: 6px;"); + const label = document.createElement("label"); + label.textContent = labelText; + label.setAttribute("style", "display: block; font-size: 11px; color: #57606a; margin-bottom: 2px;"); + const input = document.createElement("input"); + input.type = "text"; + input.value = value; + input.setAttribute( + "style", + "display: block; width: 100%; box-sizing: border-box; padding: 4px 6px; " + + "font-size: 12px; border: 1px solid #d0d7de; border-radius: 4px;", + ); + input.addEventListener("input", () => onInput(input.value)); + wrapper.appendChild(label); + wrapper.appendChild(input); + parent.appendChild(wrapper); + }; + + const renderRows = (): void => { + rowsContainer.innerHTML = ""; + draft.dashboards.forEach((dashboard, index) => { + const row = document.createElement("div"); + row.setAttribute( + "style", + "border: 1px solid #d0d7de; border-radius: 6px; padding: 10px; margin-bottom: 10px; position: relative;", + ); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.textContent = "Remove"; + removeButton.setAttribute( + "style", + "position: absolute; top: 8px; right: 8px; background: none; border: none; " + + "color: #cf222e; font-size: 11px; cursor: pointer;", + ); + removeButton.addEventListener("click", () => { + draft.dashboards.splice(index, 1); + renderRows(); + }); + row.appendChild(removeButton); + + textField(row, "Display name", dashboard.name, (value) => { + dashboard.name = value; + }); + textField(row, "Dashboard UID", dashboard.uid, (value) => { + dashboard.uid = value.trim(); + }); + textField(row, "Dashboard slug", dashboard.slug, (value) => { + dashboard.slug = value.trim(); + }); + + const varsHeading = document.createElement("div"); + varsHeading.textContent = "Template variable names (leave blank if not used)"; + varsHeading.setAttribute("style", "font-size: 11px; color: #57606a; margin: 8px 0 4px;"); + row.appendChild(varsHeading); + + const varFields: Array<[keyof DashboardVarNames, string]> = [ + ["branch", "Branch"], + ["prNumber", "PR number"], + ["workflowName", "Workflow file"], + ["runnerName", "Runner"], + ]; + for (const [key, label] of varFields) { + textField(row, label, dashboard.varNames[key] ?? "", (value) => { + const trimmed = value.trim(); + if (trimmed === "") { + delete dashboard.varNames[key]; + } else { + dashboard.varNames[key] = trimmed; + } + }); + } + + rowsContainer.appendChild(row); + }); + }; + renderRows(); + + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.textContent = "+ Add dashboard"; + addButton.setAttribute( + "style", + "display: block; width: 100%; padding: 8px; margin-bottom: 16px; " + + "background: #f6f8fa; border: 1px dashed #d0d7de; border-radius: 6px; " + + "font-size: 12px; cursor: pointer;", + ); + addButton.addEventListener("click", () => { + draft.dashboards.push({ name: "", uid: "", slug: "", varNames: {} }); + renderRows(); + }); + panel.appendChild(addButton); + + const actions = document.createElement("div"); + actions.setAttribute("style", "display: flex; justify-content: flex-end; gap: 8px;"); + + const cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.textContent = "Cancel"; + cancelButton.setAttribute( + "style", + "padding: 6px 14px; font-size: 12px; border-radius: 6px; border: 1px solid #d0d7de; " + + "background: #fff; cursor: pointer;", + ); + cancelButton.addEventListener("click", () => document.body.removeChild(overlay)); + actions.appendChild(cancelButton); + + const saveButton = document.createElement("button"); + saveButton.type = "button"; + saveButton.textContent = "Save"; + saveButton.setAttribute( + "style", + "padding: 6px 14px; font-size: 12px; border-radius: 6px; border: none; " + + "background: #1f883d; color: #fff; cursor: pointer;", + ); + saveButton.addEventListener("click", () => { + void (async () => { + const cleaned = normalizeConfig(draft); + await saveConfig(cleaned); + currentConfig = cleaned; + document.body.removeChild(overlay); + checkLocation(true); + })(); + }); + actions.appendChild(saveButton); + + panel.appendChild(actions); + overlay.appendChild(panel); + document.body.appendChild(overlay); +} function renderJumpButton(context: JumpContext | null): void { - const existing = document.getElementById(BUTTON_ID) as HTMLAnchorElement | null; + const existing = document.getElementById(CONTAINER_ID); if (!context) { existing?.remove(); return; } - const href = buildGrafanaJumpUrl(context); - const label = labelForContext(context); + const applicable = applicableDashboards(currentConfig, context); - const anchorEl = existing ?? document.createElement("a"); - anchorEl.setAttribute("id", BUTTON_ID); - anchorEl.setAttribute("href", href); - anchorEl.setAttribute("target", "_blank"); - anchorEl.setAttribute( + const container = existing ?? document.createElement("div"); + container.id = CONTAINER_ID; + container.setAttribute( "style", - "position: fixed; bottom: 16px; right: 16px; z-index: 2147483647; " + - "background: #F55F0E; color: #fff; padding: 8px 12px; border-radius: 6px; " + - "font-size: 12px; font-weight: 600; text-decoration: none; " + - "box-shadow: 0 1px 4px rgba(0,0,0,0.3);", + "position: fixed; bottom: 16px; right: 16px; z-index: 2147483647;", ); - anchorEl.textContent = label; + container.innerHTML = ""; + + if (applicable.length === 0) { + const setupButton = document.createElement("button"); + setupButton.type = "button"; + setupButton.textContent = isConfigured(currentConfig) + ? "⚙️ No dashboard configured for this page" + : "⚙️ Set up Grafana jump"; + setupButton.setAttribute("style", SOLO_BUTTON_STYLE); + setupButton.addEventListener("click", (event) => { + event.stopPropagation(); + openConfigModal(); + }); + container.appendChild(setupButton); + } else { + const [primary, ...rest] = applicable; + const label = labelForContext(context); + + const jumpLink = document.createElement("a"); + jumpLink.setAttribute("href", buildJumpUrl(currentConfig.baseUrl, primary, context)); + jumpLink.setAttribute("target", "_blank"); + jumpLink.setAttribute("style", BUTTON_STYLE); + jumpLink.textContent = applicable.length > 1 ? `${label} (${primary.name}) ↗️` : `${label} ↗️`; + container.appendChild(jumpLink); + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.textContent = "▾"; + toggle.setAttribute("style", TOGGLE_STYLE); + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + const items = [ + ...applicable.map((dashboard) => ({ + label: `↗️ ${dashboard.name || dashboard.uid}`, + onClick: () => window.open(buildJumpUrl(currentConfig.baseUrl, dashboard, context), "_blank"), + })), + { label: "⚙️ Edit dashboards...", onClick: openConfigModal }, + ]; + openMenu(container, items); + }); + container.appendChild(toggle); + + // rest is intentionally unused beyond being included in `applicable` above; + // named for clarity when reading the destructure at a glance. + void rest; + } if (!existing) { - document.body.appendChild(anchorEl); + document.body.appendChild(container); } } let lastLocationKey: string | undefined; -function checkLocation(): void { +function checkLocation(force = false): void { const { pathname, search } = window.location; const locationKey = `${pathname}${search}`; - if (locationKey === lastLocationKey) return; + if (!force && locationKey === lastLocationKey) return; lastLocationKey = locationKey; renderJumpButton(resolveJumpContext(pathname, search)); } // Guarded so that requiring the compiled output under Node (see the test-only -// export hook below) never touches DOM/browser globals - `document` always +// export hook below) never touches DOM/GM/browser globals - `document` always // exists in the real userscript context, so this runs unconditionally there. if (typeof document !== "undefined") { - const routeChangeObserver = new MutationObserver(checkLocation); - routeChangeObserver.observe(document.body, { childList: true, subtree: true }); - checkLocation(); + void (async () => { + currentConfig = await loadConfig(); + + const routeChangeObserver = new MutationObserver(() => checkLocation()); + routeChangeObserver.observe(document.body, { childList: true, subtree: true }); + checkLocation(); + })(); } // --------------------------------------------------------------------------- @@ -319,9 +737,14 @@ if (typeof module !== "undefined" && module.exports) { parseWorkflowContext, resolveJumpContext, extractBranchFromQuery, + contextVarKey, + contextFilterValue, + applicableDashboards, buildDashboardUrl, - buildGrafanaJumpUrl, + buildJumpUrl, labelForContext, - GRAFANA_CONFIG, + defaultConfig, + normalizeConfig, + isConfigured, }; } diff --git a/packages/github-actions-grafana-jump/test/grafana-jump.test.js b/packages/github-actions-grafana-jump/test/grafana-jump.test.js index 99721d3..843800d 100644 --- a/packages/github-actions-grafana-jump/test/grafana-jump.test.js +++ b/packages/github-actions-grafana-jump/test/grafana-jump.test.js @@ -1,4 +1,4 @@ -// Unit tests for the pure parsing/URL-building logic in src/index.ts. +// Unit tests for the pure parsing/URL-building/config logic in src/index.ts. // // This file is plain Node CommonJS (not TypeScript) and requires the // already-built dist/index.js directly, since the userscript itself only @@ -16,10 +16,15 @@ const { parseWorkflowContext, resolveJumpContext, extractBranchFromQuery, + contextVarKey, + contextFilterValue, + applicableDashboards, buildDashboardUrl, - buildGrafanaJumpUrl, + buildJumpUrl, labelForContext, - GRAFANA_CONFIG, + defaultConfig, + normalizeConfig, + isConfigured, } = require("../dist/index.js"); test("parsePrContext matches the PR checks tab and other PR sub-tabs", () => { @@ -131,70 +136,186 @@ test("resolveJumpContext dispatches to the right parser for each supported URL s assert.equal(resolveJumpContext("/oura/some-repo/issues/1", ""), null); }); -test("buildDashboardUrl builds a var-prefixed, URL-encoded Grafana link", () => { - const url = buildDashboardUrl({ uid: "abc123", slug: "my-dashboard" }, { - branch: "feature/some branch", +test("contextVarKey maps each context kind to its DashboardVarNames field", () => { + assert.equal(contextVarKey("pr"), "prNumber"); + assert.equal(contextVarKey("branch"), "branch"); + assert.equal(contextVarKey("workflow"), "workflowName"); + assert.equal(contextVarKey("runner"), "runnerName"); +}); + +test("contextFilterValue extracts the raw filter value per context kind", () => { + assert.equal(contextFilterValue({ kind: "pr", org: "o", repo: "r", prNumber: "42" }), "42"); + assert.equal(contextFilterValue({ kind: "branch", org: "o", repo: "r", branch: "main" }), "main"); + assert.equal( + contextFilterValue({ kind: "workflow", org: "o", repo: "r", workflowFile: "ci.yml" }), + "ci.yml", + ); + assert.equal( + contextFilterValue({ kind: "runner", scope: "repo", org: "o", runnerId: "9" }), + "9", + ); +}); + +test("defaultConfig starts empty and unconfigured", () => { + const config = defaultConfig(); + assert.equal(config.baseUrl, ""); + assert.deepEqual(config.dashboards, []); + assert.equal(isConfigured(config), false); +}); + +test("isConfigured requires both a base URL and at least one dashboard", () => { + assert.equal(isConfigured({ baseUrl: "", dashboards: [] }), false); + assert.equal( + isConfigured({ baseUrl: "https://g.example.com", dashboards: [] }), + false, + ); + assert.equal( + isConfigured({ + baseUrl: "https://g.example.com", + dashboards: [{ name: "d", uid: "u", slug: "s", varNames: {} }], + }), + true, + ); +}); + +test("normalizeConfig drops malformed input and defaults to empty", () => { + assert.deepEqual(normalizeConfig(null), { baseUrl: "", dashboards: [] }); + assert.deepEqual(normalizeConfig(undefined), { baseUrl: "", dashboards: [] }); + assert.deepEqual(normalizeConfig("not an object"), { baseUrl: "", dashboards: [] }); + assert.deepEqual(normalizeConfig({}), { baseUrl: "", dashboards: [] }); +}); + +test("normalizeConfig trims strings, drops empty varNames, and drops dashboards with no uid", () => { + const result = normalizeConfig({ + baseUrl: " https://g.example.com ", + dashboards: [ + { + name: " My Dashboard ", + uid: " abc123 ", + slug: " my-dash ", + varNames: { branch: " br ", prNumber: "", workflowName: " ", runnerName: 5 }, + }, + { name: "no uid", uid: "", slug: "x", varNames: {} }, + "not an object", + null, + ], + }); + assert.deepEqual(result, { + baseUrl: "https://g.example.com", + dashboards: [ + { name: "My Dashboard", uid: "abc123", slug: "my-dash", varNames: { branch: "br" } }, + ], }); +}); + +test("applicableDashboards only returns dashboards with a varName for the context's field", () => { + const config = { + baseUrl: "https://g.example.com", + dashboards: [ + { name: "branch-only", uid: "u1", slug: "s1", varNames: { branch: "br" } }, + { name: "pr-and-branch", uid: "u2", slug: "s2", varNames: { branch: "br", prNumber: "pr" } }, + { name: "runner-only", uid: "u3", slug: "s3", varNames: { runnerName: "runner" } }, + ], + }; + + const branchContext = { kind: "branch", org: "o", repo: "r", branch: "main" }; + assert.deepEqual( + applicableDashboards(config, branchContext).map((d) => d.name), + ["branch-only", "pr-and-branch"], + ); + + const prContext = { kind: "pr", org: "o", repo: "r", prNumber: "1" }; + assert.deepEqual(applicableDashboards(config, prContext).map((d) => d.name), ["pr-and-branch"]); + + const runnerContext = { kind: "runner", scope: "repo", org: "o", runnerId: "1" }; + assert.deepEqual( + applicableDashboards(config, runnerContext).map((d) => d.name), + ["runner-only"], + ); + + const workflowContext = { kind: "workflow", org: "o", repo: "r", workflowFile: "ci.yml" }; + assert.deepEqual(applicableDashboards(config, workflowContext), []); +}); + +test("buildDashboardUrl builds a var-prefixed, URL-encoded Grafana link", () => { + const url = buildDashboardUrl( + "https://g.example.com", + { uid: "abc123", slug: "my-dashboard" }, + { branch: "feature/some branch" }, + ); assert.equal( url, - "https://monitoring.oura.cloud/d/abc123/my-dashboard?var-branch=feature%2Fsome%20branch", + "https://g.example.com/d/abc123/my-dashboard?var-branch=feature%2Fsome%20branch", ); }); test("buildDashboardUrl supports multiple variables and omits the query string when empty", () => { - const withVars = buildDashboardUrl({ uid: "abc123", slug: "my-dashboard" }, { - a: "1", - b: "2", - }); - assert.equal(withVars, "https://monitoring.oura.cloud/d/abc123/my-dashboard?var-a=1&var-b=2"); + const withVars = buildDashboardUrl( + "https://g.example.com", + { uid: "abc123", slug: "my-dashboard" }, + { a: "1", b: "2" }, + ); + assert.equal(withVars, "https://g.example.com/d/abc123/my-dashboard?var-a=1&var-b=2"); - const withoutVars = buildDashboardUrl({ uid: "abc123", slug: "my-dashboard" }, {}); - assert.equal(withoutVars, "https://monitoring.oura.cloud/d/abc123/my-dashboard"); + const withoutVars = buildDashboardUrl( + "https://g.example.com", + { uid: "abc123", slug: "my-dashboard" }, + {}, + ); + assert.equal(withoutVars, "https://g.example.com/d/abc123/my-dashboard"); }); -test("buildGrafanaJumpUrl routes each context kind to its documented dashboard", () => { - const prUrl = buildGrafanaJumpUrl({ kind: "pr", org: "oura", repo: "r", prNumber: "42" }); - assert.ok(prUrl.startsWith(`${GRAFANA_CONFIG.baseUrl}/d/${GRAFANA_CONFIG.dashboards.ciDevxReport.uid}/`)); - assert.ok(prUrl.includes(`var-${GRAFANA_CONFIG.varNames.prNumber}=42`)); +test("buildJumpUrl applies the dashboard's own varName for the context's field", () => { + const dashboard = { + name: "CI", + uid: "abc123", + slug: "ci-dashboard", + varNames: { branch: "branch_name", prNumber: "pr_num" }, + }; - const branchUrl = buildGrafanaJumpUrl({ kind: "branch", org: "oura", repo: "r", branch: "main" }); - assert.ok(branchUrl.startsWith(`${GRAFANA_CONFIG.baseUrl}/d/${GRAFANA_CONFIG.dashboards.ciDevxReport.uid}/`)); - assert.ok(branchUrl.includes(`var-${GRAFANA_CONFIG.varNames.branch}=main`)); + const branchUrl = buildJumpUrl("https://g.example.com", dashboard, { + kind: "branch", + org: "o", + repo: "r", + branch: "main", + }); + assert.equal(branchUrl, "https://g.example.com/d/abc123/ci-dashboard?var-branch_name=main"); - const workflowUrl = buildGrafanaJumpUrl({ - kind: "workflow", - org: "oura", + const prUrl = buildJumpUrl("https://g.example.com", dashboard, { + kind: "pr", + org: "o", repo: "r", - workflowFile: "ci.yml", + prNumber: "42", }); - assert.ok(workflowUrl.startsWith(`${GRAFANA_CONFIG.baseUrl}/d/${GRAFANA_CONFIG.dashboards.ciDevxReport.uid}/`)); - assert.ok(workflowUrl.includes(`var-${GRAFANA_CONFIG.varNames.workflowName}=ci.yml`)); + assert.equal(prUrl, "https://g.example.com/d/abc123/ci-dashboard?var-pr_num=42"); +}); - const runnerUrl = buildGrafanaJumpUrl({ +test("buildJumpUrl omits the var- filter entirely when the dashboard has no matching varName", () => { + const dashboard = { name: "CI", uid: "abc123", slug: "ci-dashboard", varNames: {} }; + const url = buildJumpUrl("https://g.example.com", dashboard, { kind: "runner", scope: "repo", - org: "oura", - runnerId: "17", + org: "o", + runnerId: "9", }); - assert.ok(runnerUrl.startsWith(`${GRAFANA_CONFIG.baseUrl}/d/${GRAFANA_CONFIG.dashboards.androidIosCi.uid}/`)); - assert.ok(runnerUrl.includes(`var-${GRAFANA_CONFIG.varNames.runnerName}=17`)); + assert.equal(url, "https://g.example.com/d/abc123/ci-dashboard"); }); test("labelForContext produces a distinct human-readable label per context kind", () => { assert.equal( labelForContext({ kind: "pr", org: "oura", repo: "r", prNumber: "42" }), - "Grafana: PR #42 CI ↗️", + "Grafana: PR #42 CI", ); assert.equal( labelForContext({ kind: "branch", org: "oura", repo: "r", branch: "main" }), - "Grafana: main CI ↗️", + "Grafana: main CI", ); assert.equal( labelForContext({ kind: "workflow", org: "oura", repo: "r", workflowFile: "ci.yml" }), - "Grafana: ci.yml runs ↗️", + "Grafana: ci.yml runs", ); assert.equal( labelForContext({ kind: "runner", scope: "org", org: "oura", runnerId: "9" }), - "Grafana: runner 9 ↗️", + "Grafana: runner 9", ); }); From d405338004d37938a804da6e5fd1fa228301968f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 22:36:08 +0000 Subject: [PATCH 2/2] fix(release): detect manual version bumps on internal-only packages in preview The version-preview report skipped github-actions-grafana-jump entirely because the opted_in gate ran before the manual-bump check, so this PR's own 0.1.0 -> 0.2.0 bump never showed up - it just said 'no package changes detected'. Detect the bump for every package now, matching the simpler per-package version diff ai-mktpl's _preview-version-bumps task does, and report internal-only packages as 'manual (internal)' rather than silently dropping them. They still don't enter has_bumps/bumps, since there's no release-it config or built script.user.js for the release job to publish. --- scripts/auto-bump-packages.sh | 54 ++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/scripts/auto-bump-packages.sh b/scripts/auto-bump-packages.sh index 2f8e6b9..7db76b1 100755 --- a/scripts/auto-bump-packages.sh +++ b/scripts/auto-bump-packages.sh @@ -16,9 +16,12 @@ # Both refs default to origin/main. On merge to main both are release/last-run; # on a PR use --change-base=origin/ --version-base= --preview. # -# Only packages opting in with `"greasyforkPublish": true` in their own -# package.json are considered - internal-only packages (e.g. -# github-actions-grafana-jump) are deliberately outside the release pipeline. +# A manual version bump is detected for every package, published or not, so +# it's always visible in the preview - e.g. bumping github-actions-grafana-jump +# by hand still shows up, it just doesn't trigger a release. Only packages +# opting in with `"greasyforkPublish": true` in their own package.json are +# auto-bumped from file changes or have their asset uploaded on release; +# internal-only packages are deliberately outside the rest of the pipeline. # # Requires: git, jq, node. In non-preview mode also requires release-it at the # repo root (node_modules/.bin/release-it), driven per package via that @@ -66,6 +69,7 @@ next_patch() { } BUMP_ENTRIES=() +HAS_ROWS=false REPORT_MD='| Package | Base | New | Action |\n|---|---|---|---|' HAS_BUMPS=false @@ -75,9 +79,10 @@ for pkg_dir in packages/*/; do pjson="${pkg_dir}package.json" [ -f "$pjson" ] || continue - # Only packages that opt in to public release. + # Whether this package is in the release pipeline. Computed up front but + # not gated on yet - the manual-bump check just below applies to every + # package so a hand-bumped internal package still shows up in the preview. opted_in="$(node -pe "require('./$pjson').greasyforkPublish === true" 2>/dev/null || echo false)" - [ "$opted_in" = "true" ] || continue base_version="$(git show "$VERSION_BASE:$pjson" 2>/dev/null \ | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null || echo '0.0.0')" @@ -91,22 +96,39 @@ for pkg_dir in packages/*/; do # someone bumped it by hand (for a minor or major release). Leave it alone. # We check this before looking at which files changed, further down - # otherwise a version bumped by hand with no other file changes would get - # missed, since that check ignores package.json. + # missed, since that check ignores package.json. Checked for every package + # regardless of opted_in, so this is where a manually-bumped internal + # package gets reported. if [ "$head_version" != "$base_version" ]; then higher="$(printf '%s\n%s\n' "$head_version" "$base_version" | sort -V | tail -1)" if [ "$higher" = "$head_version" ]; then echo "kept: $name already bumped $base_version -> $head_version" >&2 - # Still counts as releasable: the version moved since the base ref, so - # this package needs its asset published even though we didn't bump it. - HAS_BUMPS=true - REPORT_MD="$REPORT_MD\n| $name | $base_version | $head_version | already-bumped |" - BUMP_ENTRIES+=("$(jq -nc --arg n "$name" --arg p "$pjson" --arg b "$base_version" \ - --arg w "$head_version" --arg a already-bumped \ - '{name:$n,path:$p,base:$b,new:$w,action:$a}')") + if [ "$opted_in" = "true" ]; then + # Still counts as releasable: the version moved since the base ref, + # so this package needs its asset published even though we didn't + # bump it. + HAS_BUMPS=true + HAS_ROWS=true + REPORT_MD="$REPORT_MD\n| $name | $base_version | $head_version | already-bumped |" + BUMP_ENTRIES+=("$(jq -nc --arg n "$name" --arg p "$pjson" --arg b "$base_version" \ + --arg w "$head_version" --arg a already-bumped \ + '{name:$n,path:$p,base:$b,new:$w,action:$a}')") + else + # Not in the release pipeline, so it never goes into bumps/has_bumps - + # there's no .release-it.js or built script.user.js for the release + # job to publish. Reported for visibility only. + HAS_ROWS=true + REPORT_MD="$REPORT_MD\n| $name | $base_version | $head_version | manual (internal) |" + fi continue fi fi + # Everything below only applies to packages in the release pipeline - an + # internal-only package with no version bump (handled above) has nothing + # further to detect or report. + [ "$opted_in" = "true" ] || continue + # A package counts as "changed" if any of its files, other than # package.json and CHANGELOG.md, differ from the change base. We skip # those two files so that a previous version-bump commit doesn't trigger @@ -130,6 +152,7 @@ for pkg_dir in packages/*/; do action=auto-bumped fi HAS_BUMPS=true + HAS_ROWS=true REPORT_MD="$REPORT_MD\n| $name | $base_version | $new_version | $action |" BUMP_ENTRIES+=("$(jq -nc --arg n "$name" --arg p "$pjson" --arg b "$base_version" \ --arg w "$new_version" --arg a "$action" \ @@ -140,6 +163,11 @@ if [ "${#BUMP_ENTRIES[@]}" -gt 0 ]; then bumps="$(printf '%s\n' "${BUMP_ENTRIES[@]}" | jq -sc '.')" else bumps='[]' +fi + +# BUMP_ENTRIES alone would miss the manual-internal-bump rows added above, +# which are reported but deliberately excluded from bumps/has_bumps. +if [ "$HAS_ROWS" = false ]; then REPORT_MD="$REPORT_MD\n| _no package changes detected_ | | | |" fi