From ecfaf86fb87cb3fed12deeb918c7f8ce29a822fc Mon Sep 17 00:00:00 2001 From: Sky Ning Date: Mon, 29 Jun 2026 11:14:44 -0400 Subject: [PATCH 1/3] Add PR preview link workflow --- .github/scripts/add-preview-links.mjs | 228 +++++++++++++++++++++ .github/scripts/add-preview-links.test.mjs | 93 +++++++++ .github/workflows/add-preview-links.yml | 33 +++ 3 files changed, 354 insertions(+) create mode 100644 .github/scripts/add-preview-links.mjs create mode 100644 .github/scripts/add-preview-links.test.mjs create mode 100644 .github/workflows/add-preview-links.yml diff --git a/.github/scripts/add-preview-links.mjs b/.github/scripts/add-preview-links.mjs new file mode 100644 index 0000000000..9c6845042f --- /dev/null +++ b/.github/scripts/add-preview-links.mjs @@ -0,0 +1,228 @@ +import fs from 'fs/promises'; +import { pathToFileURL } from 'url'; + +export const START_MARKER = ''; +export const END_MARKER = ''; + +const GITHUB_API_URL = 'https://api.github.com'; +const MAIN_BASE_URL = 'https://main--perf-html.netlify.app'; +const PROFILE_HOST = 'profiler.firefox.com'; +const SHARE_HOST = 'share.firefox.dev'; + +export function hasDeployPreviewLink(body) { + return ( + body.includes(START_MARKER) || + /https:\/\/deploy-preview-\d+--perf-html\.netlify\.app(?:\/|\b)/.test(body) + ); +} + +export function extractIssueNumbers(text) { + const issueNumbers = new Set(); + const markdownIssueRegExp = /(?:^|[^\w/-])#(\d+)\b/g; + const issueUrlRegExp = + /https:\/\/github\.com\/firefox-devtools\/profiler\/issues\/(\d+)\b/g; + + for (const match of text.matchAll(markdownIssueRegExp)) { + issueNumbers.add(Number(match[1])); + } + + for (const match of text.matchAll(issueUrlRegExp)) { + issueNumbers.add(Number(match[1])); + } + + return [...issueNumbers]; +} + +export function extractProfileUrls(text) { + const urls = []; + const profileUrlRegExp = + /https:\/\/(?:share\.firefox\.dev|profiler\.firefox\.com)\/[^\s<>)\]]+/g; + + for (const match of text.matchAll(profileUrlRegExp)) { + urls.push(match[0].replace(/[.,;:]+$/, '')); + } + + return urls; +} + +export function profileUrlToPath(profileUrl) { + const url = new URL(profileUrl); + + if (url.hostname !== PROFILE_HOST) { + return null; + } + + const path = `${url.pathname}${url.search}${url.hash}`; + return path === '/' ? null : path; +} + +export async function resolveProfileUrlToPath(profileUrl, fetchImpl = fetch) { + const url = new URL(profileUrl); + + if (url.hostname === PROFILE_HOST) { + return profileUrlToPath(profileUrl); + } + + if (url.hostname !== SHARE_HOST) { + return null; + } + + const response = await fetchImpl(profileUrl, { redirect: 'follow' }); + return profileUrlToPath(response.url); +} + +export function normalizePath(path) { + if (!path || path === '/') { + return '/'; + } + + return path.startsWith('/') ? path : `/${path}`; +} + +export function buildPreviewLinks(prNumber, path) { + const normalizedPath = normalizePath(path); + const previewBaseUrl = `https://deploy-preview-${prNumber}--perf-html.netlify.app`; + + return `[Main](${MAIN_BASE_URL}${normalizedPath}) | [Deploy preview](${previewBaseUrl}${normalizedPath})`; +} + +export function addPreviewLinksToBody(body, previewLinks) { + const previewLinksBlock = `${START_MARKER}\n${previewLinks}\n${END_MARKER}`; + const trimmedBody = body.trim(); + + if (!trimmedBody) { + return previewLinksBlock; + } + + return `${previewLinksBlock}\n\n${trimmedBody}`; +} + +async function githubRequest(path, token, options = {}) { + const response = await fetch(`${GITHUB_API_URL}${path}`, { + ...options, + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'user-agent': 'firefox-profiler-preview-links', + 'x-github-api-version': '2022-11-28', + ...options.headers, + }, + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText}\n${message}` + ); + } + + if (response.status === 204) { + return null; + } + + return response.json(); +} + +async function getIssueTexts({ owner, repo, issueNumber, token }) { + const issue = await githubRequest( + `/repos/${owner}/${repo}/issues/${issueNumber}`, + token + ); + const comments = await githubRequest( + `/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=100`, + token + ); + + return [issue.body ?? '', ...comments.map((comment) => comment.body ?? '')]; +} + +async function findProfilePath({ owner, repo, pullRequest, token }) { + const pullRequestText = `${pullRequest.title ?? ''}\n${pullRequest.body ?? ''}`; + + for (const profileUrl of extractProfileUrls(pullRequestText)) { + const path = await resolveProfileUrlToPath(profileUrl); + + if (path) { + return path; + } + } + + for (const issueNumber of extractIssueNumbers(pullRequestText)) { + let issueTexts; + + try { + issueTexts = await getIssueTexts({ owner, repo, issueNumber, token }); + } catch (error) { + console.warn(`Could not read issue #${issueNumber}: ${error.message}`); + continue; + } + + for (const issueText of issueTexts) { + for (const profileUrl of extractProfileUrls(issueText)) { + const path = await resolveProfileUrlToPath(profileUrl); + + if (path) { + return path; + } + } + } + } + + return '/'; +} + +export async function main() { + const eventPath = process.env.GITHUB_EVENT_PATH; + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GITHUB_TOKEN; + + if (!eventPath || !repository || !token) { + throw new Error( + 'GITHUB_EVENT_PATH, GITHUB_REPOSITORY, and GITHUB_TOKEN are required.' + ); + } + + const payload = JSON.parse(await fs.readFile(eventPath, 'utf8')); + const pullRequest = payload.pull_request; + + if (!pullRequest) { + console.log('This event does not include a pull request. Nothing to do.'); + return; + } + + const [owner, repo] = repository.split('/'); + const body = pullRequest.body ?? ''; + + if (hasDeployPreviewLink(body)) { + console.log('The pull request already has deploy preview links.'); + return; + } + + const profilePath = await findProfilePath({ + owner, + repo, + pullRequest, + token, + }); + const previewLinks = buildPreviewLinks(pullRequest.number, profilePath); + const updatedBody = addPreviewLinksToBody(body, previewLinks); + + await githubRequest( + `/repos/${owner}/${repo}/pulls/${pullRequest.number}`, + token, + { + body: JSON.stringify({ body: updatedBody }), + method: 'PATCH', + } + ); + + console.log(`Added preview links to pull request #${pullRequest.number}.`); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + await main(); +} diff --git a/.github/scripts/add-preview-links.test.mjs b/.github/scripts/add-preview-links.test.mjs new file mode 100644 index 0000000000..57c7482f8e --- /dev/null +++ b/.github/scripts/add-preview-links.test.mjs @@ -0,0 +1,93 @@ +import assert from 'assert/strict'; +import { test } from 'node:test'; + +import { + END_MARKER, + START_MARKER, + addPreviewLinksToBody, + buildPreviewLinks, + extractIssueNumbers, + extractProfileUrls, + hasDeployPreviewLink, + normalizePath, + profileUrlToPath, + resolveProfileUrlToPath, +} from './add-preview-links.mjs'; + +test('hasDeployPreviewLink detects generated and manual preview links', () => { + assert.equal(hasDeployPreviewLink('No preview links here.'), false); + assert.equal( + hasDeployPreviewLink( + '[Deploy preview](https://deploy-preview-6083--perf-html.netlify.app/)' + ), + true + ); + assert.equal( + hasDeployPreviewLink(`${START_MARKER}\nlinks\n${END_MARKER}`), + true + ); +}); + +test('extractIssueNumbers finds markdown references and issue URLs', () => { + assert.deepEqual( + extractIssueNumbers( + 'Fixes #5598 and see https://github.com/firefox-devtools/profiler/issues/6083. Duplicate #5598.' + ), + [5598, 6083] + ); +}); + +test('extractProfileUrls finds profiler and share URLs', () => { + assert.deepEqual( + extractProfileUrls('Profile: https://share.firefox.dev/466MJwC.'), + ['https://share.firefox.dev/466MJwC'] + ); + assert.deepEqual( + extractProfileUrls( + '[Profile](https://profiler.firefox.com/public/abc/calltree/?thread=1&v=16)' + ), + ['https://profiler.firefox.com/public/abc/calltree/?thread=1&v=16'] + ); +}); + +test('profileUrlToPath keeps the profiler path, query, and hash', () => { + assert.equal( + profileUrlToPath( + 'https://profiler.firefox.com/public/abc/flame-graph/?thread=1&v=16#hash' + ), + '/public/abc/flame-graph/?thread=1&v=16#hash' + ); + assert.equal(profileUrlToPath('https://profiler.firefox.com/'), null); +}); + +test('resolveProfileUrlToPath follows share.firefox.dev redirects', async () => { + const fetchImpl = async () => ({ + url: 'https://profiler.firefox.com/public/abc/marker-table/?thread=0&v=16', + }); + + assert.equal( + await resolveProfileUrlToPath( + 'https://share.firefox.dev/466MJwC', + fetchImpl + ), + '/public/abc/marker-table/?thread=0&v=16' + ); +}); + +test('buildPreviewLinks uses the main branch and deploy preview hosts', () => { + assert.equal(normalizePath('public/abc'), '/public/abc'); + assert.equal( + buildPreviewLinks(6083, '/public/abc/marker-table/?thread=0&v=16'), + '[Main](https://main--perf-html.netlify.app/public/abc/marker-table/?thread=0&v=16) | [Deploy preview](https://deploy-preview-6083--perf-html.netlify.app/public/abc/marker-table/?thread=0&v=16)' + ); +}); + +test('addPreviewLinksToBody prepends a marked block', () => { + assert.equal( + addPreviewLinksToBody( + 'Fixes #5598.', + '[Main](main) | [Deploy preview](preview)' + ), + `${START_MARKER}\n[Main](main) | [Deploy preview](preview)\n${END_MARKER}\n\nFixes #5598.` + ); +}); diff --git a/.github/workflows/add-preview-links.yml b/.github/workflows/add-preview-links.yml new file mode 100644 index 0000000000..35917cb51e --- /dev/null +++ b/.github/workflows/add-preview-links.yml @@ -0,0 +1,33 @@ +name: Add preview links + +on: + pull_request_target: + types: + - opened + - edited + - reopened + - ready_for_review + +permissions: + contents: read + issues: read + pull-requests: write + +jobs: + add-preview-links: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Add preview links + run: node .github/scripts/add-preview-links.mjs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From d845329e4a2b79b56eb55b65f81ceca41d1502ab Mon Sep 17 00:00:00 2001 From: Sky Ning Date: Tue, 30 Jun 2026 14:26:59 -0400 Subject: [PATCH 2/3] Address preview links workflow review comments --- .github/scripts/add-preview-links.mjs | 30 +++++++++++++++++++++++-- .github/workflows/add-preview-links.yml | 4 ++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/scripts/add-preview-links.mjs b/.github/scripts/add-preview-links.mjs index 9c6845042f..71ecf702c5 100644 --- a/.github/scripts/add-preview-links.mjs +++ b/.github/scripts/add-preview-links.mjs @@ -5,6 +5,7 @@ export const START_MARKER = ''; export const END_MARKER = ''; const GITHUB_API_URL = 'https://api.github.com'; +const GITHUB_API_VERSION = '2026-03-10'; const MAIN_BASE_URL = 'https://main--perf-html.netlify.app'; const PROFILE_HOST = 'profiler.firefox.com'; const SHARE_HOST = 'share.firefox.dev'; @@ -39,6 +40,8 @@ export function extractProfileUrls(text) { /https:\/\/(?:share\.firefox\.dev|profiler\.firefox\.com)\/[^\s<>)\]]+/g; for (const match of text.matchAll(profileUrlRegExp)) { + // Profile links are often followed by punctuation in prose, for example + // "Profile: https://share.firefox.dev/466MJwC.". urls.push(match[0].replace(/[.,;:]+$/, '')); } @@ -67,6 +70,7 @@ export async function resolveProfileUrlToPath(profileUrl, fetchImpl = fetch) { return null; } + // Follow share.firefox.dev redirects to get the full profiler.firefox.com URL. const response = await fetchImpl(profileUrl, { redirect: 'follow' }); return profileUrlToPath(response.url); } @@ -105,7 +109,7 @@ async function githubRequest(path, token, options = {}) { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'user-agent': 'firefox-profiler-preview-links', - 'x-github-api-version': '2022-11-28', + 'x-github-api-version': GITHUB_API_VERSION, ...options.headers, }, }); @@ -137,6 +141,10 @@ async function getIssueTexts({ owner, repo, issueNumber, token }) { return [issue.body ?? '', ...comments.map((comment) => comment.body ?? '')]; } +async function getPullRequest({ owner, repo, pullNumber, token }) { + return githubRequest(`/repos/${owner}/${repo}/pulls/${pullNumber}`, token); +} + async function findProfilePath({ owner, repo, pullRequest, token }) { const pullRequestText = `${pullRequest.title ?? ''}\n${pullRequest.body ?? ''}`; @@ -169,6 +177,7 @@ async function findProfilePath({ owner, repo, pullRequest, token }) { } } + // No profile link was found, so the generated links should point to homepage. return '/'; } @@ -206,7 +215,24 @@ export async function main() { token, }); const previewLinks = buildPreviewLinks(pullRequest.number, profilePath); - const updatedBody = addPreviewLinksToBody(body, previewLinks); + + // Re-read the PR body just before patching it. GitHub's API does not support + // conditional PATCH requests here, so this is a best-effort guard against + // overwriting edits that happened while this script looked up the profile URL. + const latestPullRequest = await getPullRequest({ + owner, + repo, + pullNumber: pullRequest.number, + token, + }); + const latestBody = latestPullRequest.body ?? ''; + + if (hasDeployPreviewLink(latestBody)) { + console.log('The pull request already has deploy preview links.'); + return; + } + + const updatedBody = addPreviewLinksToBody(latestBody, previewLinks); await githubRequest( `/repos/${owner}/${repo}/pulls/${pullRequest.number}`, diff --git a/.github/workflows/add-preview-links.yml b/.github/workflows/add-preview-links.yml index 35917cb51e..de5640178f 100644 --- a/.github/workflows/add-preview-links.yml +++ b/.github/workflows/add-preview-links.yml @@ -13,6 +13,10 @@ permissions: issues: read pull-requests: write +concurrency: + group: add-preview-links-${{ github.event.pull_request.number }} + cancel-in-progress: false + jobs: add-preview-links: runs-on: ubuntu-latest From e941f54b7daf214052318e0dfe954431e6de1b27 Mon Sep 17 00:00:00 2001 From: Sky Ning Date: Thu, 2 Jul 2026 16:58:50 -0400 Subject: [PATCH 3/3] Move preview link comments above functions --- .github/scripts/add-preview-links.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/scripts/add-preview-links.mjs b/.github/scripts/add-preview-links.mjs index 71ecf702c5..d67e59b105 100644 --- a/.github/scripts/add-preview-links.mjs +++ b/.github/scripts/add-preview-links.mjs @@ -59,6 +59,8 @@ export function profileUrlToPath(profileUrl) { return path === '/' ? null : path; } +// Returns the path for a profiler.firefox.com URL, and follows share.firefox.dev +// redirects to resolve short links to their full profiler.firefox.com URL. export async function resolveProfileUrlToPath(profileUrl, fetchImpl = fetch) { const url = new URL(profileUrl); @@ -70,7 +72,6 @@ export async function resolveProfileUrlToPath(profileUrl, fetchImpl = fetch) { return null; } - // Follow share.firefox.dev redirects to get the full profiler.firefox.com URL. const response = await fetchImpl(profileUrl, { redirect: 'follow' }); return profileUrlToPath(response.url); } @@ -145,6 +146,8 @@ async function getPullRequest({ owner, repo, pullNumber, token }) { return githubRequest(`/repos/${owner}/${repo}/pulls/${pullNumber}`, token); } +// Finds the first profile path mentioned in the PR or its linked issues. +// Returns '/' if no profile link is found, so generated links use the homepage. async function findProfilePath({ owner, repo, pullRequest, token }) { const pullRequestText = `${pullRequest.title ?? ''}\n${pullRequest.body ?? ''}`; @@ -177,7 +180,6 @@ async function findProfilePath({ owner, repo, pullRequest, token }) { } } - // No profile link was found, so the generated links should point to homepage. return '/'; }