From 4d63c419825d8c6b46f691a13442b6829eb87ec6 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Mon, 20 Jul 2026 15:19:44 -0400 Subject: [PATCH 1/2] feat(github-actions-grafana-jump): add GitHub Actions to Grafana jump-button userscript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new package that shows a fixed-position "Grafana ↗️" button on github.com Actions pages, covering the three ADX-525 contexts: a PR's workflow runs, a single workflow's runs across branches, and a self-hosted runner's detail page (repo- or org-scoped). Uses a floating button rather than anchoring inline to a page toolbar, since GitHub's Actions/PR toolbars are styled with hashed Primer React CSS-module class names (confirmed by inspecting the live DOM) that aren't safe to hardcode as selectors. The Grafana base URL and dashboard UIDs are real, sourced from existing internal docs cataloging the target Grafana folder. The per-dashboard template-variable names are explicitly flagged as unconfirmed placeholders pending verification against the live dashboards. --- .../github-actions-grafana-jump/package.json | 15 + .../github-actions-grafana-jump/src/index.ts | 327 ++++++++++++++++++ .../github-actions-grafana-jump/tsconfig.json | 9 + yarn.lock | 9 + 4 files changed, 360 insertions(+) create mode 100644 packages/github-actions-grafana-jump/package.json create mode 100644 packages/github-actions-grafana-jump/src/index.ts create mode 100644 packages/github-actions-grafana-jump/tsconfig.json diff --git a/packages/github-actions-grafana-jump/package.json b/packages/github-actions-grafana-jump/package.json new file mode 100644 index 0000000..b8d3a9b --- /dev/null +++ b/packages/github-actions-grafana-jump/package.json @@ -0,0 +1,15 @@ +{ + "name": "@nsheaps/gm-github-actions-grafana-jump", + "version": "0.1.0", + "main": "dist/index.js", + "private": true, + "scripts": { + "build": "tsc --build", + "lint": "oxlint", + "test": "tsc --build && node --test test/*.test.js" + }, + "devDependencies": { + "@types/greasemonkey": "~4.0.7", + "typescript": "~5.8.3" + } +} diff --git a/packages/github-actions-grafana-jump/src/index.ts b/packages/github-actions-grafana-jump/src/index.ts new file mode 100644 index 0000000..bceac06 --- /dev/null +++ b/packages/github-actions-grafana-jump/src/index.ts @@ -0,0 +1,327 @@ +// ==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 +// @match http*://www.github.com/* +// @match http*://github.com/* +// @version 0.1.0 +// @run-at document-start +// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== +// @grant none +// @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. + +/** + * 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. + */ +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", + }, +}; + +// --------------------------------------------------------------------------- +// 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). +// --------------------------------------------------------------------------- + +interface PrContext { + kind: "pr"; + org: string; + repo: string; + prNumber: string; +} + +interface BranchContext { + kind: "branch"; + org: string; + repo: string; + branch: string; +} + +interface RunnerContext { + kind: "runner"; + scope: "repo" | "org"; + org: string; + runnerId: string; +} + +interface WorkflowContext { + kind: "workflow"; + org: string; + repo: string; + workflowFile: string; +} + +type JumpContext = PrContext | BranchContext | RunnerContext | WorkflowContext; + +/** + * Matches a pull request's own pages (Conversation/Commits/Checks/Files changed), + * e.g. `/org/repo/pull/123` or `/org/repo/pull/123/checks`. Any sub-tab counts: + * they all show CI activity for the same PR/branch, which is what the Grafana + * dashboard filters by. + */ +function parsePrContext(pathname: string): PrContext | null { + const match = pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/.*)?$/); + if (!match) return null; + const [, org, repo, prNumber] = match; + return { kind: "pr", org, repo, prNumber }; +} + +/** + * Extracts a `branch:` filter out of a GitHub Actions search query string, + * e.g. `is:success branch:main` or `branch:"feature/some branch"`. Returns null if + * no branch filter is present. + */ +function extractBranchFromQuery(query: string): string | null { + const quotedMatch = query.match(/branch:"([^"]*)"/); + if (quotedMatch) return quotedMatch[1]; + + const bareMatch = query.match(/branch:(\S+)/); + return bareMatch ? bareMatch[1] : null; +} + +/** + * Matches the repo Actions tab filtered down to a single branch via + * `?query=branch:`, e.g. `/org/repo/actions?query=branch:my-feature`. + */ +function parseBranchContext(pathname: string, search: string): BranchContext | null { + const pathMatch = pathname.match(/^\/([^/]+)\/([^/]+)\/actions\/?$/); + if (!pathMatch) return null; + + const params = new URLSearchParams(search); + const query = params.get("query"); + if (!query) return null; + + const branch = extractBranchFromQuery(query); + if (!branch) return null; + + const [, org, repo] = pathMatch; + return { kind: "branch", org, repo, branch }; +} + +/** + * Matches a self-hosted runner's detail page, at either the repo scope + * (`/org/repo/settings/actions/runners/`) or the org scope + * (`/organizations//settings/actions/runners/`). + */ +function parseRunnerContext(pathname: string): RunnerContext | null { + // Checked first: "/organizations//settings/..." would otherwise also + // satisfy the repo-scope pattern below (with "organizations" mistaken for an + // org name), since both just look like "///settings/...". + const orgMatch = pathname.match(/^\/organizations\/([^/]+)\/settings\/actions\/runners\/(\d+)/); + if (orgMatch) { + const [, org, runnerId] = orgMatch; + return { kind: "runner", scope: "org", org, runnerId }; + } + + const repoMatch = pathname.match(/^\/([^/]+)\/[^/]+\/settings\/actions\/runners\/(\d+)/); + if (repoMatch) { + const [, org, runnerId] = repoMatch; + return { kind: "runner", scope: "repo", org, runnerId }; + } + + return null; +} + +/** + * Matches a single workflow's own page, showing its runs across all branches, + * e.g. `/org/repo/actions/workflows/ci.yml`. + */ +function parseWorkflowContext(pathname: string): WorkflowContext | null { + const match = pathname.match(/^\/([^/]+)\/([^/]+)\/actions\/workflows\/([^/?#]+)/); + if (!match) return null; + const [, org, repo, workflowFile] = match; + return { kind: "workflow", org, repo, workflowFile }; +} + +/** + * Resolves the current location into whichever jump context applies (PR/branch, + * runner, or workflow-across-branches), or null if none match. Order doesn't + * matter for correctness here since the four path shapes are mutually + * exclusive, but runner and workflow paths are checked first since they're the + * most specific. + */ +function resolveJumpContext(pathname: string, search: string): JumpContext | null { + return ( + parseRunnerContext(pathname) ?? + parseWorkflowContext(pathname) ?? + parsePrContext(pathname) ?? + parseBranchContext(pathname, search) + ); +} + +/** + * Builds a Grafana dashboard URL with one or more template variables preset via + * the `var-=` query convention. + */ +function buildDashboardUrl( + dashboard: { uid: string; slug: string }, + vars: Record, +): string { + const params = Object.entries(vars) + .map(([name, value]) => `var-${encodeURIComponent(name)}=${encodeURIComponent(value)}`) + .join("&"); + const query = params ? `?${params}` : ""; + return `${GRAFANA_CONFIG.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, + }); + } +} + +/** 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 ↗️`; + case "branch": + return `Grafana: ${context.branch} CI ↗️`; + case "workflow": + return `Grafana: ${context.workflowFile} runs ↗️`; + case "runner": + return `Grafana: runner ${context.runnerId} ↗️`; + } +} + +// --------------------------------------------------------------------------- +// DOM injection. +// +// GitHub's Actions/PR pages are a pjax/React SPA, and (confirmed by inspecting +// the live DOM while building this script) the header/toolbar elements are +// styled with Primer React's hashed CSS-module class names (e.g. +// "prc-TabNav-TabNavTabList-Ave63"), which are not stable across GitHub +// front-end deploys and unsafe to hardcode as selectors. Rather than anchor to +// one of those per-page toolbars, this script shows a single fixed-position +// button that appears whenever the current URL matches a supported context and +// disappears otherwise - this only depends on `location`, not on any specific +// GitHub toolbar DOM shape, so it degrades gracefully (no button, no navigation +// left to fix) if GitHub reshuffles the page layout again. +// --------------------------------------------------------------------------- + +const BUTTON_ID = "grafanaJumpButton"; + +function renderJumpButton(context: JumpContext | null): void { + const existing = document.getElementById(BUTTON_ID) as HTMLAnchorElement | null; + + if (!context) { + existing?.remove(); + return; + } + + const href = buildGrafanaJumpUrl(context); + const label = labelForContext(context); + + const anchorEl = existing ?? document.createElement("a"); + anchorEl.setAttribute("id", BUTTON_ID); + anchorEl.setAttribute("href", href); + anchorEl.setAttribute("target", "_blank"); + anchorEl.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);", + ); + anchorEl.textContent = label; + + if (!existing) { + document.body.appendChild(anchorEl); + } +} + +let lastLocationKey: string | undefined; + +function checkLocation(): void { + const { pathname, search } = window.location; + const locationKey = `${pathname}${search}`; + if (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 +// 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(); +} + +// --------------------------------------------------------------------------- +// Test-only export hook. `module` is a variable Node's CommonJS loader injects +// into every required file's scope (e.g. when test/grafana-jump.test.js +// `require()`s the compiled dist/index.js) - it does not exist in a browser +// script context, so `typeof module !== "undefined"` is false there and this +// is a no-op, never risking a ReferenceError on github.com. The `NodeModule` +// type for `module` itself comes from @types/node, already pulled in +// transitively via @types/greasemonkey; no explicit `declare` needed here. +// --------------------------------------------------------------------------- +if (typeof module !== "undefined" && module.exports) { + module.exports = { + parsePrContext, + parseBranchContext, + parseRunnerContext, + parseWorkflowContext, + resolveJumpContext, + extractBranchFromQuery, + buildDashboardUrl, + buildGrafanaJumpUrl, + labelForContext, + GRAFANA_CONFIG, + }; +} diff --git a/packages/github-actions-grafana-jump/tsconfig.json b/packages/github-actions-grafana-jump/tsconfig.json new file mode 100644 index 0000000..b813d42 --- /dev/null +++ b/packages/github-actions-grafana-jump/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/yarn.lock b/yarn.lock index 1498663..42a356d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -365,6 +365,15 @@ __metadata: languageName: node linkType: hard +"@nsheaps/gm-github-actions-grafana-jump@workspace:packages/github-actions-grafana-jump": + version: 0.0.0-use.local + resolution: "@nsheaps/gm-github-actions-grafana-jump@workspace:packages/github-actions-grafana-jump" + dependencies: + "@types/greasemonkey": "npm:~4.0.7" + typescript: "npm:~5.8.3" + languageName: unknown + linkType: soft + "@nsheaps/gm-github-to-graphite-button@workspace:packages/github-to-graphite-button": version: 0.0.0-use.local resolution: "@nsheaps/gm-github-to-graphite-button@workspace:packages/github-to-graphite-button" From 863204bc4c0ba699ed60841c5c76a3e3b0441242 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Mon, 20 Jul 2026 15:20:05 -0400 Subject: [PATCH 2/2] test(github-actions-grafana-jump): add unit tests for context parsing and URL building Covers the pure logic extracted in src/index.ts: parsing a PR, branch, runner, or workflow context out of location.pathname/search, and building the resulting Grafana dashboard URL (including the var-* query-param encoding). Run with `node:test` via the package's own `test` script (`tsc --build && node --test test/*.test.js`) - no new dependencies. --- .../test/grafana-jump.test.js | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 packages/github-actions-grafana-jump/test/grafana-jump.test.js diff --git a/packages/github-actions-grafana-jump/test/grafana-jump.test.js b/packages/github-actions-grafana-jump/test/grafana-jump.test.js new file mode 100644 index 0000000..99721d3 --- /dev/null +++ b/packages/github-actions-grafana-jump/test/grafana-jump.test.js @@ -0,0 +1,200 @@ +// Unit tests for the pure parsing/URL-building 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 +// exposes those functions via a Node-only `module.exports` guard (see the +// bottom of src/index.ts) - it is never loaded as an ES module in the browser. +// Run `yarn build` (or `tsc --build`) before `node --test test/` if dist/ is +// stale; the package's own "test" script does this for you. +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + parsePrContext, + parseBranchContext, + parseRunnerContext, + parseWorkflowContext, + resolveJumpContext, + extractBranchFromQuery, + buildDashboardUrl, + buildGrafanaJumpUrl, + labelForContext, + GRAFANA_CONFIG, +} = require("../dist/index.js"); + +test("parsePrContext matches the PR checks tab and other PR sub-tabs", () => { + assert.deepEqual(parsePrContext("/oura/some-repo/pull/42/checks"), { + kind: "pr", + org: "oura", + repo: "some-repo", + prNumber: "42", + }); + assert.deepEqual(parsePrContext("/oura/some-repo/pull/42"), { + kind: "pr", + org: "oura", + repo: "some-repo", + prNumber: "42", + }); + assert.deepEqual(parsePrContext("/oura/some-repo/pull/42/files"), { + kind: "pr", + org: "oura", + repo: "some-repo", + prNumber: "42", + }); +}); + +test("parsePrContext does not match non-PR paths", () => { + assert.equal(parsePrContext("/oura/some-repo/pulls"), null); + assert.equal(parsePrContext("/oura/some-repo/issues/42"), null); + assert.equal(parsePrContext("/oura/some-repo/actions"), null); +}); + +test("extractBranchFromQuery reads bare and quoted branch filters", () => { + assert.equal(extractBranchFromQuery("branch:main"), "main"); + assert.equal(extractBranchFromQuery("is:success branch:main"), "main"); + assert.equal(extractBranchFromQuery('branch:"feature/some branch"'), "feature/some branch"); + assert.equal(extractBranchFromQuery("is:success"), null); +}); + +test("parseBranchContext matches the repo Actions tab filtered by branch", () => { + assert.deepEqual( + parseBranchContext("/oura/some-repo/actions", "?query=branch%3Amy-feature"), + { kind: "branch", org: "oura", repo: "some-repo", branch: "my-feature" }, + ); + assert.deepEqual( + parseBranchContext("/oura/some-repo/actions/", "?query=is%3Asuccess+branch%3Amain"), + { kind: "branch", org: "oura", repo: "some-repo", branch: "main" }, + ); +}); + +test("parseBranchContext returns null without a branch filter or off the bare Actions tab", () => { + assert.equal(parseBranchContext("/oura/some-repo/actions", ""), null); + assert.equal(parseBranchContext("/oura/some-repo/actions", "?query=is%3Asuccess"), null); + assert.equal( + parseBranchContext("/oura/some-repo/actions/workflows/ci.yml", "?query=branch%3Amain"), + null, + ); +}); + +test("parseRunnerContext matches repo-scoped and org-scoped runner pages", () => { + assert.deepEqual(parseRunnerContext("/oura/some-repo/settings/actions/runners/17"), { + kind: "runner", + scope: "repo", + org: "oura", + runnerId: "17", + }); + assert.deepEqual( + parseRunnerContext("/organizations/oura/settings/actions/runners/17"), + { kind: "runner", scope: "org", org: "oura", runnerId: "17" }, + ); +}); + +test("parseRunnerContext returns null off a runner detail page", () => { + assert.equal(parseRunnerContext("/oura/some-repo/settings/actions"), null); + assert.equal(parseRunnerContext("/organizations/oura/settings/actions/runner-groups/1"), null); +}); + +test("parseWorkflowContext matches a workflow's own page", () => { + assert.deepEqual(parseWorkflowContext("/oura/some-repo/actions/workflows/ci.yml"), { + kind: "workflow", + org: "oura", + repo: "some-repo", + workflowFile: "ci.yml", + }); + assert.deepEqual(parseWorkflowContext("/oura/some-repo/actions/workflows/123456"), { + kind: "workflow", + org: "oura", + repo: "some-repo", + workflowFile: "123456", + }); +}); + +test("parseWorkflowContext returns null off a workflow page", () => { + assert.equal(parseWorkflowContext("/oura/some-repo/actions"), null); +}); + +test("resolveJumpContext dispatches to the right parser for each supported URL shape", () => { + assert.deepEqual(resolveJumpContext("/oura/some-repo/pull/42", ""), { + kind: "pr", + org: "oura", + repo: "some-repo", + prNumber: "42", + }); + assert.deepEqual( + resolveJumpContext("/oura/some-repo/actions/workflows/ci.yml", ""), + { kind: "workflow", org: "oura", repo: "some-repo", workflowFile: "ci.yml" }, + ); + assert.deepEqual( + resolveJumpContext("/organizations/oura/settings/actions/runners/9", ""), + { kind: "runner", scope: "org", org: "oura", runnerId: "9" }, + ); + 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", + }); + assert.equal( + url, + "https://monitoring.oura.cloud/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 withoutVars = buildDashboardUrl({ uid: "abc123", slug: "my-dashboard" }, {}); + assert.equal(withoutVars, "https://monitoring.oura.cloud/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`)); + + 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 workflowUrl = buildGrafanaJumpUrl({ + kind: "workflow", + org: "oura", + repo: "r", + workflowFile: "ci.yml", + }); + assert.ok(workflowUrl.startsWith(`${GRAFANA_CONFIG.baseUrl}/d/${GRAFANA_CONFIG.dashboards.ciDevxReport.uid}/`)); + assert.ok(workflowUrl.includes(`var-${GRAFANA_CONFIG.varNames.workflowName}=ci.yml`)); + + const runnerUrl = buildGrafanaJumpUrl({ + kind: "runner", + scope: "repo", + org: "oura", + runnerId: "17", + }); + assert.ok(runnerUrl.startsWith(`${GRAFANA_CONFIG.baseUrl}/d/${GRAFANA_CONFIG.dashboards.androidIosCi.uid}/`)); + assert.ok(runnerUrl.includes(`var-${GRAFANA_CONFIG.varNames.runnerName}=17`)); +}); + +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 ↗️", + ); + assert.equal( + labelForContext({ kind: "branch", org: "oura", repo: "r", branch: "main" }), + "Grafana: main CI ↗️", + ); + assert.equal( + labelForContext({ kind: "workflow", org: "oura", repo: "r", workflowFile: "ci.yml" }), + "Grafana: ci.yml runs ↗️", + ); + assert.equal( + labelForContext({ kind: "runner", scope: "org", org: "oura", runnerId: "9" }), + "Grafana: runner 9 ↗️", + ); +});