Created
October 15, 2026 07:20
-
-
Save warrenseine/6e9d82d593137ae49a0681fa05a4ebb9 to your computer and use it in GitHub Desktop.
Open GitHub pull requests in Linear
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 GitHub pull requests in Linear | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2026-10-15 | |
| // @description Open GitHub pull requests in Linear | |
| // @author Warren Seine | |
| // @match https://github.com/* | |
| // @match https://linear.app/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // Only these organisations review in Linear. Pull requests anywhere else stay | |
| // on GitHub. | |
| const OWNERS = new Set(['heypearl-ai']); | |
| // The conversation and the diff, e.g. /owner/repo/pull/123 or …/123/changes. | |
| // Commits and checks have no Linear counterpart, so those tabs stay on GitHub. | |
| const PULL_REQUEST = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/(?:changes|files))?\/?$/; | |
| // Append ?stay to a pull request URL to read it on GitHub instead. | |
| const STAY = 'stay'; | |
| function reviewedInLinear(url) { | |
| const parts = PULL_REQUEST.exec(url.pathname); | |
| return url.origin === 'https://github.com' && parts && OWNERS.has(parts[1].toLowerCase()) ? parts : null; | |
| } | |
| if (document.location.origin === 'https://linear.app') { | |
| /* | |
| * Linear links out to GitHub for what it does not show itself, through | |
| * noreferrer links the GitHub side cannot recognise. Mark those links so | |
| * following one does not bounce straight back to Linear. | |
| */ | |
| function markStay(href) { | |
| try { | |
| const url = new URL(href, document.location.href); | |
| if (!reviewedInLinear(url) || url.searchParams.has(STAY)) return null; | |
| url.search += `${url.search ? '&' : '?'}${STAY}`; | |
| return url.href; | |
| } catch (error) { | |
| return null; | |
| } | |
| } | |
| // contextmenu runs before the browser menu reads the link, so "Open link in | |
| // new tab" picks up the mark too. | |
| for (const type of ['click', 'auxclick', 'contextmenu']) { | |
| document.addEventListener(type, event => { | |
| const link = event.target.closest && event.target.closest('a[href]'); | |
| const href = link && markStay(link.href); | |
| if (href) link.href = href; | |
| }, true); | |
| } | |
| // Keyboard shortcuts click a link that is never attached to the page, so | |
| // no event reaches the document. | |
| const click = HTMLElement.prototype.click; | |
| HTMLAnchorElement.prototype.click = function() { | |
| const href = markStay(this.href); | |
| if (href) this.href = href; | |
| return click.call(this); | |
| }; | |
| return; | |
| } | |
| // A fragment points at something inside the pull request: a comment, a | |
| // thread, a line. Linear has no matching anchor, so the reader would land at | |
| // the top of the review rather than at what they clicked. | |
| const ANCHORED = () => document.location.hash.length > 1; | |
| // GitHub drops the query and the fragment as the reader moves around, and the | |
| // reader would then be redirected out of the page they deliberately opened. | |
| // So the decision to stay is remembered for the pull request, for as long as | |
| // the tab lives. | |
| const REMEMBERED = 'github-pull-request-stay:'; | |
| let last = null; | |
| function remember(key) { | |
| // Storage is unavailable in some browser configurations; the query and | |
| // fragment checks still hold on their own there. | |
| try { | |
| sessionStorage.setItem(key, '1'); | |
| } catch (error) { | |
| /* ignore */ | |
| } | |
| } | |
| function remembered(key) { | |
| try { | |
| return sessionStorage.getItem(key) !== null; | |
| } catch (error) { | |
| return false; | |
| } | |
| } | |
| function redirect() { | |
| const href = document.location.href; | |
| if (href === last) return; | |
| last = href; | |
| const parts = reviewedInLinear(document.location); | |
| if (!parts) return; | |
| const [, owner, repo, number] = parts; | |
| const key = `${REMEMBERED}${owner}/${repo}/${number}`.toLowerCase(); | |
| if (new URLSearchParams(document.location.search).has(STAY) || ANCHORED()) { | |
| remember(key); | |
| return; | |
| } | |
| if (remembered(key)) return; | |
| // Linear resolves a review from the GitHub path and redirects to its own | |
| // URL. Replace rather than assign: the pull request drops out of the | |
| // history, so going back does not redirect again. | |
| document.location.replace(`https://linear.app/review/${owner}/${repo}/pull/${number}`); | |
| } | |
| // GitHub opens pull requests by client-side navigation as often as by page | |
| // load, so watch the URL instead of checking it once. | |
| setInterval(redirect, 300); | |
| redirect(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment