From 34f2d48670ca26a82c0b035ce10bcf25d163f565 Mon Sep 17 00:00:00 2001 From: Payton Swick Date: Mon, 9 Sep 2024 10:27:02 -0400 Subject: [PATCH] Refactor to support multiple accounts (#187) * Add basic structure for multiple accounts * Add octokit * Move note fetching from gitnews to octokit in main process * Remove gitnews as dependency * Temporarily disable marking as read * Change note fetching to use actual account info * Add comment avatar to each note * Add subject data to notes * Make both comment and subject required but provide comment fallback This matches the behavior of gitnews. * Add gitnewsAccountId to each note * Add mark as read functionality using Octokit * Fix markNoficationRead for default account * Add proxy support for github requests * Add input form for account proxy * Fix baseUrl for Github Enterprise Server * Add support for socks proxies * Log github fetch errors to main logger * Add account edit page * Remove editing for main token * Re-fetch when accounts change * Add migration from old token to new account * Improve logging about notifications retrieved --- package.json | 16 +- src/main/index.ts | 58 +++---- src/main/lib/github-interface.ts | 176 +++++++++++++++++++++ src/main/lib/logging.ts | 29 ++++ src/main/lib/main-store.ts | 4 - src/preload.ts | 7 +- src/renderer/components/account-edit.tsx | 121 ++++++++++++++ src/renderer/components/account-list.tsx | 53 +++++++ src/renderer/components/add-token-form.tsx | 65 -------- src/renderer/components/app.tsx | 43 +++-- src/renderer/components/config-page.tsx | 8 +- src/renderer/components/main-pane.tsx | 68 +++++--- src/renderer/lib/config-middleware.ts | 6 - src/renderer/lib/constants.ts | 12 +- src/renderer/lib/demo-mode.ts | 3 + src/renderer/lib/github-middleware.ts | 27 ++-- src/renderer/lib/gitnews-fetcher.ts | 68 ++++---- src/renderer/lib/reducer.ts | 33 ++-- src/renderer/styles.css | 109 ++++++++----- src/renderer/types.ts | 72 +++------ src/shared-types.ts | 58 +++++++ yarn.lock | 156 ++++++++++++++---- 22 files changed, 858 insertions(+), 334 deletions(-) create mode 100644 src/main/lib/github-interface.ts create mode 100644 src/main/lib/logging.ts create mode 100644 src/renderer/components/account-edit.tsx create mode 100644 src/renderer/components/account-list.tsx delete mode 100644 src/renderer/components/add-token-form.tsx create mode 100644 src/shared-types.ts diff --git a/package.json b/package.json index 8792551..34012da 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,15 @@ "package": "electron-forge package", "make": "electron-forge make" }, - "keywords": ["menu", "github", "notifications"], + "keywords": [ + "menu", + "github", + "notifications" + ], "author": "Payton Swick ", "license": "MIT", "dependencies": { + "@octokit/rest": "^21.0.2", "@types/semver": "^7.5.8", "date-fns": "^3.6.0", "debug": "^4.3.7", @@ -29,7 +34,7 @@ "electron-squirrel-startup": "^1.0.1", "electron-store": "^8.2.0", "electron-unhandled": "^5.0.0", - "gitnews": "^3.1.3", + "fetch-socks": "^1.3.0", "gridicons": "^3.4.2", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -41,7 +46,8 @@ "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", "semver": "^7.6.3", - "source-map-support": "^0.5.19" + "source-map-support": "^0.5.19", + "undici": "^6.19.8" }, "devDependencies": { "@babel/plugin-proposal-class-properties": "^7.18.6", @@ -86,6 +92,8 @@ } }, "lint-staged": { - "*.{js,ts,tsx}": ["prettier --write"] + "*.{js,ts,tsx}": [ + "prettier --write" + ] } } diff --git a/src/main/index.ts b/src/main/index.ts index fe70a31..9c46770 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,19 +9,19 @@ import { import { menubar } from 'menubar'; import isDev from 'electron-is-dev'; import electronDebug from 'electron-debug'; -import { - setToken, - getToken, - isLoggingEnabled, - toggleLogging, -} from './lib/main-store'; +import { getToken, toggleLogging } from './lib/main-store'; import { getIconForState } from './lib/icon-path'; import { version } from '../../package.json'; import unhandled from 'electron-unhandled'; import debugFactory from 'debug'; -import log from 'electron-log'; import AutoLaunch from 'easy-auto-launch'; import dotEnv from 'dotenv'; +import { + fetchNotificationsForAccount, + markNotficationAsRead, +} from './lib/github-interface'; +import { logMessage } from './lib/logging'; +import type { AccountInfo, Note } from '../shared-types'; // These are provided by electron forge declare const MAIN_WINDOW_WEBPACK_ENTRY: string; @@ -41,27 +41,6 @@ electronDebug(); let lastIconState = 'loading'; -// Only use this function for logging! -function logMessage(message: string, level: 'info' | 'warn' | 'error'): void { - debug(message); - if (!isLoggingEnabled()) { - return; - } - switch (level) { - case 'info': - log.info(message); - break; - case 'warn': - log.warn(message); - break; - case 'error': - log.error(message); - break; - default: - log.error(`Unknown log level '${level}': ${message}`); - } -} - const bar = menubar({ preloadWindow: true, index: MAIN_WINDOW_WEBPACK_ENTRY, @@ -139,15 +118,6 @@ ipcMain.on('quit-app', () => { app.quit(); }); -ipcMain.on('save-token', (_event, token: unknown) => { - if (typeof token !== 'string') { - logMessage('Failed to save token: it is invalid', 'error'); - return; - } - setToken(token); - logMessage('Token saved', 'info'); -}); - const autoLauncher = new AutoLaunch({ name: 'Gitnews', }); @@ -177,6 +147,20 @@ ipcMain.handle('is-demo-mode:get', async () => { return Boolean(process.env.GITNEWS_DEMO_MODE); }); +ipcMain.handle( + 'notifications-for-account:get', + async (_event, account: AccountInfo) => { + return fetchNotificationsForAccount(account); + } +); + +ipcMain.handle( + 'mark-note-as-read', + async (_event, note: Note, account: AccountInfo) => { + return markNotficationAsRead(note, account); + } +); + function setIcon(type?: string) { if (!type) { type = lastIconState; diff --git a/src/main/lib/github-interface.ts b/src/main/lib/github-interface.ts new file mode 100644 index 0000000..8fa6df2 --- /dev/null +++ b/src/main/lib/github-interface.ts @@ -0,0 +1,176 @@ +import type { AccountInfo, Note, NoteReason } from '../../shared-types'; +import { Octokit } from '@octokit/rest'; +import { fetch as undiciFetch, ProxyAgent } from 'undici'; +import { socksDispatcher } from 'fetch-socks'; +import { logMessage } from './logging'; + +const userAgent = 'gitnews-menubar'; +const mainGithubApiUrl = 'https://api.github.com'; + +function getSocksVersionFromProtocol(protocol: string): 4 | 5 { + const lastCharNum = parseInt(protocol.charAt(-1), 10); + if (Number.isInteger(lastCharNum) && lastCharNum === 4) { + return 4; + } + return 5; +} + +function makeProxyDispatcher(proxyUrl: string) { + if (proxyUrl.startsWith('socks')) { + // eg: `socks5://user:pass@host:port` (user and pass are optional so + // `socks5://host:port` also works) + const proxyUrlData = new URL(proxyUrl); + const socksVersion = getSocksVersionFromProtocol(proxyUrlData.protocol); + // FIXME: support user and pass + const slashParts = proxyUrl.split('/'); + const hostParts = slashParts.at(-1)?.split('@').at(-1)?.split(':') ?? []; + const socksHost = hostParts[0]; + const socksPort = hostParts[1]; + if (socksVersion && socksHost && socksPort) { + return socksDispatcher({ + type: socksVersion, + host: socksHost, + port: parseInt(socksPort, 10), + }); + } + } + return new ProxyAgent(proxyUrl); +} + +function makeProxyFetch(proxyUrl: string) { + return (url: string, options: any) => { + return undiciFetch(url, { + ...options, + dispatcher: makeProxyDispatcher(proxyUrl), + }); + }; +} + +function createOctokit(account: AccountInfo) { + const options = { + auth: account.apiKey, + baseUrl: getBaseUrlForServer(account), + userAgent, + }; + if (account.proxyUrl) { + const proxyFetch = makeProxyFetch(account.proxyUrl); + return new Octokit({ + ...options, + request: { + fetch: proxyFetch, + }, + }); + } + return new Octokit(options); +} + +function getBaseUrlForServer(account: AccountInfo): string | undefined { + if (account.serverUrl === mainGithubApiUrl) { + return undefined; + } + // GitHub Enterprise Servers use this URL structure: + // https://github.com/octokit/octokit.js/?tab=readme-ov-file#octokit-api-client + const serverUrl = account.serverUrl.replace(/\/$/, ''); + return `${serverUrl}/api/v3`; +} + +function getOctokitRequestPathFromUrl( + account: AccountInfo, + urlString: string +): string { + const baseUrl = getBaseUrlForServer(account) ?? mainGithubApiUrl; + return urlString.replace(baseUrl, ''); +} + +export async function markNotficationAsRead( + note: Note, + account: AccountInfo +): Promise { + const octokit = createOctokit(account); + const path = getOctokitRequestPathFromUrl(account, note.url); + try { + await octokit.request(`PATCH ${path}`, { + thread_id: note.id, + }); + } catch (error) { + logMessage( + `Failed to mark notification read for ${path} (${note.url})`, + 'error' + ); + return; + } +} + +export async function fetchNotificationsForAccount( + account: AccountInfo +): Promise { + const octokit = createOctokit(account); + const notificationsResponse = + await octokit.rest.activity.listNotificationsForAuthenticatedUser({ + all: false, + }); + + const notes: Note[] = []; + + for (const notification of notificationsResponse.data) { + let commentAvatar: string; + let commentHtmlUrl: string; + const commentPath = getOctokitRequestPathFromUrl( + account, + notification.subject.latest_comment_url ?? notification.subject.url + ); + try { + const comment = await octokit.request(`GET ${commentPath}`, {}); + commentAvatar = comment.data.user.avatar_url; + commentHtmlUrl = comment.data.html_url; + } catch (error) { + logMessage( + `Failed to fetch comment for ${commentPath} (${notification.subject.latest_comment_url ?? notification.subject.url})`, + 'error' + ); + continue; + } + + let noteState: string; + let noteMerged: boolean; + let subjectHtmlUrl: string; + const subjectPath = getOctokitRequestPathFromUrl( + account, + notification.subject.url + ); + try { + const subject = await octokit.request(`GET ${subjectPath}`, {}); + noteState = subject.data.state; + noteMerged = subject.data.merged; + subjectHtmlUrl = subject.data.html_url; + } catch (error) { + logMessage( + `Failed to fetch comment for ${subjectPath} (${notification.subject.url})`, + 'error' + ); + continue; + } + + notes.push({ + gitnewsAccountId: account.id, + id: notification.id, + url: notification.url, + title: notification.subject.title, + unread: notification.unread, + repositoryFullName: notification.repository.full_name, + commentUrl: commentHtmlUrl, + updatedAt: notification.updated_at, + repositoryName: notification.repository.name, + type: notification.subject.type, + subjectUrl: subjectHtmlUrl, + commentAvatar: commentAvatar ?? notification.repository.owner.avatar_url, + repositoryOwnerAvatar: notification.repository.owner.avatar_url, + api: { + subject: { state: noteState, merged: noteMerged }, + notification: { reason: notification.reason as NoteReason }, + }, + }); + } + + return notes; +} diff --git a/src/main/lib/logging.ts b/src/main/lib/logging.ts new file mode 100644 index 0000000..05767fd --- /dev/null +++ b/src/main/lib/logging.ts @@ -0,0 +1,29 @@ +import log from 'electron-log'; +import debugFactory from 'debug'; +import { isLoggingEnabled } from './main-store'; + +const debug = debugFactory('gitnews-menubar'); + +// Only use this function for logging! +export function logMessage( + message: string, + level: 'info' | 'warn' | 'error' +): void { + debug(message); + if (!isLoggingEnabled()) { + return; + } + switch (level) { + case 'info': + log.info(message); + break; + case 'warn': + log.warn(message); + break; + case 'error': + log.error(message); + break; + default: + log.error(`Unknown log level '${level}': ${message}`); + } +} diff --git a/src/main/lib/main-store.ts b/src/main/lib/main-store.ts index a19a1ab..9689869 100644 --- a/src/main/lib/main-store.ts +++ b/src/main/lib/main-store.ts @@ -16,10 +16,6 @@ export function getToken(): string { return store.get('gitnews-token'); } -export function setToken(token: string): void { - store.set('gitnews-token', token); -} - export function isLoggingEnabled(): boolean { return store.get('is-logging-enabled'); } diff --git a/src/preload.ts b/src/preload.ts index 2c6ab3c..1ca87dd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron'; -import { IconType, MainBridge } from './renderer/types'; +import { AccountInfo, IconType, MainBridge, Note } from './renderer/types'; const bridge: MainBridge = { quitApp: () => ipcRenderer.send('quit-app'), @@ -10,7 +10,6 @@ const bridge: MainBridge = { toggleAutoLaunch: (isEnabled: boolean) => ipcRenderer.send('toggle-auto-launch', isEnabled), openUrl: (url: string) => ipcRenderer.send('open-url', url), - saveToken: (token: string) => ipcRenderer.send('save-token', token), setIcon: (nextIcon: IconType) => ipcRenderer.send('set-icon', nextIcon), onHide: (callback: () => void) => ipcRenderer.on('hide-app', callback), onShow: (callback: () => void) => ipcRenderer.on('show-app', callback), @@ -19,6 +18,10 @@ const bridge: MainBridge = { getVersion: () => ipcRenderer.invoke('version:get'), isDemoMode: () => ipcRenderer.invoke('is-demo-mode:get'), isAutoLaunchEnabled: () => ipcRenderer.invoke('is-auto-launch:get'), + getNotificationsForAccount: (account: AccountInfo) => + ipcRenderer.invoke('notifications-for-account:get', account), + markNotificationRead: (note: Note, account: AccountInfo) => + ipcRenderer.invoke('mark-note-as-read', note, account), }; contextBridge.exposeInMainWorld('electronApi', bridge); diff --git a/src/renderer/components/account-edit.tsx b/src/renderer/components/account-edit.tsx new file mode 100644 index 0000000..2b029c2 --- /dev/null +++ b/src/renderer/components/account-edit.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { AccountInfo, AppReduxState } from '../types'; +import { useDispatch } from 'react-redux'; +import { setAccounts } from '../lib/reducer'; +import { useSelector } from 'react-redux'; + +export default function AccountEdit({ + account, + showAccounts, +}: { + account: AccountInfo; + showAccounts: () => void; +}) { + const [tempAccount, setTempAccount] = React.useState(account); + const accounts = useSelector((state: AppReduxState) => state.accounts); + const otherAccounts = accounts.filter((acc) => acc.id !== account.id); + const dispatch = useDispatch(); + + return ( +
+

Edit Account

+
+ +
+ + +
+
+
+
+ + { + setTempAccount({ + ...tempAccount, + name: event.target.value, + }); + }} + /> +
+
+ + { + setTempAccount({ + ...tempAccount, + serverUrl: event.target.value, + }); + }} + /> +
+
+

