Last active
October 2, 2026 11:10
-
-
Save mRoca/112a0e6cb58b254b0c7da45ed4d1dac7 to your computer and use it in GitHub Desktop.
Browser script displaying the list of PRs I have to approve in a repository
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
| // Source: https://gist.github.com/mRoca/112a0e6cb58b254b0c7da45ed4d1dac7 | |
| // Push every edit made to a local copy back to that gist: it is the copy everyone installs from. | |
| // gh gist edit 112a0e6cb58b254b0c7da45ed4d1dac7 --filename github-prs-to-approve.js <local-copy> | |
| // | |
| // Changelog, newest first: add one line per version you push. The gist history starts at 8.20.0. | |
| // 8.36.1 Leaves the pull request links of comments and descriptions alone. | |
| // 8.36.0 Reloading the page refetches the whole scope once the cache is over thirty seconds old; the badge animates after a reload or a navigation. | |
| // 8.35.0 A comment made after a review request answers it, so the pull request leaves the amber bucket although GitHub still lists you as a reviewer. | |
| // 8.34.2 Approving then going back to the list within a few seconds refetches the approved pull request instead of nothing. | |
| // 8.34.1 Adds this changelog. | |
| // 8.34.0 Comments refetch the open pull request only when they can move it to another bucket; Start a review no longer triggers a refetch. | |
| // 8.33.0 Colours the approval count by rank within the scope, the most approved row being the greenest. | |
| // 8.32.0 Shows the approval count and names in green instead of faded. | |
| // 8.31.0 A middle click or a Ctrl/Cmd click on a panel row opens the pull request in a new tab and leaves the panel open. | |
| // 8.30.0 Escape closes the panel without also marking a file as reviewed. | |
| // 8.29.0 Dims rows through colour instead of opacity, which left ghost paints behind in Chromium. | |
| // 8.28.0 Closing, reopening, merging, converting to draft or marking ready refetches the open pull request and moves it across buckets. | |
| // 8.27.0 Writes approver names at the end of the row's metadata line on every row, which Refined GitHub leaves alone. | |
| // 8.26.2 Finds the metadata line by the pull request number only, since the two lists word it differently. | |
| // 8.26.1 Writes approver names on the metadata line when the row has no approval label. | |
| // 8.25.0 Finds the approval label when it wraps an icon. | |
| // 8.24.0 Finds the approval label in the "N review approvals" wording as well as "Approved". | |
| // 8.23.0 Aligns the badge with GitHub's own label pills. | |
| // 8.22.1 Matches the approver names to the font of the label they follow. | |
| // 8.22.0 Requires Contents: Read-only in the token setup, which supplies the last push date. | |
| // 8.21.3 Fixes spelling in the header. | |
| // 8.21.2 Adds the recommended browser extensions. | |
| // 8.21.1 Adds the documentation header. | |
| // 8.20.0 First version in the gist. | |
| // | |
| // Recommended usage for Chrome | |
| // - extension: https://chromewebstore.google.com/detail/user-javascript-and-css/nbhcbdghjpllgmfilhnhkllmkecfmpld | |
| // - for: https://github.com/* | |
| // | |
| // Recommended usage for Firefox | |
| // - extension: https://addons.mozilla.org/en-US/firefox/addon/greasemonkey/ | |
| // ==UserScript== | |
| // @name GitHub PRs to approve | |
| // @namespace github-prs-to-approve | |
| // @version 8.36.1 | |
| // @description Shows which pull requests still need your review, in a panel and on GitHub's own pull request rows. | |
| // @match https://github.com/* | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_setValue | |
| // @grant GM_deleteValue | |
| // @grant GM_getValue | |
| // @grant GM_registerMenuCommand | |
| // @connect api.github.com | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| /* | |
| * Answers one question on GitHub: which pull requests still need something from you. | |
| * | |
| * It answers it twice — as a panel behind a badge, and directly on GitHub's own pull request rows, | |
| * so the list you already look at tells you what to review without opening anything. | |
| * | |
| * | |
| * SETUP | |
| * | |
| * A token is required. api.github.com refuses cookie authentication, and github.com/_graphql serves persisted queries only, | |
| * so the browser session cannot be reused. Open the panel, go to Permissions, and paste a token. | |
| * | |
| * Fine-grained Resource owner: the account you review. Repository access: all. | |
| * Permissions: Pull requests: Read-only, and Contents: Read-only for the last push date. | |
| * Classic The repo scope, authorised for the organisation when it enforces SSO. | |
| * | |
| * Contents is what supplies the last push date. Without it the amber bucket can only ever read zero, and the panel says so. | |
| * Any field the token is refused is dropped from the query, and the panel names both the loss and the permission that restores it. | |
| * | |
| * | |
| * WHERE IT RUNS | |
| * | |
| * Only on a pull request list (/owner/repo/pulls) or a pull request (/owner/repo/pull/123), and only for an account you allowed. | |
| * Nothing is watched until you allow it. Permissions shows the account and the repository of the current page as two lines, | |
| * each with what the token can read and an Allow or Revoke button, plus the full list of everything allowed elsewhere. | |
| * Allowing an account watches all of its repositories; allowing a repository watches only that one. | |
| * | |
| * | |
| * THE THREE BUCKETS | |
| * | |
| * blue never commented you have never said anything on it | |
| * amber pushed since your last inline you commented, and a commit landed after it, or the review was re-requested from you since | |
| * grey waiting on the author you commented and nothing was pushed since, so the next move is not yours | |
| * | |
| * A comment answers a review request made before it, even though GitHub keeps you listed as a reviewer until you submit a review. | |
| * A pull request you approved, and one you opened, carry a badge but no row tint: neither is waiting on you. | |
| * The launcher shows the three counts side by side, hides itself when there is nothing to review, and names the account on hover. | |
| * Clicking a count filters the panel to that bucket; the counts combine rather than replace each other. | |
| * Clicking a row opens the pull request and closes the panel; a middle click or a Ctrl/Cmd click opens it in a new tab and leaves the panel open. | |
| * Escape closes the panel and stops there, so the key still marks a file as reviewed whenever the panel is shut. | |
| * | |
| * | |
| * ON GITHUB'S OWN LISTS | |
| * | |
| * Every row gets the same badge as the panel, tinted by bucket, and the approver names are written after the row's own "Approved". | |
| * The legacy and the React lists are both handled through the hovercard attribute their title links share, | |
| * so no class name or test id is relied on. Rows re-decorate after each re-render, and never twice. | |
| * Pull request links inside comments and descriptions keep their own look. | |
| * | |
| * | |
| * WHEN IT REFRESHES | |
| * | |
| * Whole scope landing on a page or opening the panel when the cache is over ten minutes old, reloading the page when it is over thirty seconds old, | |
| * the Refresh button, | |
| * changing account, allowing or revoking a scope, and replacing the token. | |
| * One pull a click on Approve, Submit review or Request changes, a click on Close, Reopen, Convert to draft, Ready for review or Merge, | |
| * a comment, a form submit or a review endpoint call seen in resource timing, only while a comment can still move the pull request, | |
| * and — at most once every five minutes per pull request — a page load, a tab becoming visible, or the window regaining focus. | |
| * | |
| * Every refetch waits a few seconds after the last trigger, so a burst of clicks costs one query. | |
| * A comment cannot move a pull request you approved, opened, or already commented inline on with no push since, so commenting on it queries nothing. | |
| * | |
| * The panel paints from cache first and refreshes behind you, so it opens in about fifteen milliseconds. | |
| * Approving animates the badge while the refetch runs, then moves the row to its new bucket, even when the page reloads or you go back to the list. | |
| * Closing, merging or converting to draft drops it from every bucket; reopening or marking it ready puts it back. | |
| * | |
| * | |
| * STORAGE | |
| * | |
| * gh-prs-to-approve-token the token, in clear | |
| * gh-prs-to-approve-enabled the accounts and repositories you allowed | |
| * gh-prs-to-approve-scope the login the token belongs to | |
| * gh-prs-to-approve-cache:* one entry per watched scope, discarded when its version no longer matches | |
| * gh-prs-to-approve-queued the pull requests still to refetch when the page unloads, in sessionStorage, read back on the next load | |
| * | |
| * | |
| * LIMITS | |
| * | |
| * Only inline review comments count as yours, so a conversation comment does not move a pull request out of the amber bucket. | |
| * Drafts are excluded by the search. A review submitted as a GraphQL mutation is not visible in resource timing, | |
| * which leaves the button match and the five minute floor to catch it. | |
| * Nothing on the page is patched: wrapping fetch through unsafeWindow breaks GitHub's own navigation in Firefox. | |
| */ | |
| (function () { | |
| 'use strict' | |
| const PAGE_SIZE = 50 | |
| const MAX_PAGES = 10 | |
| const CACHE_TTL_MS = 10 * 60 * 1000 | |
| // A hard reload asks for fresh data, so it gets a much shorter cache age than a soft navigation. | |
| const RELOAD_CACHE_TTL_MS = 30 * 1000 | |
| const CACHE_VERSION = 16 | |
| const TOKEN_KEY = 'gh-prs-to-approve-token' | |
| const CACHE_KEY = 'gh-prs-to-approve-cache' | |
| const SCOPE_KEY = 'gh-prs-to-approve-scope' | |
| const ENABLED_KEY = 'gh-prs-to-approve-enabled' | |
| const QUEUED_REFRESH_KEY = 'gh-prs-to-approve-queued' | |
| // Keys this script wrote before it was renamed, read once so a rename never costs you the token. | |
| const RENAMED_KEYS = { | |
| [TOKEN_KEY]: 'gh-prs-not-approved-token', | |
| [CACHE_KEY]: 'gh-prs-not-approved-cache', | |
| [SCOPE_KEY]: 'gh-prs-not-approved-scope', | |
| } | |
| const TOKEN_BASE_NAME = 'prs-browser-extension' | |
| const TOKEN_SETUP_URL = 'https://github.com/settings/personal-access-tokens/new' | |
| const TOKEN_LIST_URL = 'https://github.com/settings/personal-access-tokens' | |
| const MISSING_TOKEN = 'MISSING_TOKEN' | |
| const NO_OWNER = 'NO_OWNER' | |
| const NOT_ENABLED = 'NOT_ENABLED' | |
| // Long enough for GitHub to have persisted the review before the refetch asks for it. | |
| const REVIEW_SETTLE_MS = 2500 | |
| // Focus and visibility fire on every tab switch; without a floor they would query on each one. | |
| // Counted per pull request, so opening a different one is always checked however recently another was. | |
| const PASSIVE_REFRESH_GAP_MS = 5 * 60 * 1000 | |
| // A token is unavoidable: api.github.com refuses cookie auth, and github.com/_graphql serves persisted queries only. | |
| const API_ENDPOINT = 'https://api.github.com/graphql' | |
| const CORE_SELECTION = ` | |
| number | |
| title | |
| url | |
| state | |
| isDraft | |
| createdAt | |
| author { login } | |
| repository { nameWithOwner } | |
| viewerLatestReview { state } | |
| viewerLatestReviewRequest { __typename } | |
| latestOpinionatedReviews(first: 10) { nodes { author { login } state } }` | |
| // Connections the panel enriches rows with. Each needs a permission the core approval check does not, so each is droppable. | |
| const OPTIONAL_FIELDS = { | |
| // Server-side filtered down to the single review that decides the bucket: your last one carrying a comment. | |
| myReviews: { | |
| selection: 'myReviews: reviews(last: 1, author: $me, states: [COMMENTED, CHANGES_REQUESTED]) { nodes { state submittedAt comments { totalCount } } }', | |
| permission: 'Pull requests: Read-only', | |
| lost: 'your comment history', | |
| needsViewer: true, | |
| }, | |
| // Not filterable by reviewer server-side, so the last twenty are read and yours picked out. | |
| reviewRequestEvents: { | |
| selection: 'reviewRequestEvents: timelineItems(last: 20, itemTypes: [REVIEW_REQUESTED_EVENT]) { nodes { ... on ReviewRequestedEvent { createdAt requestedReviewer { ... on User { login } } } } }', | |
| permission: 'Pull requests: Read-only', | |
| lost: 'review request dates, so a request you already answered with a comment stays amber', | |
| }, | |
| commits: { | |
| selection: 'commits(last: 1) { nodes { commit { committedDate } } }', | |
| permission: 'Contents: Read-only', | |
| lost: 'last push date, so the amber "pushed since" count stays at 0', | |
| }, | |
| } | |
| const enabledFieldNames = (enabled) => Object.keys(OPTIONAL_FIELDS).filter((field) => enabled[field]) | |
| function pullRequestSelection(enabled) { | |
| return `${CORE_SELECTION}\n ${enabledFieldNames(enabled).map((field) => OPTIONAL_FIELDS[field].selection).join('\n ')}` | |
| } | |
| /** GraphQL rejects a declared variable that nothing reads, so $me only appears when a selection needs it. */ | |
| function viewerVariable(enabled) { | |
| return enabledFieldNames(enabled).some((field) => OPTIONAL_FIELDS[field].needsViewer) ? ', $me: String!' : '' | |
| } | |
| function buildCoreQuery() { | |
| return ` | |
| query($search: String!, $cursor: String) { | |
| viewer { login } | |
| search(query: $search, type: ISSUE, first: ${PAGE_SIZE}, after: $cursor) { | |
| issueCount | |
| pageInfo { hasNextPage endCursor } | |
| nodes { | |
| ... on PullRequest {${CORE_SELECTION} | |
| } | |
| } | |
| } | |
| }` | |
| } | |
| function buildEnrichmentQuery(enabled) { | |
| return ` | |
| query($search: String!, $cursor: String${viewerVariable(enabled)}) { | |
| search(query: $search, type: ISSUE, first: ${PAGE_SIZE}, after: $cursor) { | |
| issueCount | |
| pageInfo { hasNextPage endCursor } | |
| nodes { | |
| ... on PullRequest { | |
| number | |
| repository { nameWithOwner } | |
| ${enabledFieldNames(enabled).map((field) => OPTIONAL_FIELDS[field].selection).join('\n ')} | |
| } | |
| } | |
| } | |
| }` | |
| } | |
| function buildSingleQuery(enabled) { | |
| return ` | |
| query($owner: String!, $name: String!, $number: Int!${viewerVariable(enabled)}) { | |
| repository(owner: $owner, name: $name) { | |
| pullRequest(number: $number) {${pullRequestSelection(enabled)} | |
| } | |
| } | |
| }` | |
| } | |
| /** @return string[] the droppable fields the token was refused, so the next attempt can leave them out */ | |
| function refusedFields(errors) { | |
| const refused = new Set() | |
| errors.forEach((error) => { | |
| const path = Array.isArray(error.path) ? error.path : [] | |
| path.forEach((step) => { | |
| if (typeof step === 'string' && OPTIONAL_FIELDS[step]) refused.add(step) | |
| }) | |
| }) | |
| return [...refused] | |
| } | |
| function describeErrors(errors) { | |
| const counts = new Map() | |
| errors.forEach((error) => counts.set(error.message, (counts.get(error.message) || 0) + 1)) | |
| return [...counts].map(([message, count]) => (count > 1 ? `${message} (${count}×)` : message)).join('\n') | |
| } | |
| function readSetting(key) { | |
| return typeof GM_getValue === 'function' ? GM_getValue(key, '') : localStorage.getItem(key) || '' | |
| } | |
| function writeSetting(key, value) { | |
| if (typeof GM_setValue === 'function') GM_setValue(key, value) | |
| else localStorage.setItem(key, value) | |
| } | |
| function deleteSetting(key) { | |
| if (typeof GM_deleteValue === 'function') GM_deleteValue(key) | |
| else if (typeof GM_setValue === 'function') GM_setValue(key, '') | |
| else localStorage.removeItem(key) | |
| } | |
| /** Moves the token, cache and scope onto the current key names, then drops the old ones. */ | |
| function migrateStorageKeys() { | |
| Object.keys(RENAMED_KEYS).forEach((currentKey) => { | |
| const previousKey = RENAMED_KEYS[currentKey] | |
| const carried = readSetting(previousKey) | |
| if (!carried) return | |
| if (!readSetting(currentKey)) writeSetting(currentKey, carried) | |
| deleteSetting(previousKey) | |
| }) | |
| } | |
| const cacheKeyFor = (scopeKey) => `${CACHE_KEY}:${scopeKey}` | |
| function readCache(scopeKey) { | |
| try { | |
| const cache = JSON.parse(readSetting(cacheKeyFor(scopeKey || (activeScope() || {}).key)) || 'null') | |
| return cache && cache.version === CACHE_VERSION ? cache : null | |
| } catch (error) { | |
| return null | |
| } | |
| } | |
| const writeCache = (scopeKey, cache) => writeSetting(cacheKeyFor(scopeKey), JSON.stringify(cache)) | |
| /** @return string the login of the signed-in user, empty when neither the cache nor the page can tell */ | |
| function currentUserLogin() { | |
| const scope = readScope() | |
| if (scope && scope.login) return scope.login | |
| const meta = document.querySelector('meta[name="user-login"], meta[name="octolytics-actor-login"]') | |
| if (meta && meta.content) return meta.content | |
| const avatar = document.querySelector('img.avatar-user[alt^="@"], header img[alt^="@"]') | |
| return avatar ? avatar.alt.replace('@', '') : '' | |
| } | |
| function tokenName() { | |
| const login = currentUserLogin() | |
| return login ? `${login}-${TOKEN_BASE_NAME}` : TOKEN_BASE_NAME | |
| } | |
| function classicTokenUrl() { | |
| return `https://github.com/settings/tokens/new?scopes=repo&description=${encodeURIComponent(tokenName())}` | |
| } | |
| /** @return string the comma-separated scopes of a classic token, empty for a fine-grained one */ | |
| function readScopeHeader(rawHeaders) { | |
| const match = /^x-oauth-scopes:\s*(.*)$/im.exec(rawHeaders) | |
| return match ? match[1].trim() : '' | |
| } | |
| function postGraphql(token, query, variables) { | |
| const body = JSON.stringify({ query, variables }) | |
| const headers = { Authorization: `bearer ${token}`, 'Content-Type': 'application/json' } | |
| if (typeof GM_xmlhttpRequest === 'function') { | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'POST', | |
| url: API_ENDPOINT, | |
| headers, | |
| data: body, | |
| onload: (response) => { | |
| try { | |
| const payload = JSON.parse(response.responseText) | |
| payload.oauthScopes = readScopeHeader(response.responseHeaders || '') | |
| resolve(payload) | |
| } catch (error) { | |
| reject(new Error(`Unreadable API response (HTTP ${response.status}).`)) | |
| } | |
| }, | |
| onerror: () => reject(new Error('Network error while calling the GitHub API.')), | |
| }) | |
| }) | |
| } | |
| return fetch(API_ENDPOINT, { method: 'POST', headers, body }) | |
| .then((response) => response.json().then((payload) => Object.assign(payload, { oauthScopes: response.headers.get('x-oauth-scopes') || '' }))) | |
| } | |
| const reviewedAt = (review) => review.submittedAt | |
| const newest = (dates) => dates.filter(Boolean).sort().pop() || null | |
| /** Flattens one pull request into the row the panel renders, so the cache never holds the raw payload. */ | |
| function summarize(pullRequest, viewerLogin) { | |
| const myReviews = pullRequest.myReviews ? pullRequest.myReviews.nodes : [] | |
| const commits = pullRequest.commits ? pullRequest.commits.nodes : [] | |
| const reviewRequestEvents = pullRequest.reviewRequestEvents ? pullRequest.reviewRequestEvents.nodes : [] | |
| const myInlineAt = newest(myReviews.filter((review) => review.comments.totalCount > 0).map(reviewedAt)) | |
| const myGlobalAt = newest(myReviews.filter((review) => review.comments.totalCount === 0 && review.state === 'COMMENTED').map(reviewedAt)) | |
| const myLastCommentAt = newest([myInlineAt, myGlobalAt]) | |
| const lastPushAt = commits.length ? commits[0].commit.committedDate : null | |
| const requestedFromMeAt = newest(reviewRequestEvents | |
| .filter((event) => event.requestedReviewer && event.requestedReviewer.login === viewerLogin) | |
| .map((event) => event.createdAt)) | |
| // A team request has no event naming you, so it stays pending until a review is submitted. | |
| const requestAnsweredByComment = Boolean(requestedFromMeAt && myLastCommentAt && myLastCommentAt > requestedFromMeAt) | |
| // Without the reviews connection the viewer's own latest review is the only trace of a comment left. | |
| const fallbackCommentKind = pullRequest.viewerLatestReview && pullRequest.viewerLatestReview.state === 'COMMENTED' ? 'global' : '' | |
| return { | |
| number: pullRequest.number, | |
| title: pullRequest.title, | |
| url: pullRequest.url, | |
| // The search only returns open pull requests, but the single refetch follows the one you have open through a close or a merge. | |
| isOpen: pullRequest.state === 'OPEN', | |
| isDraft: pullRequest.isDraft, | |
| repository: pullRequest.repository.nameWithOwner, | |
| authorLogin: pullRequest.author ? pullRequest.author.login : 'ghost', | |
| createdAt: pullRequest.createdAt, | |
| lastPushAt, | |
| myCommentKind: myInlineAt ? 'inline' : myGlobalAt ? 'global' : fallbackCommentKind, | |
| myLastCommentAt, | |
| pushedSinceMyComment: Boolean(myLastCommentAt && lastPushAt && lastPushAt > myLastCommentAt), | |
| approvers: pullRequest.latestOpinionatedReviews.nodes | |
| .filter((review) => review.state === 'APPROVED' && review.author) | |
| .map((review) => review.author.login), | |
| approvedByMe: pullRequest.latestOpinionatedReviews.nodes.some( | |
| (review) => review.author && review.author.login === viewerLogin && review.state === 'APPROVED', | |
| ), | |
| changesRequestedByMe: pullRequest.viewerLatestReview ? pullRequest.viewerLatestReview.state === 'CHANGES_REQUESTED' : false, | |
| reviewRequestedFromMe: Boolean(pullRequest.viewerLatestReviewRequest) && !requestAnsweredByComment, | |
| } | |
| } | |
| const CATEGORIES = [ | |
| { key: 'never', label: 'never commented', hint: 'never commented by you' }, | |
| { key: 'revisit', label: 'pushed since', hint: 'you commented, pushed since' }, | |
| { key: 'parked', label: 'no push since', hint: 'you commented, no push since' }, | |
| ] | |
| /** @return string which of the three review buckets a row belongs to */ | |
| function categoryOf(pullRequest) { | |
| if (!pullRequest.myCommentKind) return 'never' | |
| if (pullRequest.pushedSinceMyComment || pullRequest.reviewRequestedFromMe) return 'revisit' | |
| return 'parked' | |
| } | |
| const countByCategory = (pullRequests) => | |
| CATEGORIES.map((category) => pullRequests.filter((pullRequest) => categoryOf(pullRequest) === category.key).length) | |
| const byCreationDateDesc = (left, right) => (left.createdAt < right.createdAt ? 1 : -1) | |
| /** Prefers the login the page already carries, so a cold start skips a round trip. The core query verifies it. */ | |
| async function resolveViewerLogin(token) { | |
| const known = currentUserLogin() | |
| if (known) return known | |
| const payload = await postGraphql(token, 'query { viewer { login } }', {}) | |
| if (payload.errors) throw new Error(describeErrors(payload.errors)) | |
| return payload.data.viewer.login | |
| } | |
| const SCOPE_QUERY = 'query { viewer { login organizations(first: 50) { nodes { login } } } }' | |
| const OWNER_REPOS_QUERY = ` | |
| query($owner: String!) { | |
| repositoryOwner(login: $owner) { | |
| __typename | |
| ... on Organization { repositories(first: 100, orderBy: {field: PUSHED_AT, direction: DESC}) { totalCount nodes { nameWithOwner isPrivate } } } | |
| ... on User { repositories(first: 100, orderBy: {field: PUSHED_AT, direction: DESC}) { totalCount nodes { nameWithOwner isPrivate } } } | |
| } | |
| }` | |
| const REPO_ACCESS_QUERY = 'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { nameWithOwner isPrivate viewerPermission } }' | |
| // Adds a probe of one account, because the viewer query alone says nothing about what the token can reach. | |
| const TOKEN_PROBE_QUERY = ` | |
| query($search: String!) { | |
| viewer { login organizations(first: 50) { nodes { login } } } | |
| search(query: $search, type: ISSUE, first: 1) { issueCount } | |
| }` | |
| function readScope() { | |
| try { | |
| return JSON.parse(readSetting(SCOPE_KEY) || 'null') | |
| } catch (error) { | |
| return null | |
| } | |
| } | |
| /** | |
| * Asks the API who the token belongs to, what it announces about itself, and whether it can read one account. | |
| * @param owner the account to probe, empty to skip that part | |
| */ | |
| async function probeToken(token, owner) { | |
| const payload = owner | |
| ? await postGraphql(token, TOKEN_PROBE_QUERY, { search: `is:pr owner:${owner}` }) | |
| : await postGraphql(token, SCOPE_QUERY, {}) | |
| if (payload.errors) throw new Error(describeErrors(payload.errors)) | |
| const scope = { | |
| discoveredAt: Date.now(), | |
| login: payload.data.viewer.login, | |
| owners: payload.data.viewer.organizations.nodes.map((organization) => organization.login), | |
| oauthScopes: payload.oauthScopes || '', | |
| } | |
| writeSetting(SCOPE_KEY, JSON.stringify(scope)) | |
| return Object.assign({ probedOwner: owner, visiblePullRequests: payload.data.search ? payload.data.search.issueCount : null }, scope) | |
| } | |
| const maskToken = (token) => (token.length > 12 ? `${token.slice(0, 8)}…${token.slice(-4)}` : '••••') | |
| /** @return object[] the repositories of one account that the token can see */ | |
| async function fetchOwnerRepositories(token, owner) { | |
| const payload = await postGraphql(token, OWNER_REPOS_QUERY, { owner }) | |
| if (payload.errors) throw new Error(describeErrors(payload.errors)) | |
| if (!payload.data || !payload.data.repositoryOwner) throw new Error(`${owner} is not visible to this token.`) | |
| const repositories = payload.data.repositoryOwner.repositories | |
| return { kind: payload.data.repositoryOwner.__typename, total: repositories.totalCount, nodes: repositories.nodes } | |
| } | |
| /** @return object|null the repository as the token sees it, null when the token cannot reach it */ | |
| async function fetchRepositoryAccess(token, owner, name) { | |
| const payload = await postGraphql(token, REPO_ACCESS_QUERY, { owner, name }) | |
| if (!payload.data || !payload.data.repository) return null | |
| return payload.data.repository | |
| } | |
| // GitHub paths that are not an account. Everything else under github.com/<segment> is a user or an organisation. | |
| const RESERVED_PATHS = new Set([ | |
| '', 'about', 'account', 'apps', 'codespaces', 'collections', 'contact', 'dashboard', 'enterprise', 'events', 'explore', | |
| 'features', 'issues', 'login', 'logout', 'marketplace', 'new', 'notifications', 'orgs', 'organizations', 'pricing', | |
| 'pulls', 'search', 'security', 'settings', 'site', 'sponsors', 'stars', 'topics', 'trending', 'users', 'watching', | |
| ]) | |
| /** @return object the owner and repository named by the page URL, both empty when it names neither */ | |
| function currentLocation() { | |
| const segments = window.location.pathname.split('/') | |
| const owner = segments[1] || '' | |
| if (RESERVED_PATHS.has(owner.toLowerCase())) return { owner: '', repo: '' } | |
| const repo = segments[2] || '' | |
| return { owner, repo: RESERVED_PATHS.has(repo.toLowerCase()) ? '' : repo } | |
| } | |
| const currentOwner = () => currentLocation().owner | |
| // Only the pull request list and a pull request itself. Never /pulse, which shares the prefix. | |
| const PULL_SURFACE = /^\/[^/]+\/[^/]+\/pulls?(\/|$)/ | |
| const onPullRequestSurface = () => PULL_SURFACE.test(window.location.pathname) | |
| function readEnabled() { | |
| try { | |
| const stored = JSON.parse(readSetting(ENABLED_KEY) || 'null') | |
| return { owners: (stored && stored.owners) || [], repos: (stored && stored.repos) || [] } | |
| } catch (error) { | |
| return { owners: [], repos: [] } | |
| } | |
| } | |
| const writeEnabled = (enabled) => writeSetting(ENABLED_KEY, JSON.stringify(enabled)) | |
| /** @return object what the page is enabled for, null while it is not enabled. owner: also matches a user account. */ | |
| function activeScope() { | |
| const { owner, repo } = currentLocation() | |
| if (!owner) return null | |
| const enabled = readEnabled() | |
| if (enabled.owners.includes(owner)) return { kind: 'owner', key: `owner:${owner}`, label: owner, filter: `owner:${owner}` } | |
| const full = `${owner}/${repo}` | |
| if (repo && enabled.repos.includes(full)) return { kind: 'repo', key: `repo:${full}`, label: full, filter: `repo:${full}` } | |
| return null | |
| } | |
| const searchFilter = (scope) => `is:pr is:open archived:false draft:false ${scope.filter} sort:created-desc` | |
| const rowKey = (node) => `${node.repository.nameWithOwner}#${node.number}` | |
| /** Walks every page of one search query. @throws Error carrying .refused when the token was denied a droppable field */ | |
| async function pagedFetch(token, filter, query, extraVariables) { | |
| const nodes = [] | |
| let cursor = null | |
| let candidateCount = 0 | |
| let unreadableCount = 0 | |
| let viewerLogin = '' | |
| for (let page = 0; page < MAX_PAGES; page += 1) { | |
| const payload = await postGraphql(token, query, Object.assign({ search: filter, cursor }, extraVariables)) | |
| // GraphQL answers 200 with data alongside a partial errors array, so a silent half-result must fail loudly here. | |
| if (payload.errors) { | |
| const failure = new Error(describeErrors(payload.errors)) | |
| failure.refused = refusedFields(payload.errors) | |
| throw failure | |
| } | |
| if (!payload.data) throw new Error(payload.message || 'The API returned no data — check the token.') | |
| if (payload.data.viewer) viewerLogin = payload.data.viewer.login | |
| if (page === 0) candidateCount = payload.data.search.issueCount | |
| // A token allowed to search but not to read a hit gets a null node, never an error. | |
| const readable = payload.data.search.nodes.filter((node) => node && node.number) | |
| unreadableCount += payload.data.search.nodes.length - readable.length | |
| nodes.push(...readable) | |
| if (!payload.data.search.pageInfo.hasNextPage) break | |
| cursor = payload.data.search.pageInfo.endCursor | |
| } | |
| return { nodes, candidateCount, unreadableCount, viewerLogin } | |
| } | |
| /** Never rejects: the approval answer comes from the core query, so a refused enrichment only costs columns. */ | |
| async function fetchEnrichment(token, filter, viewerLogin) { | |
| const enabled = {} | |
| Object.keys(OPTIONAL_FIELDS).forEach((field) => { enabled[field] = true }) | |
| while (enabledFieldNames(enabled).length > 0) { | |
| try { | |
| const result = await pagedFetch(token, filter, buildEnrichmentQuery(enabled), { me: viewerLogin }) | |
| return { | |
| byKey: new Map(result.nodes.map((node) => [rowKey(node), node])), | |
| unavailable: Object.keys(OPTIONAL_FIELDS).filter((field) => !enabled[field]), | |
| } | |
| } catch (error) { | |
| const droppable = (error.refused || []).filter((field) => enabled[field]) | |
| if (droppable.length === 0) break | |
| droppable.forEach((field) => { enabled[field] = false }) | |
| } | |
| } | |
| return { byKey: new Map(), unavailable: Object.keys(OPTIONAL_FIELDS) } | |
| } | |
| function assembleResult(core, byKey, viewerLogin, unavailable, scope) { | |
| const summarized = core.nodes | |
| .map((node) => summarize(Object.assign({}, node, byKey.get(rowKey(node))), viewerLogin)) | |
| .filter((pullRequest) => !pullRequest.isDraft) | |
| const marker = (pullRequest) => ({ repository: pullRequest.repository, number: pullRequest.number, approvers: pullRequest.approvers }) | |
| const mine = summarized.filter((pullRequest) => pullRequest.authorLogin === viewerLogin) | |
| const theirs = summarized.filter((pullRequest) => pullRequest.authorLogin !== viewerLogin) | |
| const rows = theirs | |
| .filter((pullRequest) => !pullRequest.approvedByMe || pullRequest.reviewRequestedFromMe) | |
| .sort(byCreationDateDesc) | |
| const approved = theirs.filter((pullRequest) => pullRequest.approvedByMe && !pullRequest.reviewRequestedFromMe).map(marker) | |
| const authored = mine.map(marker) | |
| return { | |
| version: CACHE_VERSION, | |
| fetchedAt: Date.now(), | |
| candidateCount: core.candidateCount, | |
| unreadableCount: core.unreadableCount, | |
| viewerLogin, | |
| unavailable, | |
| scopeKey: scope.key, | |
| scopeLabel: scope.label, | |
| approved, | |
| authored, | |
| pullRequests: rows, | |
| } | |
| } | |
| /** | |
| * Runs the core and enrichment searches at once rather than in one heavy query. | |
| * @param onCoreReady called with a dateless result as soon as the core search lands, so the list can paint early | |
| */ | |
| async function fetchFromApi(token, scope, onCoreReady) { | |
| const filter = searchFilter(scope) | |
| let viewerLogin = await resolveViewerLogin(token) | |
| let enrichment = fetchEnrichment(token, filter, viewerLogin) | |
| const core = await pagedFetch(token, filter, buildCoreQuery(), {}) | |
| // The stored login can be stale; the API's answer wins, and the enrichment must be redone under it. | |
| if (core.viewerLogin && core.viewerLogin !== viewerLogin) { | |
| viewerLogin = core.viewerLogin | |
| enrichment = fetchEnrichment(token, filter, viewerLogin) | |
| } | |
| if (onCoreReady) onCoreReady(assembleResult(core, new Map(), viewerLogin, Object.keys(OPTIONAL_FIELDS), scope)) | |
| const { byKey, unavailable } = await enrichment | |
| return assembleResult(core, byKey, viewerLogin, unavailable, scope) | |
| } | |
| /** @return string what the token failed to see, empty when the result can be trusted */ | |
| function accessProblem(result) { | |
| if (result.candidateCount === 0) return `The search matched 0 open pull requests in ${result.scopeLabel}.` | |
| if (result.unreadableCount > 0) return `The token could not read ${result.unreadableCount} of the ${result.candidateCount} pull requests the search matched.` | |
| return '' | |
| } | |
| const cacheListeners = [] | |
| const onCacheUpdated = (listener) => { | |
| cacheListeners.push(listener) | |
| return () => cacheListeners.splice(cacheListeners.indexOf(listener), 1) | |
| } | |
| const isFresh = (cache, maxAgeMs) => Date.now() - cache.fetchedAt < maxAgeMs | |
| // The launcher and the panel both ask on a cold start; without this they would run the whole fetch twice. | |
| let inFlight = null | |
| const progressListeners = [] | |
| /** | |
| * Stale-while-revalidate: the caller gets whatever is cached at once, then the fresh result when it lands. | |
| * @param onProgress called with (result, settled) — settled is false while a refresh is still running | |
| * @param maxAgeMs the age past which the cache is refetched | |
| * @throws Error with message MISSING_TOKEN when no token is stored and the cache cannot answer | |
| */ | |
| function loadPullRequests(force, onProgress, maxAgeMs = CACHE_TTL_MS) { | |
| if (!currentOwner()) return Promise.reject(new Error(NO_OWNER)) | |
| const scope = activeScope() | |
| if (!scope) return Promise.reject(new Error(NOT_ENABLED)) | |
| const cache = readCache(scope.key) | |
| if (cache && onProgress) onProgress(cache, isFresh(cache, maxAgeMs) && !force) | |
| if (cache && isFresh(cache, maxAgeMs) && !force) return Promise.resolve(cache) | |
| const token = readSetting(TOKEN_KEY) | |
| if (!token) return Promise.reject(new Error(MISSING_TOKEN)) | |
| if (onProgress && !cache) progressListeners.push(onProgress) | |
| if (!inFlight) { | |
| inFlight = fetchFromApi(token, scope, (partial) => progressListeners.forEach((listener) => listener(partial, false))) | |
| .then((fresh) => { | |
| writeCache(scope.key, fresh) | |
| // Every full fetch passes here, so the rows of a list page follow it without each caller remembering to ask. | |
| decorateSafely() | |
| return fresh | |
| }) | |
| .finally(() => { | |
| inFlight = null | |
| progressListeners.length = 0 | |
| }) | |
| } | |
| return inFlight | |
| } | |
| function openPullRequestLocation() { | |
| const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/) | |
| return match ? { owner: match[1], name: match[2], number: Number(match[3]) } : null | |
| } | |
| /** | |
| * Refetches a single pull request and moves it to its new bucket, so approving or commenting on it shows up on the next panel open. | |
| * @param location the pull request to refetch, as openPullRequestLocation() returns it; nothing is fetched when it is null | |
| * @return object the cache, updated in place when the refetch succeeded | |
| */ | |
| async function refreshPullRequest(cache, location) { | |
| const token = readSetting(TOKEN_KEY) | |
| const scope = activeScope() | |
| if (!location || !token || !cache.viewerLogin || !scope) return cache | |
| if (scope.key !== cache.scopeKey) return cache | |
| const enabled = {} | |
| Object.keys(OPTIONAL_FIELDS).forEach((field) => { enabled[field] = !(cache.unavailable || []).includes(field) }) | |
| try { | |
| const payload = await postGraphql(token, buildSingleQuery(enabled), { | |
| owner: location.owner, | |
| name: location.name, | |
| number: location.number, | |
| me: cache.viewerLogin, | |
| }) | |
| if (payload.errors || !payload.data || !payload.data.repository) return cache | |
| const fresh = summarize(payload.data.repository.pullRequest, cache.viewerLogin) | |
| const isSame = (entry) => entry.number === fresh.number && entry.repository === fresh.repository | |
| const without = (list) => (list || []).filter((entry) => !isSame(entry)) | |
| const marker = { repository: fresh.repository, number: fresh.number, approvers: fresh.approvers } | |
| // All three buckets are rewritten: approving moves a row across them, it does not merely drop it. | |
| const authoredByMe = fresh.authorLogin === cache.viewerLogin | |
| const approvedByMe = fresh.approvedByMe && !fresh.reviewRequestedFromMe | |
| // Closing, merging or converting to draft takes a pull request out of every bucket, the same way the search would. | |
| const isListed = fresh.isOpen && !fresh.isDraft | |
| const stillPending = isListed && !authoredByMe && !approvedByMe | |
| const updated = Object.assign({}, cache, { | |
| pullRequests: (stillPending ? without(cache.pullRequests).concat(fresh) : without(cache.pullRequests)).sort(byCreationDateDesc), | |
| approved: isListed && approvedByMe && !authoredByMe ? without(cache.approved).concat(marker) : without(cache.approved), | |
| authored: isListed && authoredByMe ? without(cache.authored).concat(marker) : without(cache.authored), | |
| }) | |
| writeCache(scope.key, updated) | |
| return updated | |
| } catch (error) { | |
| return cache | |
| } | |
| } | |
| let refreshTimer = null | |
| let refreshInFlight = false | |
| const lastRefreshedAt = new Map() | |
| // Captured when you act, not when the timer fires: going back to the list within the settle delay changes the URL. | |
| const pullRequestsToRefresh = new Map() | |
| // Taken out of the queue while their query runs, and still saved if the page unloads before it answers. | |
| let locationsInFlight = [] | |
| const pullRequestKey = (location) => (location ? `${location.owner}/${location.name}#${location.number}` : '') | |
| const openPullRequestKey = () => pullRequestKey(openPullRequestLocation()) | |
| // Page load and tab switches refresh quietly; only something you just did is worth animating. | |
| let announcedRefresh = false | |
| const isRefreshing = () => announcedRefresh && (refreshInFlight || Boolean(refreshTimer)) | |
| function markLauncherRefreshing() { | |
| const launcher = document.getElementById('prna-launcher') | |
| if (launcher) launcher.dataset.refreshing = String(isRefreshing()) | |
| } | |
| /** | |
| * Refetches the open pull request shortly after you act on it, so approving makes its row disappear even once you are back on the list. | |
| * A call made while a refetch runs is queued behind it: that refetch may have read the pull request before your action. | |
| * @param announce true when you triggered it, which makes the badge show that it is working | |
| */ | |
| function scheduleOpenPullRequestRefresh(announce) { | |
| const location = openPullRequestLocation() | |
| if (!location) return | |
| pullRequestsToRefresh.set(pullRequestKey(location), location) | |
| if (announce) announcedRefresh = true | |
| startRefreshTimer() | |
| // Set while the debounce runs, so clicking Approve reacts at once rather than after the settle delay. | |
| markLauncherRefreshing() | |
| } | |
| /** Restarts the settle delay, then refetches every queued pull request; does nothing while a refetch runs, which restarts it on its own. */ | |
| function startRefreshTimer() { | |
| if (refreshInFlight) return | |
| if (refreshTimer) clearTimeout(refreshTimer) | |
| refreshTimer = setTimeout(async () => { | |
| refreshTimer = null | |
| const locations = [...pullRequestsToRefresh.values()] | |
| pullRequestsToRefresh.clear() | |
| locationsInFlight = locations | |
| refreshInFlight = true | |
| markLauncherRefreshing() | |
| try { | |
| for (const location of locations) { | |
| const cache = readCache() | |
| if (!cache) break | |
| const updated = await refreshPullRequest(cache, location) | |
| lastRefreshedAt.set(pullRequestKey(location), Date.now()) | |
| if (updated !== cache) cacheListeners.forEach((listener) => listener(updated)) | |
| } | |
| } finally { | |
| refreshInFlight = false | |
| locationsInFlight = [] | |
| if (pullRequestsToRefresh.size) startRefreshTimer() | |
| else announcedRefresh = false | |
| markLauncherRefreshing() | |
| } | |
| }, REVIEW_SETTLE_MS) | |
| } | |
| /** Saves the pull requests still to refetch, so a reload or a hard navigation finishes them and keeps the badge animated. */ | |
| function saveQueuedRefreshes() { | |
| const locations = locationsInFlight.concat([...pullRequestsToRefresh.values()]) | |
| if (locations.length) sessionStorage.setItem(QUEUED_REFRESH_KEY, JSON.stringify({ locations, announced: announcedRefresh })) | |
| } | |
| /** Queues again the pull requests the previous page left unrefetched, then forgets them. */ | |
| function restoreQueuedRefreshes() { | |
| const saved = sessionStorage.getItem(QUEUED_REFRESH_KEY) | |
| sessionStorage.removeItem(QUEUED_REFRESH_KEY) | |
| if (!saved) return | |
| try { | |
| const { locations, announced } = JSON.parse(saved) | |
| locations.forEach((location) => pullRequestsToRefresh.set(pullRequestKey(location), location)) | |
| if (announced) announcedRefresh = true | |
| } catch (error) { | |
| return | |
| } | |
| startRefreshTimer() | |
| markLauncherRefreshing() | |
| } | |
| // Anchored on the whole label: a loose match would fire on "Files changed", "2 comments", or our own launcher. | |
| const REVIEW_ACTION = /^(approve|submit review|request changes)$/ | |
| // Pending review comments are left out: they change nothing until the review is submitted. | |
| const COMMENT_ACTION = /^(add (a )?single comment|comment)$/ | |
| // Every button that takes a pull request out of the list, or puts it back in. | |
| const STATE_ACTION = /^(close pull request|comment and close|close with comment|reopen pull request|reopen and comment|convert to draft|ready for review|(confirm )?(squash and |rebase and )?merge( pull request)?)$/ | |
| const label = (element) => (element.textContent || element.value || '').replace(/\s+/g, ' ').trim().toLowerCase() | |
| const REVIEW_ENDPOINT = /\/pulls?\/\d+\/review/i | |
| const sameKey = (left, right) => left.toLowerCase() === right.toLowerCase() | |
| /** @return boolean false when a comment on the open pull request would leave it in the same bucket */ | |
| function commentCanMoveOpenPullRequest() { | |
| const cache = readCache() | |
| const key = openPullRequestKey() | |
| if (!cache || !key) return false | |
| // Approved, authored, closed and draft pull requests are not pending, and no comment makes them pending. | |
| const pending = cache.pullRequests.find((pullRequest) => sameKey(`${pullRequest.repository}#${pullRequest.number}`, key)) | |
| return Boolean(pending) && !(categoryOf(pending) === 'parked' && pending.myCommentKind === 'inline') | |
| } | |
| function scheduleRefreshIfCommentCanMoveOpenPullRequest() { | |
| if (commentCanMoveOpenPullRequest()) scheduleOpenPullRequestRefresh(true) | |
| } | |
| /** | |
| * Reads the page's own network activity through resource timing. | |
| * Patching fetch or XMLHttpRequest would be more precise, but doing that across Firefox's Xray boundary | |
| * breaks the very requests GitHub navigates with. | |
| */ | |
| function observeReviewRequests() { | |
| if (typeof PerformanceObserver !== 'function') return | |
| try { | |
| const observer = new PerformanceObserver((list) => { | |
| // The URL does not tell a comment from an approval: the Approve and Submit review clicks cover the approval. | |
| if (list.getEntries().some((entry) => REVIEW_ENDPOINT.test(entry.name))) scheduleRefreshIfCommentCanMoveOpenPullRequest() | |
| }) | |
| observer.observe({ type: 'resource' }) | |
| } catch (error) { | |
| // Resource timing is optional: the click, submit and focus triggers still cover the case. | |
| } | |
| } | |
| /** | |
| * Watches for a review being submitted. The button match and the request match are both best effort; | |
| * the focus, visibility and page-load triggers are the guarantee that the row catches up. | |
| */ | |
| function watchForReviewSubmission() { | |
| observeReviewRequests() | |
| document.addEventListener('submit', (event) => { | |
| // Every comment box is a form, so a submit is handled as a comment. | |
| if (!event.target || !event.target.closest || !event.target.closest('#prna-panel')) scheduleRefreshIfCommentCanMoveOpenPullRequest() | |
| }, true) | |
| document.addEventListener('click', (event) => { | |
| if (!event.target || !event.target.closest) return | |
| if (event.target.closest('#prna-panel, #prna-launcher')) return | |
| const target = event.target.closest('button, [role="button"], input[type="submit"]') | |
| if (!target) return | |
| const clicked = label(target) | |
| if (REVIEW_ACTION.test(clicked) || STATE_ACTION.test(clicked)) scheduleOpenPullRequestRefresh(true) | |
| else if (COMMENT_ACTION.test(clicked)) scheduleRefreshIfCommentCanMoveOpenPullRequest() | |
| }, true) | |
| const passiveRefresh = () => { | |
| const key = openPullRequestKey() | |
| if (key && Date.now() - (lastRefreshedAt.get(key) || 0) >= PASSIVE_REFRESH_GAP_MS) scheduleOpenPullRequestRefresh(false) | |
| } | |
| document.addEventListener('visibilitychange', () => { | |
| if (!document.hidden) passiveRefresh() | |
| }) | |
| window.addEventListener('focus', passiveRefresh) | |
| // Submitting a review can reload the page, which silences every listener above. | |
| window.addEventListener('pagehide', saveQueuedRefreshes) | |
| restoreQueuedRefreshes() | |
| passiveRefresh() | |
| } | |
| // Present on the title link of both the legacy and the React pull request lists, unlike any class or test id. | |
| const LIST_ANCHOR = 'a[data-hovercard-type="pull_request"]' | |
| // Rendered markdown links to pull requests through the same attribute, and its bullets are li elements too. | |
| const MARKDOWN = '.markdown-body, .comment-body' | |
| function pullRequestReference(anchor) { | |
| const url = anchor.getAttribute('data-hovercard-url') || anchor.getAttribute('href') || '' | |
| const match = url.match(/\/([^/]+)\/([^/]+)\/pull\/(\d+)/) | |
| return match ? { repository: `${match[1]}/${match[2]}`, number: Number(match[3]) } : null | |
| } | |
| /** @return string the single fact that decides whether this pull request needs you now */ | |
| function primaryTag(pullRequest) { | |
| if (pullRequest.reviewRequestedFromMe) return 'review re-requested from you' | |
| if (pullRequest.pushedSinceMyComment) return 'pushed since your last inline comment' | |
| if (!pullRequest.myCommentKind) return 'never commented' | |
| return 'waiting on the author' | |
| } | |
| const chipSignature = (pullRequest) => | |
| [categoryOf(pullRequest), primaryTag(pullRequest), (pullRequest.approvers || []).join(',')].join('|') | |
| function buildChip(category, tag, approvers, signature) { | |
| const chip = document.createElement('span') | |
| chip.className = 'prna-chip' | |
| chip.dataset.prnaChip = 'true' | |
| chip.dataset.category = category | |
| chip.dataset.signature = signature | |
| chip.appendChild(document.createElement('i')) | |
| chip.appendChild(document.createTextNode(tag)) | |
| chip.title = `${approvers.length} approval${approvers.length === 1 ? '' : 's'}${approvers.length ? `: ${approvers.join(', ')}` : ''}` | |
| return chip | |
| } | |
| // How much green the least approved row keeps, in percent. Below it the count fades into the metadata line and stops being read. | |
| const APPROVAL_GREEN_FLOOR = 30 | |
| const maxApprovalCount = (entries) => entries.reduce((highest, entry) => Math.max(highest, (entry.approvers || []).length), 0) | |
| /** | |
| * Colours the approval count by rank rather than by number: the most approved row of the list is the greenest, | |
| * and every other one fades towards the colour of the line it sits on. | |
| */ | |
| function paintApprovalCount(element, count, maxApprovals) { | |
| const green = APPROVAL_GREEN_FLOOR + (100 - APPROVAL_GREEN_FLOOR) * (count / Math.max(maxApprovals, 1)) | |
| element.style.color = 'var(--fgColor-success, #1a7f37)' | |
| // Dropped by a browser without color-mix, which leaves the flat green above. | |
| element.style.color = `color-mix(in oklab, var(--fgColor-success, #1a7f37) ${Math.round(green)}%, var(--fgColor-muted, #656d76))` | |
| } | |
| /** @return Element|null the row's metadata line, matched on the pull request number it opens with */ | |
| function metadataLine(row, number) { | |
| // The markup breaks lines between words, and the two lists word the line differently, so only the number is reliable. | |
| return [...row.querySelectorAll('span, div')] | |
| .map((element) => ({ element, text: element.textContent.replace(/\s+/g, ' ').trim() })) | |
| .filter(({ element, text }) => text.startsWith(`#${number}`) && text.length > String(number).length + 8 && element.querySelectorAll('*').length > 0) | |
| .map(({ element }) => element) | |
| .sort((left, right) => left.querySelectorAll('*').length - right.querySelectorAll('*').length)[0] || null | |
| } | |
| /** Writes the approver names at the end of the row's metadata line, the one part both lists always render. */ | |
| function nameApprovers(row, approvers, number, maxApprovals) { | |
| const existing = row.querySelector('[data-prna-names]') | |
| // The count alone would keep a stale colour: the green is relative, so it moves when another row gains an approval. | |
| const wanted = `${approvers.join(', ')}/${maxApprovals}` | |
| if (existing && existing.dataset.prnaNames === wanted) return | |
| if (existing) existing.remove() | |
| if (!approvers.length) return | |
| // Deliberately not anchored to the row's own approval label: it is absent while a review is unsettled, | |
| // and Refined GitHub rewrites its text. The metadata line is present on every row and nobody rewrites it. | |
| const host = metadataLine(row, number) | |
| if (!host) return | |
| const names = document.createElement('span') | |
| names.className = 'prna-approved-by' | |
| names.dataset.prnaNames = wanted | |
| names.textContent = `· ${approvers.length} approval${approvers.length === 1 ? '' : 's'} by ${approvers.join(', ')}` | |
| // Copied from the anchor rather than inherited: the two sit in the same row but not in the same styling context. | |
| const hostStyle = window.getComputedStyle(host) | |
| names.style.font = hostStyle.font | |
| names.style.lineHeight = hostStyle.lineHeight | |
| paintApprovalCount(names, approvers.length, maxApprovals) | |
| host.appendChild(names) | |
| } | |
| /** @return Element|null the row's own label pill, found by what it paints rather than by a class name */ | |
| function neighbouringPill(chip, row) { | |
| if (!row) return null | |
| return [...row.querySelectorAll('span, a')].find((element) => { | |
| if (element === chip || chip.contains(element) || element.contains(chip)) return false | |
| const style = window.getComputedStyle(element) | |
| return parseFloat(style.borderTopLeftRadius) > 3 && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && element.getBoundingClientRect().height > 0 | |
| }) || null | |
| } | |
| /** Nudges the chip onto the same line as GitHub's own labels, which the two list layouts align differently. */ | |
| function alignWithNeighbour(chip, row) { | |
| const pill = neighbouringPill(chip, row) | |
| if (!pill) return | |
| const offset = chip.getBoundingClientRect().top - pill.getBoundingClientRect().top | |
| if (Math.abs(offset) >= 0.5 && Math.abs(offset) < 12) chip.style.marginTop = `${-offset}px` | |
| } | |
| /** Marks the rows of a pull request list with what the panel knows, so the list itself answers what to review. */ | |
| function decorateLists() { | |
| if (!onPullRequestSurface()) return | |
| const cache = readCache() | |
| if (!cache) return | |
| const byKey = new Map(cache.pullRequests.map((pullRequest) => [`${pullRequest.repository}#${pullRequest.number}`, pullRequest])) | |
| const approved = new Map((cache.approved || []).map((entry) => [`${entry.repository}#${entry.number}`, entry])) | |
| const authored = new Map((cache.authored || []).map((entry) => [`${entry.repository}#${entry.number}`, entry])) | |
| // Ranked against the whole scope, not against the page: scrolling to page two must not repaint the rows of page one. | |
| const maxApprovals = maxApprovalCount([...cache.pullRequests, ...(cache.approved || []), ...(cache.authored || [])]) | |
| document.querySelectorAll(LIST_ANCHOR).forEach((anchor) => { | |
| if (anchor.closest(MARKDOWN)) return | |
| const reference = pullRequestReference(anchor) | |
| if (!reference) return | |
| // Sit beside the title rather than inside it: the React list owns everything under its heading. | |
| const host = anchor.closest('h3') || anchor | |
| if (!host.parentNode) return | |
| const key = `${reference.repository}#${reference.number}` | |
| const pullRequest = byKey.get(key) | |
| const done = approved.get(key) | |
| const own = authored.get(key) | |
| const state = pullRequest ? categoryOf(pullRequest) : (done ? 'approved' : (own ? 'mine' : '')) | |
| const row = anchor.closest('.js-issue-row, li') | |
| if (row) { | |
| // Only what still needs you is tinted. Approved and your own rows keep the default background. | |
| if (state && state !== 'mine' && state !== 'approved') row.dataset.prnaRow = state | |
| else delete row.dataset.prnaRow | |
| } | |
| const sibling = host.nextElementSibling | |
| const existing = sibling && sibling.dataset && sibling.dataset.prnaChip ? sibling : null | |
| if (!state) return existing && existing.remove() | |
| const marker = pullRequest || done || own | |
| const tag = pullRequest ? primaryTag(pullRequest) : (done ? 'approved by you' : 'your pull request') | |
| const approvers = marker.approvers || [] | |
| const signature = pullRequest ? chipSignature(pullRequest) : `${state}|${approvers.join(',')}` | |
| if (row) nameApprovers(row, approvers, reference.number, maxApprovals) | |
| if (existing && existing.dataset.signature === signature) return | |
| if (existing) existing.remove() | |
| const chip = buildChip(state, tag, approvers, signature) | |
| host.parentNode.insertBefore(chip, host.nextSibling) | |
| alignWithNeighbour(chip, row) | |
| }) | |
| } | |
| function decorateSafely() { | |
| try { | |
| decorateLists() | |
| } catch (error) { | |
| // Decorating someone else's DOM must never break their page. | |
| } | |
| } | |
| /** Both lists re-render under us — React on the new one, Turbo on the legacy — so the pass has to be repeatable. */ | |
| function watchLists() { | |
| let timer = null | |
| const observer = new MutationObserver(() => { | |
| if (timer) clearTimeout(timer) | |
| timer = setTimeout(() => { | |
| timer = null | |
| decorateSafely() | |
| }, 300) | |
| }) | |
| observer.observe(document.body, { childList: true, subtree: true }) | |
| decorateSafely() | |
| } | |
| function injectStyles() { | |
| if (document.getElementById('prna-styles')) return | |
| const style = document.createElement('style') | |
| style.id = 'prna-styles' | |
| style.textContent = ` | |
| #prna-launcher { position: fixed; right: 18px; bottom: 18px; z-index: 2147483000; padding: 7px 13px; border-radius: 999px; | |
| border: 1px solid var(--borderColor-default, #d0d7de); background: var(--bgColor-default, #fff); color: var(--fgColor-default, #1f2328); | |
| font: 600 12px/1.4 -apple-system, system-ui, sans-serif; cursor: pointer; box-shadow: 0 3px 12px rgba(0,0,0,.2); } | |
| #prna-launcher[data-pending="0"] { opacity: .7; } | |
| #prna-launcher[data-idle="true"] { opacity: .55; font-weight: 500; } | |
| #prna-launcher[data-idle="true"]:hover { opacity: 1; } | |
| #prna-launcher { display: flex; gap: 5px; align-items: center; } | |
| .prna-label { max-width: 0; overflow: hidden; white-space: nowrap; opacity: 0; | |
| transition: max-width .18s ease, opacity .18s ease, margin .18s ease; } | |
| #prna-launcher:hover .prna-label, #prna-launcher:focus-visible .prna-label { max-width: 320px; opacity: 1; margin-right: 3px; } | |
| .prna-count { min-width: 20px; padding: 1px 6px; border-radius: 999px; text-align: center; font-weight: 700; | |
| color: var(--fgColor-onEmphasis, #fff); } | |
| .prna-count[data-zero="true"] { background: none; color: var(--fgColor-muted, #656d76); font-weight: 400; } | |
| .prna-count-never { background: var(--bgColor-accent-emphasis, #0969da); } | |
| .prna-count-revisit { background: var(--bgColor-attention-emphasis, #9a6700); } | |
| .prna-count-parked { background: var(--bgColor-neutral-emphasis, #6e7781); } | |
| .prna-count-requested { background: var(--bgColor-done-emphasis, #8250df); } | |
| .prna-row[data-category="never"] { box-shadow: inset 3px 0 0 var(--bgColor-accent-emphasis, #0969da); } | |
| .prna-row[data-category="revisit"] { box-shadow: inset 3px 0 0 var(--bgColor-attention-emphasis, #9a6700); } | |
| .prna-row[data-category="parked"] { box-shadow: inset 3px 0 0 var(--bgColor-neutral-emphasis, #6e7781); color: var(--fgColor-muted, #656d76); } | |
| #prna-legend { display: flex; flex-wrap: nowrap; gap: 4px; padding: 5px 10px; font-size: 11px; white-space: nowrap; | |
| border-bottom: 1px solid var(--borderColor-muted, #d8dee4); } | |
| .prna-legend-item { display: flex; align-items: center; gap: 5px; cursor: pointer; | |
| border: 1px solid transparent !important; border-radius: 999px; padding: 1px 8px 1px 6px !important; | |
| background: none !important; color: inherit; font: inherit; opacity: .7; } | |
| .prna-legend-item:hover { opacity: 1; background: var(--bgColor-muted, #f6f8fa) !important; } | |
| .prna-legend-item[data-active="true"] { opacity: 1; font-weight: 600; | |
| border-color: var(--borderColor-default, #d0d7de) !important; background: var(--bgColor-muted, #f6f8fa) !important; } | |
| #prna-legend i { pointer-events: none; } | |
| #prna-legend[hidden] { display: none; } | |
| #prna-legend i { width: 9px; height: 9px; border-radius: 50%; display: inline-block; } | |
| #prna-panel { position: fixed; right: 18px; bottom: 58px; z-index: 2147483000; width: 560px; max-width: calc(100vw - 36px); | |
| max-height: 74vh; display: flex; flex-direction: column; border-radius: 10px; overflow: hidden; | |
| border: 1px solid var(--borderColor-default, #d0d7de); background: var(--bgColor-default, #fff); color: var(--fgColor-default, #1f2328); | |
| font: 13px/1.5 -apple-system, system-ui, sans-serif; box-shadow: 0 10px 40px rgba(0,0,0,.28); } | |
| #prna-panel header { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--borderColor-muted, #d8dee4); } | |
| #prna-panel header strong { flex: 1; font-size: 13px; } | |
| #prna-heading[data-back="true"] { cursor: pointer; color: var(--fgColor-accent, #0969da); } | |
| #prna-heading[data-back="true"]::before { content: '← '; } | |
| #prna-heading[data-back="true"]:hover { text-decoration: underline; } | |
| #prna-panel button { border: 1px solid var(--borderColor-default, #d0d7de); background: var(--bgColor-muted, #f6f8fa); | |
| color: inherit; border-radius: 6px; padding: 3px 8px; cursor: pointer; font-size: 12px; } | |
| #prna-progress { position: relative; height: 3px; overflow: hidden; background: var(--bgColor-muted, #f6f8fa); } | |
| #prna-progress::after { content: ''; position: absolute; inset: 0 auto 0 0; width: 40%; border-radius: 3px; | |
| background: var(--fgColor-accent, #0969da); animation: prna-slide 1.1s ease-in-out infinite; } | |
| @keyframes prna-slide { 0% { left: -40%; } 100% { left: 100%; } } | |
| @keyframes prna-pulse { 50% { opacity: .55; } } | |
| @keyframes prna-sweep { from { background-position: -60% 0; } to { background-position: 160% 0; } } | |
| #prna-launcher[data-refreshing="true"] { border-color: var(--fgColor-accent, #0969da); } | |
| #prna-launcher[data-refreshing="true"] .prna-count { animation: prna-pulse 1.1s ease-in-out infinite; } | |
| #prna-launcher[data-refreshing="true"]::after { content: ''; position: absolute; left: 7px; right: 7px; bottom: 3px; height: 2px; | |
| border-radius: 2px; background-image: linear-gradient(90deg, transparent, var(--fgColor-accent, #0969da), transparent); | |
| background-size: 60% 100%; background-repeat: no-repeat; animation: prna-sweep 1s linear infinite; } | |
| /* Dimmed through the colour, never through opacity: opacity on a scrolling box makes Chromium leave the previous paint behind. */ | |
| #prna-list[data-stale="true"] .prna-row, #prna-list[data-stale="true"] .prna-repo { color: var(--fgColor-muted, #656d76); } | |
| #prna-footer[data-refreshing="true"] { color: var(--fgColor-accent, #0969da); opacity: 1; font-weight: 600; } | |
| #prna-note { padding: 7px 12px; font-size: 11px; line-height: 1.45; | |
| color: var(--fgColor-attention, #9a6700); background: var(--bgColor-attention-muted, #fff8c5); | |
| border-bottom: 1px solid var(--borderColor-muted, #d8dee4); } | |
| #prna-list { overflow-y: auto; padding-bottom: 4px; } | |
| .prna-repo { position: sticky; top: 0; z-index: 1; padding: 6px 12px; font-size: 12px; font-weight: 700; | |
| background: var(--bgColor-muted, #f6f8fa); color: var(--fgColor-default, #1f2328); | |
| border-top: 1px solid var(--borderColor-muted, #d8dee4); border-bottom: 1px solid var(--borderColor-muted, #d8dee4); } | |
| /* Opaque on purpose: a row that lets anything show through it is unreadable the moment a paint lands behind it. */ | |
| .prna-row { display: block; position: relative; padding: 8px 12px; text-decoration: none; color: inherit; | |
| background: var(--bgColor-default, #fff); border-left: 3px solid transparent; } | |
| .prna-row:hover { background: var(--bgColor-muted, #f6f8fa); } | |
| .prna-row.prna-requested { border-left-color: var(--fgColor-attention, #9a6700); } | |
| .prna-title { display: block; font-weight: 500; } | |
| .prna-meta { display: block; margin-top: 2px; font-size: 11px; opacity: .65; } | |
| .prna-author { font-weight: 700; opacity: 1; color: var(--fgColor-default, #1f2328); } | |
| /* The green is painted on the count, which the names sit inside, so both fade together. */ | |
| .prna-approvers { color: inherit; font-style: italic; } | |
| .prna-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 5px; } | |
| #prna-list .prna-chip { margin: 0; } | |
| .prna-tag { display: inline-block; padding: 0 6px; border-radius: 999px; font-size: 10px; line-height: 16px; | |
| border: 1px solid var(--borderColor-default, #d0d7de); opacity: .85; } | |
| .prna-tag-warn { border-color: var(--fgColor-attention, #9a6700); color: var(--fgColor-attention, #9a6700); opacity: 1; } | |
| .prna-tag-quiet { opacity: .5; } | |
| #prna-status { padding: 14px 12px; font-size: 12px; opacity: .8; white-space: pre-wrap; } | |
| #prna-status a { color: var(--fgColor-accent, #0969da); } | |
| /* Translucent so it layers over whatever background the row already has, in either theme. */ | |
| [data-prna-row] { box-shadow: inset 3px 0 0 var(--prna-mark) !important; background-color: var(--prna-tint) !important; } | |
| [data-prna-row="never"] { --prna-mark: var(--bgColor-accent-emphasis, #0969da); --prna-tint: rgba(31, 111, 235, .13); } | |
| [data-prna-row="revisit"] { --prna-mark: var(--bgColor-attention-emphasis, #9a6700); --prna-tint: rgba(187, 128, 9, .13); } | |
| [data-prna-row="parked"] { --prna-mark: var(--bgColor-neutral-emphasis, #6e7781); --prna-tint: rgba(110, 118, 129, .09); } | |
| /* Tinted like Primer's own labels: an outline alone reads as washed out beside them. */ | |
| .prna-chip { display: inline-flex; align-items: center; gap: 5px; margin: 0 6px 0 10px; padding: 0 9px; | |
| border-radius: 2em; font: 500 12px/18px -apple-system, system-ui, sans-serif; vertical-align: middle; | |
| border: 1px solid transparent; } | |
| .prna-chip i { width: 7px; height: 7px; border-radius: 50%; flex: none; background: currentColor; } | |
| /* Its own class: .prna-approvers is the panel's list, and that style leaked into the row. */ | |
| .prna-approved-by { font-style: normal; font-weight: inherit; white-space: nowrap; | |
| margin-left: .35em; vertical-align: baseline; color: var(--fgColor-success, #1a7f37); } | |
| .prna-chip[data-category="never"] { background: var(--bgColor-accent-muted, #ddf4ff); | |
| color: var(--fgColor-accent, #0969da); border-color: var(--borderColor-accent-muted, rgba(84,174,255,.4)); } | |
| .prna-chip[data-category="revisit"] { background: var(--bgColor-attention-muted, #fff8c5); | |
| color: var(--fgColor-attention, #9a6700); border-color: var(--borderColor-attention-muted, rgba(212,167,44,.4)); } | |
| .prna-chip[data-category="parked"], .prna-chip[data-category="approved"] { background: var(--bgColor-neutral-muted, #eff1f3); | |
| color: var(--fgColor-muted, #656d76); border-color: var(--borderColor-neutral-muted, rgba(175,184,193,.4)); } | |
| .prna-chip[data-category="mine"] { background: var(--bgColor-done-muted, #fbefff); | |
| color: var(--fgColor-done, #8250df); border-color: var(--borderColor-done-muted, rgba(194,151,255,.4)); } | |
| .prna-subhead { display: block; margin: 10px 0 3px; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; opacity: .6; } | |
| .prna-permission { display: flex; align-items: center; gap: 6px; padding: 2px 0; } | |
| .prna-permission i { font-style: normal; opacity: .6; flex: 1; } | |
| .prna-repolist { margin: 2px 0 6px 12px; opacity: .7; max-height: 160px; overflow-y: auto; } | |
| #prna-footer { padding: 6px 12px; border-top: 1px solid var(--borderColor-muted, #d8dee4); font-size: 11px; opacity: .55; } | |
| ` | |
| document.head.appendChild(style) | |
| } | |
| function relativeTime(timestamp) { | |
| const minutes = Math.round((Date.now() - timestamp) / 60000) | |
| if (minutes < 1) return 'just now' | |
| if (minutes < 60) return `${minutes}m ago` | |
| if (minutes < 1440) return `${Math.round(minutes / 60)}h ago` | |
| const days = Math.round(minutes / 1440) | |
| return days === 1 ? 'yesterday' : `${days}d ago` | |
| } | |
| function appendFact(parent, label, isoDate) { | |
| if (parent.childNodes.length) parent.appendChild(document.createTextNode(' · ')) | |
| const fact = document.createElement('span') | |
| fact.textContent = isoDate ? `${label} ${relativeTime(new Date(isoDate).getTime())}` : `${label} —` | |
| if (isoDate) { | |
| fact.title = new Date(isoDate).toLocaleString(undefined, { | |
| weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', | |
| }) | |
| } | |
| parent.appendChild(fact) | |
| } | |
| function appendTag(parent, label, variant) { | |
| const tag = document.createElement('span') | |
| tag.className = variant ? `prna-tag ${variant}` : 'prna-tag' | |
| tag.textContent = label | |
| parent.appendChild(tag) | |
| } | |
| /** @return boolean true when the browser opens the link beside this page instead of navigating it: a modifier, or any button but the left one */ | |
| function clickKeepsCurrentPage(event) { | |
| return event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey | |
| } | |
| function buildRow(pullRequest, onOpen, maxApprovals) { | |
| const row = document.createElement('a') | |
| row.className = pullRequest.reviewRequestedFromMe ? 'prna-row prna-requested' : 'prna-row' | |
| row.dataset.category = categoryOf(pullRequest) | |
| row.href = pullRequest.url | |
| row.addEventListener('click', (event) => { | |
| // Opening the pull request in a new tab leaves this page up, so the list must stay open to pick the next one from. | |
| if (clickKeepsCurrentPage(event)) return | |
| onOpen(event) | |
| }) | |
| const title = document.createElement('span') | |
| title.className = 'prna-title' | |
| title.textContent = `#${pullRequest.number} ${pullRequest.title}` | |
| row.appendChild(title) | |
| const meta = document.createElement('span') | |
| meta.className = 'prna-meta' | |
| const author = document.createElement('span') | |
| author.className = 'prna-author' | |
| author.textContent = pullRequest.authorLogin | |
| meta.appendChild(author) | |
| appendFact(meta, 'created', pullRequest.createdAt) | |
| appendFact(meta, 'pushed', pullRequest.lastPushAt) | |
| const approvers = pullRequest.approvers || [] | |
| const counted = document.createElement('span') | |
| counted.className = 'prna-approvals' | |
| counted.textContent = ` · ${approvers.length} approval${approvers.length === 1 ? '' : 's'}` | |
| if (approvers.length) { | |
| paintApprovalCount(counted, approvers.length, maxApprovals) | |
| const names = document.createElement('span') | |
| names.className = 'prna-approvers' | |
| names.textContent = ` ${approvers.join(', ')}` | |
| counted.appendChild(names) | |
| } | |
| meta.appendChild(counted) | |
| row.appendChild(meta) | |
| const tags = document.createElement('span') | |
| tags.className = 'prna-tags' | |
| tags.appendChild(buildChip(categoryOf(pullRequest), primaryTag(pullRequest), pullRequest.approvers || [], chipSignature(pullRequest))) | |
| if (pullRequest.approvedByMe) appendTag(tags, 'your approval still stands', 'prna-tag-quiet') | |
| if (pullRequest.changesRequestedByMe) appendTag(tags, 'you requested changes') | |
| if (pullRequest.myCommentKind === 'inline') appendTag(tags, 'commented inline', 'prna-tag-quiet') | |
| row.appendChild(tags) | |
| return row | |
| } | |
| function renderRows(list, pullRequests, onlyRequested, activeCategories, onOpen) { | |
| setListChrome(true) | |
| list.textContent = '' | |
| const visible = pullRequests | |
| .filter((pullRequest) => !(onlyRequested && !pullRequest.reviewRequestedFromMe)) | |
| .filter((pullRequest) => activeCategories.size === 0 || activeCategories.has(categoryOf(pullRequest))) | |
| if (visible.length === 0) { | |
| const empty = document.createElement('div') | |
| empty.id = 'prna-status' | |
| empty.textContent = pullRequests.length === 0 | |
| ? 'Nothing left — every open PR already carries your approval.' | |
| : `Nothing matches: the filters above hide all ${pullRequests.length} of them.` | |
| list.appendChild(empty) | |
| return 0 | |
| } | |
| // Ranked against every row the panel holds, so a filter never repaints the rows it leaves visible. | |
| const maxApprovals = maxApprovalCount(pullRequests) | |
| // Rows arrive newest-first, so grouping here keeps that order inside each repo and ranks repos by their newest PR. | |
| const groups = new Map() | |
| visible.forEach((pullRequest) => { | |
| if (!groups.has(pullRequest.repository)) groups.set(pullRequest.repository, []) | |
| groups.get(pullRequest.repository).push(pullRequest) | |
| }) | |
| groups.forEach((rows, repository) => { | |
| const repositoryHeading = document.createElement('div') | |
| repositoryHeading.className = 'prna-repo' | |
| repositoryHeading.textContent = repository | |
| list.appendChild(repositoryHeading) | |
| rows.forEach((pullRequest) => list.appendChild(buildRow(pullRequest, onOpen, maxApprovals))) | |
| }) | |
| return visible.length | |
| } | |
| function appendLink(parent, href, text) { | |
| const link = document.createElement('a') | |
| link.href = href | |
| link.target = '_blank' | |
| link.rel = 'noopener' | |
| link.textContent = text | |
| parent.appendChild(link) | |
| return link | |
| } | |
| function appendPasteButton(parent, onTokenSaved, caption) { | |
| const pasteButton = document.createElement('button') | |
| pasteButton.textContent = caption || 'Paste token' | |
| pasteButton.addEventListener('click', () => { | |
| const answer = window.prompt(`Paste the "${tokenName()}" token:`, readSetting(TOKEN_KEY)) | |
| if (answer === null || !answer.trim()) return | |
| writeSetting(TOKEN_KEY, answer.trim()) | |
| deleteSetting(SCOPE_KEY) | |
| deleteSetting(cacheKeyFor(currentOwner())) | |
| onTokenSaved() | |
| }) | |
| parent.appendChild(pasteButton) | |
| } | |
| let rowsVisible = true | |
| /** The legend and the filters describe a row list, so every other view hides them. */ | |
| function setListChrome(visible) { | |
| rowsVisible = visible | |
| const heading = document.getElementById('prna-heading') | |
| if (heading) { | |
| heading.dataset.back = String(!visible) | |
| heading.title = visible ? '' : 'Back to the list' | |
| } | |
| const legend = document.getElementById('prna-legend') | |
| if (legend) legend.hidden = !visible | |
| } | |
| function appendLine(parent, caption, value) { | |
| const line = document.createElement('div') | |
| const label = document.createElement('b') | |
| label.textContent = `${caption.padEnd(14)} ` | |
| line.append(label, document.createTextNode(value)) | |
| parent.appendChild(line) | |
| } | |
| /** @return string what the probe found out about the account in the page URL */ | |
| function describeOwnerAccess(owner, scope) { | |
| if (!owner) return 'this page belongs to no account, so nothing was probed' | |
| if (!scope) return `${owner} — could not be probed` | |
| if (scope.visiblePullRequests === null) return owner | |
| if (scope.visiblePullRequests > 0) return `${owner} — ${scope.visiblePullRequests} pull requests visible` | |
| return `${owner} — no pull request visible, so the token most likely cannot read it` | |
| } | |
| /** Reports what the stored token is and what it reaches, so replacing it is an informed decision. */ | |
| async function renderTokenDetails(list, onTokenSaved) { | |
| const token = readSetting(TOKEN_KEY) | |
| if (!token) return renderSetup(list, onTokenSaved) | |
| setListChrome(false) | |
| list.textContent = '' | |
| const status = document.createElement('div') | |
| status.id = 'prna-status' | |
| status.textContent = 'Checking the token…' | |
| list.appendChild(status) | |
| const owner = currentOwner() | |
| let scope = null | |
| let failure = '' | |
| try { | |
| scope = await probeToken(token, owner) | |
| } catch (error) { | |
| failure = error.message.split('\n')[0] | |
| } | |
| status.textContent = '' | |
| const cache = readCache() | |
| const unavailable = (cache && cache.unavailable) || [] | |
| const granted = Object.keys(OPTIONAL_FIELDS).filter((field) => !unavailable.includes(field)) | |
| appendLine(status, 'Token', maskToken(token)) | |
| appendLine(status, 'Type', scope && scope.oauthScopes ? `classic — scopes: ${scope.oauthScopes}` : 'fine-grained') | |
| appendLine(status, 'Identifies as', scope ? scope.login : '—') | |
| appendLine(status, 'Member of', scope && scope.owners.length ? scope.owners.join(', ') : 'no organisation it can name') | |
| appendLine(status, 'Reads', describeOwnerAccess(owner, scope)) | |
| appendLine(status, 'Can read', granted.length ? granted.map((field) => OPTIONAL_FIELDS[field].permission).join(', ') : 'nothing optional') | |
| if (unavailable.length) appendLine(status, 'Refused', unavailable.map((field) => OPTIONAL_FIELDS[field].permission).join(', ')) | |
| if (failure) appendLine(status, 'Error', failure) | |
| appendLine(status, 'Checked', new Date().toLocaleTimeString()) | |
| status.appendChild(document.createTextNode('\n')) | |
| appendPasteButton(status, onTokenSaved, 'Replace token') | |
| const recheck = document.createElement('button') | |
| recheck.textContent = owner ? `Re-check this token against ${owner}` : 'Re-check this token' | |
| recheck.title = 'Asks the API again who the token is and what it may read. Nothing is stored or changed.' | |
| recheck.addEventListener('click', () => renderTokenDetails(list, onTokenSaved)) | |
| status.appendChild(document.createTextNode(' ')) | |
| status.appendChild(recheck) | |
| const hint = document.createElement('div') | |
| hint.appendChild(document.createTextNode('\nA fine-grained token names no organisation here unless it was granted organisation read. That does not affect the list: the search is scoped to the account in the page URL.')) | |
| status.appendChild(hint) | |
| list.appendChild(status) | |
| } | |
| function renderSetup(list, onTokenSaved) { | |
| setListChrome(false) | |
| list.textContent = '' | |
| const status = document.createElement('div') | |
| status.id = 'prna-status' | |
| status.appendChild(document.createTextNode('One-time setup. ')) | |
| appendLink(status, TOKEN_SETUP_URL, 'Create a fine-grained token') | |
| status.appendChild(document.createTextNode(`, then set: | |
| Token name ${tokenName()} | |
| Resource owner the org whose PRs you review | |
| Repository access All repositories | |
| Permissions Pull requests: Read-only | |
| Contents: Read-only | |
| Metadata: Read-only is added for you. Contents supplies the last push date, without which the amber "pushed since" count stays at 0. | |
| If the org requires approval, an owner has to approve the token before it answers. | |
| `)) | |
| appendPasteButton(status, onTokenSaved) | |
| const fallback = document.createElement('div') | |
| fallback.appendChild(document.createTextNode('\n\nIf the panel then reports it cannot read the results, use a ')) | |
| appendLink(fallback, classicTokenUrl(), 'classic token with the repo scope') | |
| fallback.appendChild(document.createTextNode(' instead.')) | |
| status.appendChild(fallback) | |
| list.appendChild(status) | |
| } | |
| /** Shows a partial or empty search result for what it is: a token that cannot see the org, not an empty backlog. */ | |
| function renderAccessProblem(list, summary, onTokenSaved, onRefreshScope) { | |
| setListChrome(false) | |
| list.textContent = '' | |
| const status = document.createElement('div') | |
| status.id = 'prna-status' | |
| status.appendChild(document.createTextNode(`${summary} | |
| The count above is therefore not trustworthy. A token that the org has not approved, or whose repository access excludes these repos, returns empty or null results rather than an error. | |
| Check the resource owner and the org approval of "${tokenName()}" in your token settings, or switch to a `)) | |
| appendLink(status, classicTokenUrl(), 'classic token with the repo scope') | |
| status.appendChild(document.createTextNode('.\n\n')) | |
| appendPasteButton(status, onTokenSaved) | |
| if (onRefreshScope) { | |
| const detailsButton = document.createElement('button') | |
| detailsButton.textContent = 'Inspect token' | |
| detailsButton.addEventListener('click', onRefreshScope) | |
| status.appendChild(document.createTextNode(' ')) | |
| status.appendChild(detailsButton) | |
| } | |
| list.appendChild(status) | |
| } | |
| function appendButton(parent, caption, onClick, title) { | |
| const button = document.createElement('button') | |
| button.textContent = caption | |
| if (title) button.title = title | |
| button.addEventListener('click', onClick) | |
| parent.appendChild(button) | |
| return button | |
| } | |
| function appendRemovable(parent, entry, onRemove) { | |
| const line = document.createElement('div') | |
| line.className = 'prna-permission' | |
| line.appendChild(document.createTextNode(entry)) | |
| appendButton(line, '✕', onRemove, `Stop watching ${entry}`) | |
| parent.appendChild(line) | |
| } | |
| const allowedOwner = (owner) => readEnabled().owners.includes(owner) | |
| const allowedRepo = (full) => readEnabled().repos.includes(full) | |
| function toggleOwner(owner) { | |
| const enabled = readEnabled() | |
| const owners = allowedOwner(owner) ? enabled.owners.filter((entry) => entry !== owner) : enabled.owners.concat(owner) | |
| writeEnabled({ owners, repos: enabled.repos }) | |
| deleteSetting(cacheKeyFor(`owner:${owner}`)) | |
| } | |
| function toggleRepo(full) { | |
| const enabled = readEnabled() | |
| const repos = allowedRepo(full) ? enabled.repos.filter((entry) => entry !== full) : enabled.repos.concat(full) | |
| writeEnabled({ owners: enabled.owners, repos }) | |
| deleteSetting(cacheKeyFor(`repo:${full}`)) | |
| } | |
| /** | |
| * One line per scope: what it is, whether it is allowed, what the token can see of it, and a button that flips it. | |
| * @param check resolves to a sentence about the token's reach, so allowing is an informed click | |
| */ | |
| function appendScopeLine(parent, label, isAllowed, onToggle, check) { | |
| const line = document.createElement('div') | |
| line.className = 'prna-permission' | |
| const name = document.createElement('b') | |
| name.textContent = label | |
| const state = document.createElement('i') | |
| state.textContent = isAllowed ? 'allowed · checking…' : 'not allowed · checking…' | |
| line.append(name, state) | |
| appendButton(line, isAllowed ? 'Revoke' : 'Allow', onToggle) | |
| parent.appendChild(line) | |
| check() | |
| .then((reach) => { state.textContent = `${isAllowed ? 'allowed' : 'not allowed'} · ${reach}` }) | |
| .catch((error) => { state.textContent = `${isAllowed ? 'allowed' : 'not allowed'} · ${error.message.split('\n')[0]}` }) | |
| } | |
| /** @param showAll true to list every allowed scope, not only the ones this page is in */ | |
| function renderPermissions(list, onChanged, showAll) { | |
| setListChrome(false) | |
| list.textContent = '' | |
| const status = document.createElement('div') | |
| status.id = 'prna-status' | |
| list.appendChild(status) | |
| const token = readSetting(TOKEN_KEY) | |
| const { owner, repo } = currentLocation() | |
| const full = `${owner}/${repo}` | |
| const again = (all) => { renderPermissions(list, onChanged, all); onChanged() } | |
| const heading = (caption) => { | |
| const element = document.createElement('b') | |
| element.className = 'prna-subhead' | |
| element.textContent = caption | |
| status.appendChild(element) | |
| } | |
| const ownerReach = () => fetchOwnerRepositories(token, owner) | |
| .then((result) => `${result.nodes.length} of ${result.total} repositories visible to the token`) | |
| const repoReach = () => fetchRepositoryAccess(token, owner, repo) | |
| .then((repository) => (repository ? `readable, your permission is ${repository.viewerPermission.toLowerCase()}` : 'the token cannot read it')) | |
| if (owner) { | |
| heading('This page') | |
| appendScopeLine(status, `${owner}/*`, allowedOwner(owner), () => { toggleOwner(owner); again(showAll) }, ownerReach) | |
| if (repo) appendScopeLine(status, full, allowedRepo(full), () => { toggleRepo(full); again(showAll) }, repoReach) | |
| } | |
| heading('Everything allowed') | |
| const enabled = readEnabled() | |
| const total = enabled.owners.length + enabled.repos.length | |
| appendButton(status, showAll ? 'Hide the list' : `Show the list (${total})`, () => renderPermissions(list, onChanged, !showAll)) | |
| if (showAll) { | |
| const shownAbove = new Set(owner ? [owner] : []) | |
| const listedOwners = enabled.owners.filter((entry) => !shownAbove.has(entry)) | |
| const listedRepos = enabled.repos.filter((entry) => entry !== full) | |
| if (total === 0) status.appendChild(document.createTextNode('\nNothing is allowed yet.')) | |
| else if (listedOwners.length + listedRepos.length === 0) status.appendChild(document.createTextNode('\nOnly what is shown above.')) | |
| listedOwners.forEach((entry) => { | |
| appendScopeLine(status, `${entry}/*`, true, () => { toggleOwner(entry); again(true) }, | |
| () => fetchOwnerRepositories(token, entry).then((result) => `${result.nodes.length} of ${result.total} repositories visible to the token`)) | |
| }) | |
| listedRepos.forEach((entry) => { | |
| const [entryOwner, entryName] = entry.split('/') | |
| appendScopeLine(status, entry, true, () => { toggleRepo(entry); again(true) }, | |
| () => fetchRepositoryAccess(token, entryOwner, entryName) | |
| .then((repository) => (repository ? `readable, your permission is ${repository.viewerPermission.toLowerCase()}` : 'the token cannot read it'))) | |
| }) | |
| } | |
| heading('Token') | |
| appendLine(status, 'Stored', token ? maskToken(token) : 'none') | |
| status.appendChild(document.createTextNode('\n')) | |
| appendPasteButton(status, () => again(showAll), token ? 'Replace token' : 'Paste token') | |
| status.appendChild(document.createTextNode(' ')) | |
| appendButton(status, 'Inspect token', () => renderTokenDetails(list, onChanged)) | |
| status.appendChild(document.createTextNode('\n')) | |
| appendLink(status, TOKEN_LIST_URL, 'Change this token’s repository access on GitHub') | |
| status.appendChild(document.createTextNode(', then Allow again to re-check.')) | |
| } | |
| function renderNoOwner(list) { | |
| setListChrome(false) | |
| list.textContent = '' | |
| const status = document.createElement('div') | |
| status.id = 'prna-status' | |
| status.textContent = 'This page belongs to no account. Open an organisation or repository page to see its pull requests.' | |
| list.appendChild(status) | |
| } | |
| /** Names what the token could not fetch and the permission that would bring it back. */ | |
| function renderNote(note, unavailable) { | |
| note.hidden = unavailable.length === 0 | |
| if (note.hidden) return | |
| note.textContent = '' | |
| note.appendChild(document.createTextNode(`Missing ${unavailable.map((field) => OPTIONAL_FIELDS[field].lost).join(', ')}. `)) | |
| note.appendChild(document.createTextNode(`Add to "${tokenName()}": ${[...new Set(unavailable.map((field) => OPTIONAL_FIELDS[field].permission))].join(', ')}.`)) | |
| } | |
| function updateLauncher(launcher, pullRequests) { | |
| const counts = countByCategory(pullRequests) | |
| // An account the token cannot read returns nothing, and so does one with nothing pending: neither deserves a badge. | |
| launcher.hidden = pullRequests.length === 0 && !document.getElementById('prna-panel') | |
| launcher.dataset.idle = 'false' | |
| launcher.dataset.pending = String(counts[0] + counts[1]) | |
| launcher.textContent = '' | |
| launcher.title = CATEGORIES.map((category, index) => `${counts[index]} ${category.hint}`).join('\n') | |
| const label = document.createElement('span') | |
| label.className = 'prna-label' | |
| label.textContent = `To approve in ${(activeScope() || {}).label || currentOwner()}` | |
| launcher.appendChild(label) | |
| CATEGORIES.forEach((category, index) => { | |
| const chip = document.createElement('span') | |
| chip.className = `prna-count prna-count-${category.key}` | |
| chip.dataset.zero = String(counts[index] === 0) | |
| chip.textContent = String(counts[index]) | |
| launcher.appendChild(chip) | |
| }) | |
| } | |
| function buildPanel(launcher) { | |
| const panel = document.createElement('div') | |
| panel.id = 'prna-panel' | |
| const header = document.createElement('header') | |
| const heading = document.createElement('strong') | |
| heading.id = 'prna-heading' | |
| heading.textContent = 'PRs to approve' | |
| heading.addEventListener('click', () => { | |
| if (heading.dataset.back === 'true') load(false) | |
| }) | |
| const refreshButton = document.createElement('button') | |
| refreshButton.textContent = 'Refresh' | |
| const tokenButton = document.createElement('button') | |
| tokenButton.textContent = 'Permissions' | |
| const closeButton = document.createElement('button') | |
| closeButton.textContent = '✕' | |
| header.append(heading, refreshButton, tokenButton, closeButton) | |
| const activeCategories = new Set() | |
| let onlyRequested = false | |
| const legend = document.createElement('div') | |
| legend.id = 'prna-legend' | |
| const addFilter = (key, label, hint, onToggle, isActive) => { | |
| const item = document.createElement('button') | |
| item.className = 'prna-legend-item' | |
| item.dataset.filter = key | |
| item.dataset.active = 'false' | |
| item.title = `Show only: ${hint}` | |
| const dot = document.createElement('i') | |
| dot.className = `prna-count-${key}` | |
| item.append(dot, document.createTextNode(label)) | |
| item.addEventListener('click', () => { | |
| onToggle() | |
| legend.querySelectorAll('.prna-legend-item').forEach((button) => { | |
| button.dataset.active = String(isActiveByKey[button.dataset.filter]()) | |
| }) | |
| repaint() | |
| }) | |
| legend.appendChild(item) | |
| isActiveByKey[key] = isActive | |
| } | |
| const isActiveByKey = {} | |
| CATEGORIES.forEach((category) => { | |
| addFilter(category.key, category.label, category.hint, | |
| () => (activeCategories.has(category.key) ? activeCategories.delete(category.key) : activeCategories.add(category.key)), | |
| () => activeCategories.has(category.key)) | |
| }) | |
| addFilter('requested', 'requested from me', 'pull requests whose review is requested from you', | |
| () => { onlyRequested = !onlyRequested }, () => onlyRequested) | |
| const progress = document.createElement('div') | |
| progress.id = 'prna-progress' | |
| progress.hidden = true | |
| const note = document.createElement('div') | |
| note.id = 'prna-note' | |
| note.hidden = true | |
| const list = document.createElement('div') | |
| list.id = 'prna-list' | |
| const footer = document.createElement('div') | |
| footer.id = 'prna-footer' | |
| panel.append(header, progress, legend, note, list, footer) | |
| document.body.appendChild(panel) | |
| let pullRequests = [] | |
| let fetchedAt = Date.now() | |
| let problem = '' | |
| let ownerLabel = (activeScope() || {}).label || currentOwner() | |
| const closePanel = () => removePanel(panel) | |
| /** | |
| * Closes the panel on Escape and lets nothing else see the key: GitHub binds it too, to mark a file as reviewed. | |
| * A field keeps its own Escape, which is the only way out of a comment box. | |
| */ | |
| const closeOnEscape = (event) => { | |
| if (event.key !== 'Escape' || event.defaultPrevented) return | |
| if (event.target && event.target.closest && event.target.closest('input, textarea, select, [contenteditable="true"]')) return | |
| event.preventDefault() | |
| event.stopImmediatePropagation() | |
| closePanel() | |
| } | |
| // Captured, so it runs before the handlers GitHub binds on the document. | |
| document.addEventListener('keydown', closeOnEscape, true) | |
| panel.addEventListener('prna-closed', () => document.removeEventListener('keydown', closeOnEscape, true)) | |
| function repaint() { | |
| // Permissions and the token report must not be torn down by a background reload; the badge still updates. | |
| if (!rowsVisible) return updateLauncher(launcher, pullRequests) | |
| if (problem) { | |
| progress.hidden = !refreshing | |
| launcher.dataset.refreshing = String(refreshing || isRefreshing()) | |
| heading.textContent = 'PRs to approve' | |
| footer.textContent = `Updated ${relativeTime(fetchedAt)} · ${ownerLabel}` | |
| launcher.textContent = 'To approve · check token' | |
| renderAccessProblem(list, problem, () => load(true), () => renderPermissions(list, reloadQuietly, false)) | |
| return | |
| } | |
| const visibleCount = renderRows(list, pullRequests, onlyRequested, activeCategories, closePanel) | |
| heading.textContent = `PRs to approve (${visibleCount})` | |
| footer.textContent = refreshing | |
| ? `Refreshing… · showing data from ${relativeTime(fetchedAt)}` | |
| : `Updated ${relativeTime(fetchedAt)} · ${ownerLabel}` | |
| progress.hidden = !refreshing | |
| footer.dataset.refreshing = String(refreshing) | |
| list.dataset.stale = String(refreshing) | |
| launcher.dataset.refreshing = String(refreshing || isRefreshing()) | |
| refreshButton.disabled = refreshing | |
| refreshButton.textContent = refreshing ? 'Refreshing' : 'Refresh' | |
| updateLauncher(launcher, pullRequests) | |
| } | |
| let refreshing = false | |
| function apply(result) { | |
| pullRequests = result.pullRequests | |
| fetchedAt = result.fetchedAt | |
| ownerLabel = result.scopeLabel || ownerLabel | |
| problem = accessProblem(result) | |
| renderNote(note, result.unavailable || []) | |
| repaint() | |
| } | |
| /** Reloads the data without leaving the current view. */ | |
| const reloadQuietly = () => loadPullRequests(true) | |
| .then((result) => { | |
| pullRequests = result.pullRequests | |
| fetchedAt = result.fetchedAt | |
| problem = accessProblem(result) | |
| updateLauncher(launcher, result.pullRequests) | |
| }) | |
| .catch(() => undefined) | |
| async function load(force) { | |
| setListChrome(true) | |
| let painted = false | |
| const showCached = (result, settled) => { | |
| refreshing = !settled | |
| apply(result) | |
| painted = true | |
| } | |
| if (!painted) { | |
| list.textContent = '' | |
| const status = document.createElement('div') | |
| status.id = 'prna-status' | |
| status.textContent = 'Loading…' | |
| list.appendChild(status) | |
| } | |
| try { | |
| const result = await loadPullRequests(force, showCached) | |
| refreshing = false | |
| apply(await refreshPullRequest(result, openPullRequestLocation())) | |
| } catch (error) { | |
| refreshing = false | |
| if (error.message === NO_OWNER) renderNoOwner(list) | |
| else if (error.message === NOT_ENABLED) renderPermissions(list, reloadQuietly, false) | |
| else if (error.message === MISSING_TOKEN) renderSetup(list, () => load(true)) | |
| else if (painted) footer.textContent = `${error.message.split('\n')[0]} · showing the cached list` | |
| else { | |
| list.textContent = '' | |
| const status = document.createElement('div') | |
| status.id = 'prna-status' | |
| status.textContent = `${error.message}\n\nUse "Token" to set a valid one.` | |
| list.appendChild(status) | |
| } | |
| } | |
| } | |
| const unsubscribe = onCacheUpdated((cache) => apply(cache)) | |
| panel.addEventListener('prna-closed', unsubscribe) | |
| refreshButton.addEventListener('click', () => load(true)) | |
| tokenButton.addEventListener('click', () => renderPermissions(list, reloadQuietly, false)) | |
| closeButton.addEventListener('click', closePanel) | |
| load(false) | |
| } | |
| /** Removes the panel through its own close event, so its document listeners come off with it. */ | |
| function removePanel(panel) { | |
| if (!panel) return | |
| panel.dispatchEvent(new CustomEvent('prna-closed')) | |
| panel.remove() | |
| } | |
| function openPanel(launcher) { | |
| const existing = document.getElementById('prna-panel') | |
| if (existing) removePanel(existing) | |
| else buildPanel(launcher) | |
| } | |
| function mountLauncher() { | |
| // Consumed by the first mount, whatever the page: every later mount comes from a soft navigation. | |
| const maxAgeMs = consumeHardReload() ? RELOAD_CACHE_TTL_MS : CACHE_TTL_MS | |
| const existing = document.getElementById('prna-launcher') | |
| const leaving = existing && (existing.dataset.owner !== currentOwner() || !onPullRequestSurface()) | |
| if (existing && !leaving) return | |
| if (leaving) { | |
| removePanel(document.getElementById('prna-panel')) | |
| existing.remove() | |
| } | |
| if (!onPullRequestSurface()) return | |
| const launcher = document.createElement('button') | |
| launcher.dataset.owner = currentOwner() | |
| launcher.id = 'prna-launcher' | |
| launcher.hidden = true | |
| launcher.textContent = 'To approve…' | |
| // A soft navigation rebuilds the launcher while a refetch may be running. | |
| launcher.dataset.refreshing = String(isRefreshing()) | |
| launcher.addEventListener('click', () => openPanel(launcher)) | |
| document.body.appendChild(launcher) | |
| const showCount = (result) => { | |
| if (accessProblem(result)) launcher.hidden = true | |
| else updateLauncher(launcher, result.pullRequests) | |
| } | |
| onCacheUpdated((result) => { showCount(result); decorateSafely() }) | |
| loadPullRequests(false, showCount, maxAgeMs) | |
| .then(showCount) | |
| .catch((error) => { | |
| if (error.message === NO_OWNER) return | |
| // Setup, enabling and failures stay visible: they are the only way back into the panel. | |
| launcher.hidden = false | |
| launcher.dataset.idle = String(error.message === NOT_ENABLED) | |
| if (error.message === NOT_ENABLED) launcher.textContent = 'List of PRs to approve' | |
| else launcher.textContent = error.message === MISSING_TOKEN ? 'To approve · set up' : 'To approve · error' | |
| }) | |
| if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand('GitHub PRs to approve', () => { | |
| launcher.hidden = false | |
| openPanel(launcher) | |
| }) | |
| } | |
| let hardReloadPending = (performance.getEntriesByType('navigation')[0] || {}).type === 'reload' | |
| /** @return boolean true for the first call after a hard reload only */ | |
| function consumeHardReload() { | |
| const reloaded = hardReloadPending | |
| hardReloadPending = false | |
| return reloaded | |
| } | |
| let lastPath = window.location.pathname | |
| /** GitHub navigates without reloading, so the panel has to be dismissed by watching the path itself. */ | |
| function handleNavigation() { | |
| if (window.location.pathname === lastPath) return mountLauncher() | |
| lastPath = window.location.pathname | |
| removePanel(document.getElementById('prna-panel')) | |
| mountLauncher() | |
| decorateSafely() | |
| } | |
| migrateStorageKeys() | |
| injectStyles() | |
| mountLauncher() | |
| watchLists() | |
| watchForReviewSubmission() | |
| window.addEventListener('popstate', handleNavigation) | |
| // A soft navigation fires no event of its own, and the body swap it performs also drops the launcher. | |
| setInterval(handleNavigation, 500) | |
| })() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment