Last active
September 11, 2026 11:04
-
-
Save warrenseine/8cc9d91add1f5bceef888559a0df1ce0 to your computer and use it in GitHub Desktop.
Open Linear reviews in GitHub
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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