diff --git a/.github/scripts/add-preview-links.mjs b/.github/scripts/add-preview-links.mjs new file mode 100644 index 0000000000..d67e59b105 --- /dev/null +++ b/.github/scripts/add-preview-links.mjs @@ -0,0 +1,256 @@ +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 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'; + +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)) { + // Profile links are often followed by punctuation in prose, for example + // "Profile: https://share.firefox.dev/466MJwC.". + 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; +} + +// 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); + + 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': GITHUB_API_VERSION, + ...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 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 ?? ''}`; + + 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); + + // 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}`, + 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..de5640178f --- /dev/null +++ b/.github/workflows/add-preview-links.yml @@ -0,0 +1,37 @@ +name: Add preview links + +on: + pull_request_target: + types: + - opened + - edited + - reopened + - ready_for_review + +permissions: + contents: read + 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 + 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 }}