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/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 ↗️", + ); +}); 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"