-
Notifications
You must be signed in to change notification settings - Fork 488
Add workflow to insert PR preview links #6133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ecfaf86
d845329
89d7370
e941f54
85ee429
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,256 @@ | ||
| import fs from 'fs/promises'; | ||
| import { pathToFileURL } from 'url'; | ||
|
|
||
| export const START_MARKER = '<!-- profiler-preview-links:start -->'; | ||
| export const END_MARKER = '<!-- profiler-preview-links:end -->'; | ||
|
|
||
| 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) { | ||
|
skylarkning marked this conversation as resolved.
|
||
| 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 }) { | ||
|
skylarkning marked this conversation as resolved.
|
||
| 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', | ||
| } | ||
| ); | ||
|
Comment on lines
+239
to
+246
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I think there's a potential race here. What if somebody else edits this comment in the window of time between when this script reads the old contents and writes the new contents? The intermediate edit would be overwritten. It doesn't look like github's API has a "only-overwrite-if-no-changes-have-happened-since-X" primitive. Can you do some research into how people usually deal with this issue? (Or maybe nobody cares?)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That makes sense! I will double check if there is anything that can be done here. Maybe it can detect the overwrite and edit back the links.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What outcome did you arrive at?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry Markus, I forgot to explain in comments, so I looked into this and I didn’t find a GitHub API that lets us do a conditional
I think this should avoid overwriting edits while the script is resolving issue/profile links. With that said though, I guess there still is a very small remaining race window between the final GET and PATCH, but I don’t think GitHub gives us a way to fully eliminate that from my understanding. Not sure if this is good enough.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good, thanks! |
||
|
|
||
| console.log(`Added preview links to pull request #${pullRequest.number}.`); | ||
| } | ||
|
|
||
| if ( | ||
| process.argv[1] && | ||
| import.meta.url === pathToFileURL(process.argv[1]).href | ||
| ) { | ||
| await main(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.` | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }} |
Uh oh!
There was an error while loading. Please reload this page.