Skip to content

Instantly share code, notes, and snippets.

@warrenseine
Last active September 11, 2026 11:04
Show Gist options
  • Select an option

  • Save warrenseine/8cc9d91add1f5bceef888559a0df1ce0 to your computer and use it in GitHub Desktop.

Select an option

Save warrenseine/8cc9d91add1f5bceef888559a0df1ce0 to your computer and use it in GitHub Desktop.
Open Linear reviews in GitHub
// ==UserScript==
// @name Open Linear reviews in GitHub
// @namespace http://tampermonkey.net/
// @version 2026-09-11
// @description Open Linear reviews in GitHub
// @author Warren Seine
// @match https://linear.app/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=linear.app
// @run-at document-idle
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Review pages live at /<workspace>/review/<slug>, with tabs below them.
const REVIEW = /^\/([^/]+)\/review\/([^/]+)/;
// The subheader's actions button names the pull request in full. It is there
// on every tab of the review, including the Diff, where nothing links out.
const ACTIONS = 'button[aria-label*="pull request actions"]';
const OWNER_REPO_NUMBER = /^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)#(\d+)/;
// Older review pages link out instead, e.g. https://github.com/owner/repo/pull/7958.
const PULL_REQUEST = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/;
// Such a link renders as repo#number. Links inside the description point at
// other pull requests, so the text shape tells them apart.
const SHORTHAND = /^[A-Za-z0-9._-]+#\d+$/;
// Append ?stay to a review URL to read it in Linear instead.
const STAY = 'stay';
// A fragment points at something inside the review — a comment, a thread. No
// shared identifier exists for those on the GitHub side, so a redirect would
// drop the reader at the top of the pull request rather than at what they
// clicked. Those links stay in Linear.
const ANCHORED = () => document.location.hash.length > 1;
// Linear drops the fragment once the comment is dealt with — resolved, or
// navigated away from — and the reader would then be redirected out of the
// page they deliberately opened. So the decision to stay is remembered for
// the review, for as long as the tab lives.
const REMEMBERED = 'linear-review-stay:';
// The page is a client-side app, so the link appears some time after the URL
// does. Give up rather than redirect a review that has been open for a while.
const TIMEOUT = 20000;
let review = null;
let since = 0;
function reviewKey() {
const parts = REVIEW.exec(document.location.pathname);
// The tab below the review (/changes and friends) is not part of identity:
// switching tabs must not forget the decision.
return parts ? `${REMEMBERED}${parts[1]}/${parts[2]}` : null;
}
function remember(key) {
// Storage is unavailable in some browser configurations; the fragment check
// still holds on its own there.
try {
sessionStorage.setItem(key, '1');
} catch (error) {
/* ignore */
}
}
function remembered(key) {
try {
return sessionStorage.getItem(key) !== null;
} catch (error) {
return false;
}
}
function pullRequestUrl() {
for (const button of document.querySelectorAll(ACTIONS)) {
const parts = OWNER_REPO_NUMBER.exec(button.getAttribute('aria-label'));
if (parts) return `https://github.com/${parts[1]}/${parts[2]}/pull/${parts[3]}`;
}
const candidates = [...document.querySelectorAll('a[href*="/pull/"]')]
.filter(a => PULL_REQUEST.test(a.href) && SHORTHAND.test(a.textContent.trim()));
if (!candidates.length) return null;
// Several links carry the shorthand; the topmost is the subheader, which
// always belongs to this review.
candidates.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
return candidates[0].href;
}
function redirect() {
const key = reviewKey();
if (!key) {
review = null;
return;
}
if (new URLSearchParams(document.location.search).has(STAY) || ANCHORED()) {
remember(key);
return;
}
if (remembered(key)) return;
if (review !== document.location.pathname) {
review = document.location.pathname;
since = Date.now();
}
if (Date.now() - since > TIMEOUT) return;
const url = pullRequestUrl();
// Replace rather than assign: the review drops out of the history, so going
// back lands on whatever came before it instead of redirecting again.
if (url) document.location.replace(url);
}
// Reviews are opened by client-side navigation as often as by page load, and
// the link renders late, so poll instead of running once.
setInterval(redirect, 300);
redirect();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment