Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions .github/scripts/add-preview-links.mjs
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(/[.,;:]+$/, ''));
Comment thread
skylarkning marked this conversation as resolved.
}

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) {
Comment thread
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 }) {
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What outcome did you arrive at?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 PATCH for this, like “only update the PR body if it has not changed since I read it”. So I added two manual protections:

  1. The workflow now uses a per-PR concurrency group, so multiple runs of this workflow for the same PR do not race each other.
  2. The script now re-fetches the latest PR body immediately before doing the PATCH, then inserts the preview links into that latest body.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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();
}
93 changes: 93 additions & 0 deletions .github/scripts/add-preview-links.test.mjs
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.`
);
});
37 changes: 37 additions & 0 deletions .github/workflows/add-preview-links.yml
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 }}
Loading