+ You must generate a GitHub authentication token so this app can see + your notifications. It will need the `notifications` and `repo` + scopes. +

+ + { + setTempAccount({ + ...tempAccount, + apiKey: event.target.value, + }); + }} + /> +
+
+ + { + setTempAccount({ + ...tempAccount, + proxyUrl: event.target.value, + }); + }} + /> +
+
+
+ ); +} diff --git a/src/renderer/components/account-list.tsx b/src/renderer/components/account-list.tsx new file mode 100644 index 0000000..a2d8f63 --- /dev/null +++ b/src/renderer/components/account-list.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { AccountInfo } from '../types'; +import { useDispatch } from 'react-redux'; +import { selectAccount } from '../lib/reducer'; + +export default function AccountList({ + accounts, + showAccountEdit, +}: { + accounts: AccountInfo[]; + showAccountEdit: () => void; +}) { + const dispatch = useDispatch(); + return ( +
+
+

Accounts

+
+ +
+
+
    + {accounts.map((account) => { + return ( +
  • { + dispatch(selectAccount(account)); + showAccountEdit(); + }} + > + {account.name ?? 'Unnamed'}: {account.serverUrl} +
  • + ); + })} +
+
+ ); +} diff --git a/src/renderer/components/add-token-form.tsx b/src/renderer/components/add-token-form.tsx deleted file mode 100644 index f96637c..0000000 --- a/src/renderer/components/add-token-form.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import { OpenUrl } from '../types'; - -export default function AddTokenForm({ - token, - openUrl, - changeToken, - showCancel, - hideEditToken, - isTokenInvalid, -}: { - token: string; - openUrl: OpenUrl; - changeToken: (token: string) => void; - showCancel: boolean; - hideEditToken: () => void; - isTokenInvalid: boolean; -}) { - const openLink = (event: React.MouseEvent) => { - event.preventDefault(); - openUrl((event.target as HTMLAnchorElement).href); - }; - const [tempToken, setTempToken] = React.useState(token); - - const saveToken = () => { - if (tempToken) { - changeToken(tempToken); - hideEditToken(); - } - }; - return ( -
-

- You must generate a GitHub authentication token so this app can see your - notifications. It will need the `notifications` and `repo` scopes. You - can generate a token{' '} - - here - - . -

- - setTempToken(event.target.value)} - /> - {isTokenInvalid &&
Sorry! That token is invalid.
} - {showCancel && ( - - )} - -
- ); -} diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 4731a8b..308a45f 100644 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -7,8 +7,9 @@ import MainPane from '../components/main-pane'; import { PANE_CONFIG, PANE_NOTIFICATIONS, - PANE_TOKEN, PANE_MUTED_REPOS, + PANE_ACCOUNTS, + PANE_ACCOUNT_EDIT, } from '../lib/constants'; import Poller from '../lib/poller'; import { getSecondsUntilNextFetch } from '../lib/helpers'; @@ -19,7 +20,6 @@ import { fetchNotifications, openUrl, setIcon, - changeToken, changeAutoLoad, muteRepo, unmuteRepo, @@ -62,7 +62,6 @@ interface AppConnectedProps { } interface AppConnectedActions { - changeToken: (token: string) => void; setIcon: (icon: IconType) => void; openUrl: OpenUrl; fetchNotifications: () => void; @@ -210,24 +209,40 @@ class App extends React.Component { const { currentPane } = this.state; const hideConfig = () => this.setState({ currentPane: PANE_NOTIFICATIONS }); const showConfig = () => this.setState({ currentPane: PANE_CONFIG }); - const showEditToken = () => this.setState({ currentPane: PANE_TOKEN }); - const hideEditToken = () => this.setState({ currentPane: PANE_CONFIG }); + const showAccounts = () => this.setState({ currentPane: PANE_ACCOUNTS }); + const showAccountEdit = () => + this.setState({ currentPane: PANE_ACCOUNT_EDIT }); const showMutedReposList = () => this.setState({ currentPane: PANE_MUTED_REPOS }); const setSearchTo = (value: string) => this.setState({ searchValue: value }); - const showBackButton = - token && - (currentPane === PANE_CONFIG || currentPane === PANE_MUTED_REPOS); + const backButtonPanes = [PANE_CONFIG, PANE_MUTED_REPOS, PANE_ACCOUNTS]; + const confugSubPanes = [PANE_MUTED_REPOS, PANE_ACCOUNTS]; + const showBackButton = (() => { + if (!token) { + return false; + } + if (backButtonPanes.includes(currentPane)) { + return true; + } + return false; + })(); const onBack = () => { - if (currentPane === PANE_MUTED_REPOS) { + if (confugSubPanes.includes(currentPane)) { showConfig(); return; } hideConfig(); }; + const headerOnClickConfig = (() => { + if (token && currentPane === PANE_NOTIFICATIONS) { + return showConfig; + } + return undefined; + })(); + return (
{ lastSuccessfulCheck={lastSuccessfulCheck} lastChecked={this.props.lastChecked} fetchInterval={this.props.fetchInterval} - showConfig={ - token && currentPane === PANE_NOTIFICATIONS ? showConfig : undefined - } + showConfig={headerOnClickConfig} hideConfig={showBackButton ? onBack : undefined} fetchingInProgress={fetchingInProgress} filterType={this.props.filterType} @@ -264,10 +277,9 @@ class App extends React.Component { lastSuccessfulCheck={lastSuccessfulCheck} fetchingInProgress={fetchingInProgress} openUrl={this.props.openUrl} - changeToken={this.props.changeToken} quitApp={this.props.quitApp} - hideEditToken={hideEditToken} - showEditToken={showEditToken} + showAccounts={showAccounts} + showAccountEdit={showAccountEdit} markRead={this.props.markRead} markUnread={this.props.markUnread} isAutoLoadEnabled={this.props.isAutoLoadEnabled} @@ -313,7 +325,6 @@ const actions = { fetchNotifications, openUrl, setIcon, - changeToken, changeAutoLoad, muteRepo, unmuteRepo, diff --git a/src/renderer/components/config-page.tsx b/src/renderer/components/config-page.tsx index cbc78c3..b9d94a6 100644 --- a/src/renderer/components/config-page.tsx +++ b/src/renderer/components/config-page.tsx @@ -4,7 +4,7 @@ import Attributions from '../components/attributions'; import { ChangeAutoload, OpenUrl } from '../types'; export default function ConfigPage({ - showEditToken, + showAccounts, showMutedReposList, openUrl, getVersion, @@ -14,7 +14,7 @@ export default function ConfigPage({ isLogging, toggleLogging, }: { - showEditToken: () => void; + showAccounts: () => void; showMutedReposList: () => void; openUrl: OpenUrl; getVersion: () => Promise; @@ -35,8 +35,8 @@ export default function ConfigPage({

Settings

  • -
  • diff --git a/src/renderer/components/main-pane.tsx b/src/renderer/components/main-pane.tsx index 5530f22..2f4a163 100644 --- a/src/renderer/components/main-pane.tsx +++ b/src/renderer/components/main-pane.tsx @@ -1,10 +1,15 @@ import React from 'react'; import ConfigPage from '../components/config-page'; import UncheckedNotice from '../components/unchecked-notice'; -import AddTokenForm from '../components/add-token-form'; import NotificationsArea from '../components/notifications-area'; import MutedReposList from '../components/muted-repos-list'; -import { PANE_CONFIG, PANE_TOKEN, PANE_MUTED_REPOS } from '../lib/constants'; +import { + PANE_CONFIG, + PANE_MUTED_REPOS, + PANE_ACCOUNTS, + PANE_ACCOUNT_EDIT, + defaultAccountInfo, +} from '../lib/constants'; import { AppReduxState, ChangeAutoload, @@ -17,16 +22,20 @@ import { UnmuteRepo, } from '../types'; import { AppPane } from '../types'; +import AccountList from './account-list'; +import { useSelector } from 'react-redux'; +import AccountEdit from './account-edit'; +import { useDispatch } from 'react-redux'; +import { setAccounts } from '../lib/reducer'; export default function MainPane({ token, isTokenInvalid, currentPane, openUrl, - changeToken, quitApp, - hideEditToken, - showEditToken, + showAccounts, + showAccountEdit, showMutedReposList, lastSuccessfulCheck, getVersion, @@ -49,10 +58,9 @@ export default function MainPane({ token: string; currentPane: AppPane; openUrl: OpenUrl; - changeToken: (token: string) => void; quitApp: () => void; - hideEditToken: () => void; - showEditToken: () => void; + showAccounts: () => void; + showAccountEdit: () => void; showMutedReposList: () => void; lastSuccessfulCheck: AppReduxState['lastSuccessfulCheck']; getVersion: () => Promise; @@ -73,26 +81,50 @@ export default function MainPane({ toggleLogging: (newValue: boolean) => void; isTokenInvalid: boolean; }) { - if (!token || isTokenInvalid || currentPane === PANE_TOKEN) { + const accounts = useSelector((state: AppReduxState) => state.accounts); + const dispatch = useDispatch(); + const selectedAccount = useSelector( + (state: AppReduxState) => state.selectedAccount + ); + + if (accounts.length === 0 && token) { + // Migrate old single-token system to account. + const migratedAccount = { + ...defaultAccountInfo, + apiKey: token, + }; + dispatch(setAccounts([migratedAccount])); return ( - +
    +
    Migrating to account system…
    +
    If you see this for more than a moment, something is wrong.
    +
    + ); + } + + if (accounts.length === 0) { + return ( + ); } if (currentPane === PANE_MUTED_REPOS) { return ; } + if (currentPane === PANE_ACCOUNT_EDIT && selectedAccount) { + return ( + + ); + } + if (currentPane === PANE_ACCOUNTS) { + return ( + + ); + } if (currentPane === PANE_CONFIG) { return ( = ); } switch (action.type) { - case 'SET_INITIAL_TOKEN': - window.electronApi.saveToken(action.token); - break; - case 'CHANGE_TOKEN': - window.electronApi.saveToken(action.token); - break; case 'CHANGE_AUTO_LOAD': window.electronApi.toggleAutoLaunch(action.isEnabled); break; diff --git a/src/renderer/lib/constants.ts b/src/renderer/lib/constants.ts index 525eea9..dc4af42 100644 --- a/src/renderer/lib/constants.ts +++ b/src/renderer/lib/constants.ts @@ -1,4 +1,14 @@ +import type { AccountInfo } from '../../shared-types'; + export const PANE_NOTIFICATIONS = 'notifications-pane'; +export const PANE_ACCOUNTS = 'accounts-pane'; +export const PANE_ACCOUNT_EDIT = 'account-edit-pane'; export const PANE_CONFIG = 'config-pane'; -export const PANE_TOKEN = 'token-pane'; export const PANE_MUTED_REPOS = 'muted-repos-pane'; + +export const defaultAccountInfo: AccountInfo = { + id: 'main-github-api', + name: 'GitHub', + serverUrl: 'https://api.github.com', + apiKey: '', +}; diff --git a/src/renderer/lib/demo-mode.ts b/src/renderer/lib/demo-mode.ts index 6fe3295..77ab3e6 100644 --- a/src/renderer/lib/demo-mode.ts +++ b/src/renderer/lib/demo-mode.ts @@ -1,4 +1,5 @@ import { Note } from '../types'; +import { defaultAccountInfo } from './constants'; import { words } from './random-words'; const hourInMiliseconds = 3600000; @@ -24,6 +25,8 @@ function createDemoNotification(initialDate: Date): Note { const isUnread = randomNumber(1, 2) === 1; return { + gitnewsAccountId: defaultAccountInfo.id, + url: '', updatedAt: new Date( initialDate.getTime() - hourInMiliseconds * randomNumber(1, 23) ).toISOString(), diff --git a/src/renderer/lib/github-middleware.ts b/src/renderer/lib/github-middleware.ts index cfaa053..e4f14cd 100644 --- a/src/renderer/lib/github-middleware.ts +++ b/src/renderer/lib/github-middleware.ts @@ -1,16 +1,10 @@ // require('dotenv').config(); import { Middleware } from 'redux'; -import { AppReduxState } from '../types'; +import type { AccountInfo, AppReduxState, Note } from '../types'; import { isAction } from './helpers'; -import { createNoteMarkRead, Note } from 'gitnews'; export function createGitHubMiddleware(): Middleware<{}, AppReduxState> { - const markNotificationRead = createNoteMarkRead({ - fetch: (url, options) => fetch(url, options), - log: (message) => console.log('Gitnews: ' + message), - }); - return (store) => (next) => (action) => { if (!isAction(action)) { throw new Error( @@ -24,13 +18,26 @@ export function createGitHubMiddleware(): Middleware<{}, AppReduxState> { next(action); return; } - markNoteRead(action.token, action.note); + const account = store + .getState() + .accounts.find( + (account) => account.id === action.note.gitnewsAccountId + ); + if (!account) { + console.error( + 'Cannot find account for notification to mark as read', + action.note + ); + + return; + } + markNoteRead(action.note, account); } } next(action); }; - function markNoteRead(token: string, note: Note) { - markNotificationRead(token, note); + function markNoteRead(note: Note, account: AccountInfo) { + window.electronApi.markNotificationRead(note, account); } } diff --git a/src/renderer/lib/gitnews-fetcher.ts b/src/renderer/lib/gitnews-fetcher.ts index 7006e36..717a7e0 100644 --- a/src/renderer/lib/gitnews-fetcher.ts +++ b/src/renderer/lib/gitnews-fetcher.ts @@ -10,7 +10,6 @@ import { isInvalidJson, isTokenInvalid, } from '../lib/helpers'; -import { createNoteGetter } from 'gitnews'; import { changeToOffline, fetchBegin, @@ -19,7 +18,7 @@ import { addConnectionError, setIsTokenInvalid, } from '../lib/reducer'; -import { AppReduxState, Note, UnknownFetchError } from '../types'; +import { AccountInfo, AppReduxState, Note, UnknownFetchError } from '../types'; import { AppDispatch } from './store'; import { createDemoNotifications } from './demo-mode'; @@ -38,6 +37,7 @@ export function createFetcher(): Middleware<{}, AppReduxState> { if (!isDispatch(next)) { throw new Error('Invalid dispatcher in fetcher'); } + if (action.type === 'MARK_NOTE_READ' && store.getState().isDemoMode) { currentDemoNotifications = currentDemoNotifications.map((note) => { if (note.id === action.note.id) { @@ -48,36 +48,31 @@ export function createFetcher(): Middleware<{}, AppReduxState> { return next(action); } - if (action.type === 'CHANGE_TOKEN') { - debug('Token being changed; fetching with new token'); + if (action.type === 'SET_ACCOUNTS') { + debug('Accounts changed; fetching with updated accounts'); window.electronApi.logMessage( - 'Token being changed; fetching with new token', + 'Accounts changed; fetching with updated accounts', 'info' ); - performFetch( - Object.assign({}, store.getState(), { token: action.token }), - next - ); + performFetch(store.getState(), next); return next(action); } - if (action.type !== 'GITNEWS_FETCH_NOTIFICATIONS') { - return next(action); + if (action.type === 'GITNEWS_FETCH_NOTIFICATIONS') { + debug('Fetching accounts'); + window.electronApi.logMessage('Fetching accounts', 'info'); + performFetch(store.getState(), next); + return; } - debug('fetching with existing token'); - window.electronApi.logMessage('Fetching with existing token', 'info'); - performFetch(store.getState(), next); - return; + return next(action); }; - async function performFetch( - { fetchingInProgress, token, fetchingStartedAt, isDemoMode }: AppReduxState, - next: AppDispatch - ) { + async function performFetch(state: AppReduxState, next: AppDispatch) { const fetchingMaxTime = secsToMs(120); // 2 minutes - if (fetchingInProgress) { - const timeSinceFetchingStarted = Date.now() - (fetchingStartedAt || 0); + if (state.fetchingInProgress) { + const timeSinceFetchingStarted = + Date.now() - (state.fetchingStartedAt || 0); if (timeSinceFetchingStarted > fetchingMaxTime) { const message = `It has been too long since we started fetching (${timeSinceFetchingStarted} ms). Giving up.`; debug(message); @@ -97,7 +92,7 @@ export function createFetcher(): Middleware<{}, AppReduxState> { next(changeToOffline()); return; } - if (!token) { + if (!state.token) { next(changeToOffline()); return; } @@ -105,12 +100,13 @@ export function createFetcher(): Middleware<{}, AppReduxState> { // NOTE: After this point, any return action MUST disable fetchingInProgress // or the app will get stuck never updating again. next(fetchBegin()); - const getGithubNotifications = getFetcher(token, isDemoMode); + + const getGithubNotifications = getFetcher(state.accounts, state.isDemoMode); try { const notes = await getGithubNotifications(); debug('notifications retrieved', notes); window.electronApi.logMessage( - `Notifications retrieved (${notes.length} found)`, + `Notifications retrieved (${notes.length} found in ${state.accounts.length} accounts)`, 'info' ); next(fetchDone()); @@ -122,30 +118,34 @@ export function createFetcher(): Middleware<{}, AppReduxState> { 'warn' ); next(fetchDone()); - getErrorHandler(next)(err as Error, token); + getErrorHandler(next)(err as Error, state.token); } } - const getNotifications = createNoteGetter({ - fetch: (url, options) => fetch(url, options), - log: (message) => { - console.log('Gitnews: ' + message); - }, - }); - function getFetcher( - token: string, + accounts: AccountInfo[], isDemoMode: boolean ): () => Promise { if (isDemoMode) { return () => getDemoNotifications(); } - return () => getNotifications(token); + return async () => { + let allNotes: Note[] = []; + for (const account of accounts) { + const notes = await fetchNotifications(account); + allNotes = [...allNotes, ...notes]; + } + return allNotes; + }; } return fetcher; } +async function fetchNotifications(account: AccountInfo): Promise { + return window.electronApi.getNotificationsForAccount(account); +} + async function getDemoNotifications(): Promise { currentDemoNotifications = [ ...currentDemoNotifications, diff --git a/src/renderer/lib/reducer.ts b/src/renderer/lib/reducer.ts index 04fc85a..b2deeb4 100644 --- a/src/renderer/lib/reducer.ts +++ b/src/renderer/lib/reducer.ts @@ -8,7 +8,7 @@ import { AppReduxState, AppReduxAction, Note, - ActionChangeToken, + ActionSetAccounts, ActionSetDemoMode, ActionChangeToOffline, ActionGotNotes, @@ -24,6 +24,8 @@ import { ActionInitToken, FilterType, ActionToggleTokenInvalid, + AccountInfo, + ActionSelectAccount, } from '../types'; const defaultFetchInterval = secsToMs(120); @@ -46,6 +48,8 @@ const initialState: AppReduxState = { isDemoMode: false, isLogging: false, isTokenInvalid: false, + accounts: [], + selectedAccount: undefined, }; export function createReducer() { @@ -111,11 +115,16 @@ export function createReducer() { ); return Object.assign({}, state, { notes }); } - case 'CHANGE_TOKEN': - return Object.assign({}, state, { - token: action.token, - isTokenInvalid: false, - }); + case 'SET_ACCOUNTS': + return { + ...state, + accounts: action.accounts, + }; + case 'SELECT_ACCOUNT': + return { + ...state, + selectedAccount: action.account, + }; case 'SET_INITIAL_TOKEN': return Object.assign({}, state, { token: action.token, @@ -178,6 +187,10 @@ export function unmuteRepo(repo: string): ActionUnmuteRepo { return { type: 'UNMUTE_REPO', repo }; } +export function setAccounts(accounts: AccountInfo[]): ActionSetAccounts { + return { type: 'SET_ACCOUNTS', accounts }; +} + export function markRead(token: string, note: Note): ActionMarkRead { return { type: 'MARK_NOTE_READ', token, note }; } @@ -194,14 +207,14 @@ export function markAllNotesSeen(): ActionMarkAllNotesSeen { return { type: 'MARK_ALL_NOTES_SEEN' }; } -export function changeToken(token: string): ActionChangeToken { - return { type: 'CHANGE_TOKEN', token }; -} - export function initToken(token: string): ActionInitToken { return { type: 'SET_INITIAL_TOKEN', token }; } +export function selectAccount(account: AccountInfo): ActionSelectAccount { + return { type: 'SELECT_ACCOUNT', account }; +} + export function setIsDemoMode(isDemoMode: boolean): ActionSetDemoMode { return { type: 'SET_DEMO_MODE', isDemoMode }; } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index f8ded2a..8cc1f54 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -7,14 +7,14 @@ --button-text-color: #000; --button-background-color: #eee; --primary-button-text-color: #000; - --primary-button-background-color: #F5E2C8; + --primary-button-background-color: #f5e2c8; --primary-button-shadow-color: #c1ab8c; --bottom-bar-background-color: #24292e; --bottom-bar-text-color: #fff; --bottom-bar-action-text-color: #fff; --header-background-color: #24292e; - --header-text-color: rgba(255,255,255,0.75); - --header-link-color: rgba(255,255,255,0.75); + --header-text-color: rgba(255, 255, 255, 0.75); + --header-link-color: rgba(255, 255, 255, 0.75); --dropdown-background-color: #24292e; --dropdown-selected-background-color: #fff; --dropdown-selected-text-color: #000; @@ -25,7 +25,7 @@ --error-message-color: #d60000; --notification-border-color: #ccc; --search-icon-color: #24292e; - --multi-open-notification-pending-overlay: rgba(255,255,255,0.85); + --multi-open-notification-pending-overlay: rgba(255, 255, 255, 0.85); --notification-image-background-color: #fff; --no-notifications-icon-color: #000; } @@ -86,17 +86,17 @@ button { } .btn { - box-shadow:inset 0px 1px 0px 0px var(--primary-button-shadow-color); + box-shadow: inset 0px 1px 0px 0px var(--primary-button-shadow-color); background-color: var(--primary-button-background-color); - border-radius:3px; - border:1px solid #24292E; - display:inline-block; - cursor:pointer; + border-radius: 3px; + border: 1px solid #24292e; + display: inline-block; + cursor: pointer; color: var(--primary-button-text-color); - font-family:Arial; - font-size:13px; - padding:6px 24px; - text-decoration:none; + font-family: Arial; + font-size: 13px; + padding: 6px 24px; + text-decoration: none; margin-right: 10px; } @@ -146,7 +146,7 @@ header { } header svg { - fill: rgba(255,255,255,0.75); + fill: rgba(255, 255, 255, 0.75); } .header__primary { @@ -195,7 +195,7 @@ header svg { right: 8px; top: 38px; font-size: 0.9rem; - box-shadow: 0px 2px 4px 4px rgba(130,130,130,0.1); + box-shadow: 0px 2px 4px 4px rgba(130, 130, 130, 0.1); padding: 15px; } @@ -241,22 +241,29 @@ header svg { .retry-button { color: var(--retry-button-text-color); - background: none!important; + background: none !important; border: none; - padding: 0!important; + padding: 0 !important; text-decoration: underline; cursor: pointer; } +.account-page-actions { + display: flex; + gap: 2px; + align-items: center; + justify-content: space-between; +} + .edit-token-button, .edit-muted-repos-button, .config-button, .filter-button, .back-button { color: var(--config-action-text-color); - background: none!important; + background: none !important; border: none; - padding: 0!important; + padding: 0 !important; text-decoration: underline; cursor: pointer; } @@ -292,7 +299,7 @@ header svg { position: absolute; left: -9px; top: 20px; - background-color: rgba(255,255,255,0.75); + background-color: rgba(255, 255, 255, 0.75); border-radius: 20px; vertical-align: middle; width: 16px; @@ -389,7 +396,7 @@ header h1 { } .notification--multi-open-clicked { - border-left: 4px solid #4FB477; + border-left: 4px solid #4fb477; } .notification__muted { @@ -471,17 +478,17 @@ header h1 { } .btn--cancel { - box-shadow:inset 0px 1px 0px 0px var(--notification-border-color); + box-shadow: inset 0px 1px 0px 0px var(--notification-border-color); background-color: var(--button-background-color); - border-radius:3px; - border:1px solid #999; - display:inline-block; - cursor:pointer; + border-radius: 3px; + border: 1px solid #999; + display: inline-block; + cursor: pointer; color: var(--button-text-color); - font-family:Arial; - font-size:13px; - padding:6px 24px; - text-decoration:none; + font-family: Arial; + font-size: 13px; + padding: 6px 24px; + text-decoration: none; margin-right: 10px; } @@ -490,9 +497,9 @@ header h1 { .notification__mark-read { font-size: 0.7em; color: var(--notification-action-text-color); - background: none!important; + background: none !important; border: none; - padding: 0!important; + padding: 0 !important; text-decoration: underline; cursor: pointer; flex: 0 0 auto; @@ -551,8 +558,9 @@ header h1 { .add-token-form__input { display: block; - font-size: 1.5em; + font-size: 1em; padding: 5px; + border: 1px solid var(--notification-border-color); width: 100%; } @@ -560,6 +568,21 @@ header h1 { margin: 10px 10px 10px 0; } +.edit-account-form { + margin: 10px 0; + position: relative; + display: flex; + flex-direction: column; + gap: 10px; +} + +.account-page-header { + display: flex; + width: 100%; + justify-content: space-between; + margin-bottom: 10px; +} + .no-notifications { padding: 12px; border-top: 1px solid #eee; @@ -629,13 +652,27 @@ header h1 { .update-available-notice button { color: var(--bottom-bar-action-text-color); - background: none!important; + background: none !important; border: none; - padding: 0!important; + padding: 0 !important; text-decoration: underline; cursor: pointer; } +.account-list { + height: 150px; + border-top: 1px solid var(--notification-border-color); + overflow-y: scroll; + padding: 5px; + margin: 0; +} + +.account-list li { + list-style-type: none; + padding: 5px; + margin: 0; +} + .config-page { margin: 12px; height: 100%; @@ -645,7 +682,7 @@ header h1 { } .config-page h2 { - margin: 0 0 10px 0; + margin: 0; } .config-page h3 { diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 5ed020e..8273dc0 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -1,60 +1,16 @@ import { PANE_CONFIG, PANE_NOTIFICATIONS, - PANE_TOKEN, PANE_MUTED_REPOS, + PANE_ACCOUNTS, + PANE_ACCOUNT_EDIT, } from './lib/constants'; +import type { NoteReason, Note, AccountInfo } from '../shared-types'; -export type NoteReason = - | 'assign' - | 'author' - | 'ci_activity' - | 'comment' - | 'manual' - | 'mention' - | 'push' - | 'review_requested' - | 'security_alert' - | 'state_change' - | 'subscribed' - | 'team_mention' - | 'your_activity'; +export type { NoteReason, Note, AccountInfo }; export type FilterType = NoteReason | 'all'; -export interface NoteApi { - subject?: { state?: string; merged?: boolean }; - notification?: { reason?: NoteReason }; -} - -export interface Note { - id: string; - title: string; - unread: boolean; - repositoryFullName: string; - gitnewsMarkedUnread?: boolean; - gitnewsSeen?: boolean; - - /** - * Number of milliseconds since the epoc (what Date.now() returns). - */ - gitnewsSeenAt?: number; - - api: NoteApi; - commentUrl: string; - - /** - * ISO 8601 formatted date string like `2017-08-23T18:20:00Z`. - */ - updatedAt: string; - - repositoryName: string; - type: string; - subjectUrl: string; - commentAvatar?: string; - repositoryOwnerAvatar?: string; -} - export interface AppReduxState { token: undefined | string; notes: Note[]; @@ -73,6 +29,8 @@ export interface AppReduxState { isDemoMode: boolean; isLogging: boolean; isTokenInvalid: boolean; + accounts: AccountInfo[]; + selectedAccount: AccountInfo | undefined; } export type ActionMuteRepo = { type: 'MUTE_REPO'; repo: string }; @@ -85,8 +43,15 @@ export type ActionMarkRead = { export type ActionMarkUnread = { type: 'MARK_NOTE_UNREAD'; note: Note }; export type ActionClearErrors = { type: 'CLEAR_ERRORS' }; export type ActionMarkAllNotesSeen = { type: 'MARK_ALL_NOTES_SEEN' }; -export type ActionChangeToken = { type: 'CHANGE_TOKEN'; token: string }; export type ActionInitToken = { type: 'SET_INITIAL_TOKEN'; token: string }; +export type ActionSelectAccount = { + type: 'SELECT_ACCOUNT'; + account: AccountInfo; +}; +export type ActionSetAccounts = { + type: 'SET_ACCOUNTS'; + accounts: AccountInfo[]; +}; export type ActionToggleTokenInvalid = { type: 'SET_TOKEN_INVALID'; isInvalid: boolean; @@ -124,13 +89,14 @@ export type MarkAppShown = { type: 'NOTE_APP_VISIBLE'; visible: true }; export type ActionSetDemoMode = { type: 'SET_DEMO_MODE'; isDemoMode: boolean }; export type AppReduxAction = + | ActionSelectAccount | ActionMuteRepo | ActionUnmuteRepo | ActionMarkRead | ActionMarkUnread | ActionClearErrors | ActionMarkAllNotesSeen - | ActionChangeToken + | ActionSetAccounts | ActionInitToken | ActionChangeToOffline | ActionGotNotes @@ -164,8 +130,9 @@ export type UnmuteRepo = (repo: string) => void; export type IconType = 'normal' | 'unseen' | 'unread' | 'offline' | 'error'; export type AppPane = + | typeof PANE_ACCOUNTS + | typeof PANE_ACCOUNT_EDIT | typeof PANE_NOTIFICATIONS - | typeof PANE_TOKEN | typeof PANE_CONFIG | typeof PANE_MUTED_REPOS; @@ -175,13 +142,14 @@ export interface MainBridge { toggleLogging: (isLogging: boolean) => void; toggleAutoLaunch: (isEnabled: boolean) => void; openUrl: OpenUrl; - saveToken: (token: string) => void; setIcon: (nextIcon: IconType) => void; onHide: (callback: () => void) => void; onShow: (callback: () => void) => void; onClick: (callback: () => void) => void; getToken: () => Promise; getVersion: () => Promise; + getNotificationsForAccount: (account: AccountInfo) => Promise; + markNotificationRead: (note: Note, account: AccountInfo) => void; isDemoMode: () => Promise; isAutoLaunchEnabled: () => Promise; } diff --git a/src/shared-types.ts b/src/shared-types.ts new file mode 100644 index 0000000..1490d16 --- /dev/null +++ b/src/shared-types.ts @@ -0,0 +1,58 @@ +export type NoteReason = + | 'assign' + | 'author' + | 'ci_activity' + | 'comment' + | 'manual' + | 'mention' + | 'push' + | 'review_requested' + | 'security_alert' + | 'state_change' + | 'subscribed' + | 'team_mention' + | 'your_activity'; + +export interface NoteApi { + subject?: { state?: string; merged?: boolean }; + notification?: { reason?: NoteReason }; +} + +export interface Note { + id: string; + url: string; + title: string; + unread: boolean; + repositoryFullName: string; + gitnewsMarkedUnread?: boolean; + gitnewsSeen?: boolean; + + /** + * Number of milliseconds since the epoc (what Date.now() returns). + */ + gitnewsSeenAt?: number; + + api: NoteApi; + commentUrl: string; + + /** + * ISO 8601 formatted date string like `2017-08-23T18:20:00Z`. + */ + updatedAt: string; + + repositoryName: string; + type: string; + subjectUrl: string; + commentAvatar?: string; + repositoryOwnerAvatar?: string; + + gitnewsAccountId: AccountInfo['id']; +} + +export interface AccountInfo { + id: string; + name: string; + apiKey: string; + serverUrl: string; + proxyUrl?: string; +} diff --git a/yarn.lock b/yarn.lock index 885faed..9b43fe7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1743,6 +1743,99 @@ mkdirp "^1.0.4" rimraf "^3.0.2" +"@octokit/auth-token@^5.0.0": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-5.1.1.tgz#3bbfe905111332a17f72d80bd0b51a3e2fa2cf07" + integrity sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA== + +"@octokit/core@^6.1.2": + version "6.1.2" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-6.1.2.tgz#20442d0a97c411612da206411e356014d1d1bd17" + integrity sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg== + dependencies: + "@octokit/auth-token" "^5.0.0" + "@octokit/graphql" "^8.0.0" + "@octokit/request" "^9.0.0" + "@octokit/request-error" "^6.0.1" + "@octokit/types" "^13.0.0" + before-after-hook "^3.0.2" + universal-user-agent "^7.0.0" + +"@octokit/endpoint@^10.0.0": + version "10.1.1" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-10.1.1.tgz#1a9694e7aef6aa9d854dc78dd062945945869bcc" + integrity sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q== + dependencies: + "@octokit/types" "^13.0.0" + universal-user-agent "^7.0.2" + +"@octokit/graphql@^8.0.0": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-8.1.1.tgz#3cacab5f2e55d91c733e3bf481d3a3f8a5f639c4" + integrity sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg== + dependencies: + "@octokit/request" "^9.0.0" + "@octokit/types" "^13.0.0" + universal-user-agent "^7.0.0" + +"@octokit/openapi-types@^22.2.0": + version "22.2.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-22.2.0.tgz#75aa7dcd440821d99def6a60b5f014207ae4968e" + integrity sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg== + +"@octokit/plugin-paginate-rest@^11.0.0": + version "11.3.3" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.3.tgz#efc97ba66aae6797e2807a082f99b9cfc0e05aba" + integrity sha512-o4WRoOJZlKqEEgj+i9CpcmnByvtzoUYC6I8PD2SA95M+BJ2x8h7oLcVOg9qcowWXBOdcTRsMZiwvM3EyLm9AfA== + dependencies: + "@octokit/types" "^13.5.0" + +"@octokit/plugin-request-log@^5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz#ccb75d9705de769b2aa82bcd105cc96eb0c00f69" + integrity sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw== + +"@octokit/plugin-rest-endpoint-methods@^13.0.0": + version "13.2.4" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.2.4.tgz#543add032d3fe3f5d2839bfd619cf66d85469f01" + integrity sha512-gusyAVgTrPiuXOdfqOySMDztQHv6928PQ3E4dqVGEtOvRXAKRbJR4b1zQyniIT9waqaWk/UDaoJ2dyPr7Bk7Iw== + dependencies: + "@octokit/types" "^13.5.0" + +"@octokit/request-error@^6.0.1": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-6.1.4.tgz#ad96e29148d19edc2ba8009fc2b5a24a36c90f16" + integrity sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg== + dependencies: + "@octokit/types" "^13.0.0" + +"@octokit/request@^9.0.0": + version "9.1.3" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-9.1.3.tgz#42b693bc06238f43af3c037ebfd35621c6457838" + integrity sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA== + dependencies: + "@octokit/endpoint" "^10.0.0" + "@octokit/request-error" "^6.0.1" + "@octokit/types" "^13.1.0" + universal-user-agent "^7.0.2" + +"@octokit/rest@^21.0.2": + version "21.0.2" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-21.0.2.tgz#9b767dbc1098daea8310fd8b76bf7a97215d5972" + integrity sha512-+CiLisCoyWmYicH25y1cDfCrv41kRSvTq6pPWtRroRJzhsCZWZyCqGyI8foJT5LmScADSwRAnr/xo+eewL04wQ== + dependencies: + "@octokit/core" "^6.1.2" + "@octokit/plugin-paginate-rest" "^11.0.0" + "@octokit/plugin-request-log" "^5.3.1" + "@octokit/plugin-rest-endpoint-methods" "^13.0.0" + +"@octokit/types@^13.0.0", "@octokit/types@^13.1.0", "@octokit/types@^13.5.0": + version "13.5.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-13.5.0.tgz#4796e56b7b267ebc7c921dcec262b3d5bfb18883" + integrity sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ== + dependencies: + "@octokit/openapi-types" "^22.2.0" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -2027,7 +2120,7 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== -"@types/qs@*", "@types/qs@^6.9.1": +"@types/qs@*": version "6.9.15" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.15.tgz#adde8a060ec9c305a82de1babc1056e73bd64dce" integrity sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg== @@ -2854,6 +2947,11 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +before-after-hook@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-3.0.2.tgz#d5665a5fa8b62294a5aa0a499f933f4a1016195d" + integrity sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -4848,6 +4946,14 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +fetch-socks@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fetch-socks/-/fetch-socks-1.3.0.tgz#1f07b26924b5e7370aa23fd6e9332a5863736d1b" + integrity sha512-Cq7O53hoNiVeOs6u54f8M/H/w2yzhmnTQ3tcAJj9FNKYOeNGmt8qNU1zpWOzJD09f0uqfmBXxLbzWPsnT6GcRw== + dependencies: + socks "^2.8.1" + undici "^6.10.1" + figgy-pudding@^3.5.1: version "3.5.2" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" @@ -5243,16 +5349,6 @@ get-value@^2.0.3, get-value@^2.0.6: resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA== -gitnews@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/gitnews/-/gitnews-3.1.3.tgz#389b1d4ed6e631b4792067267aef5a36e1799c98" - integrity sha512-Jx//gDh+dNd8ZeazVuaQ+V2+Z7DpjVWY7Y+hhnmlo4WvbubyOL+aYT9OFCckkllAN9TcFt2+xq1L8DwLmkJk5w== - dependencies: - lodash.get "^4.4.2" - md5-hex "^2.0.0" - node-fetch "^2.2.0" - with-query "^1.1.2" - glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -7024,7 +7120,7 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== -lodash.get@^4.0.0, lodash.get@^4.4.2: +lodash.get@^4.0.0: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== @@ -7169,18 +7265,6 @@ matcher@^3.0.0: dependencies: escape-string-regexp "^4.0.0" -md5-hex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/md5-hex/-/md5-hex-2.0.0.tgz#d0588e9f1c74954492ecd24ac0ac6ce997d92e33" - integrity sha512-0HLfzJTZ7707VBNM1ydr5sTb+IZLhmU4u2TVA+Eenfn/Ed42/gn10smbAPiuEm/jNgjvWKUiMNihqJQ6flus9w== - dependencies: - md5-o-matic "^0.1.1" - -md5-o-matic@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/md5-o-matic/-/md5-o-matic-0.1.1.tgz#822bccd65e117c514fab176b25945d54100a03c3" - integrity sha512-QBJSFpsedXUl/Lgs4ySdB2XCzUEcJ3ujpbagdZCkRaYIaC0kFnID8jhc84KEiVv6dNFtIrmW7bqow0lDxgJi6A== - media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -7556,7 +7640,7 @@ node-api-version@^0.2.0: dependencies: semver "^7.3.5" -node-fetch@^2.2.0, node-fetch@^2.6.7: +node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -8371,7 +8455,7 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" -qs@^6.12.3, qs@^6.9.3: +qs@^6.12.3: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== @@ -9259,7 +9343,7 @@ socks-proxy-agent@^7.0.0: debug "^4.3.3" socks "^2.6.2" -socks@^2.6.2: +socks@^2.6.2, socks@^2.8.1: version "2.8.3" resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5" integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw== @@ -9946,6 +10030,11 @@ undici-types@~6.19.2: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici@^6.10.1, undici@^6.19.8: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.19.8.tgz#002d7c8a28f8cc3a44ff33c3d4be4d85e15d40e1" + integrity sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" @@ -10012,6 +10101,11 @@ unique-slug@^3.0.0: dependencies: imurmurhash "^0.1.4" +universal-user-agent@^7.0.0, universal-user-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.2.tgz#52e7d0e9b3dc4df06cc33cb2b9fd79041a54827e" + integrity sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q== + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" @@ -10499,14 +10593,6 @@ winreg@^1.2.4: resolved "https://registry.yarnpkg.com/winreg/-/winreg-1.2.5.tgz#b650383e89278952494b5d113ba049a5a4fa96d8" integrity sha512-uf7tHf+tw0B1y+x+mKTLHkykBgK2KMs3g+KlzmyMbLvICSHQyB/xOFjTT8qZ3oeTFyU7Bbj4FzXitGG6jvKhYw== -with-query@^1.1.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/with-query/-/with-query-1.3.0.tgz#994bef346c954f4d0761bc14f3c9af7c428728ee" - integrity sha512-grR6VEgM5imGOcQChTHN7D+mqavGBUo9EXzzge18EVagyFqBLVfA+amXmN30RLtnt9gJoiHmpSzhq6sfI3GrKQ== - dependencies: - "@types/qs" "^6.9.1" - qs "^6.9.3" - word-wrap@^1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"