├── .env ├── src ├── Footer.css ├── react-app-env.d.ts ├── App.css ├── UserActions.css ├── TopActions.css ├── ErrorDetails.css ├── IssueList.css ├── setupProxy.js ├── ListArea.css ├── App.tsx ├── Title.css ├── Footer.tsx ├── index.tsx ├── Title.tsx ├── NextStatus.tsx ├── issue.svg ├── ErrorDetails.tsx ├── ListTabs.css ├── pr.svg ├── Issue.css ├── index.css ├── Issue.tsx ├── IssueList.tsx ├── SearchForm.tsx ├── ListTabs.tsx ├── UserActions.tsx └── ListArea.tsx ├── public ├── logo.png ├── logo-half.png ├── manifest.json └── index.html ├── .config.example.php ├── .gitignore ├── tsconfig.json ├── server └── callback │ └── index.php ├── package.json └── README.md /.env: -------------------------------------------------------------------------------- 1 | # GitHub OAuth client ID 2 | REACT_APP_CLIENT_ID= -------------------------------------------------------------------------------- /src/Footer.css: -------------------------------------------------------------------------------- 1 | footer { 2 | margin-top: 2em; 3 | } 4 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | main { 2 | margin: auto; 3 | max-width: 1200px; 4 | } 5 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/repo-overseer/main/public/logo.png -------------------------------------------------------------------------------- /src/UserActions.css: -------------------------------------------------------------------------------- 1 | .token-mode { 2 | font-size: 85%; 3 | color: var(--color-dim); 4 | } 5 | -------------------------------------------------------------------------------- /public/logo-half.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/repo-overseer/main/public/logo-half.png -------------------------------------------------------------------------------- /src/TopActions.css: -------------------------------------------------------------------------------- 1 | .top-actions { 2 | display: flex; 3 | flex-wrap: wrap; 4 | gap: 0.5em; 5 | align-items: center; 6 | } 7 | -------------------------------------------------------------------------------- /.config.example.php: -------------------------------------------------------------------------------- 1 | 9 | 10 | <ListArea /> 11 | <Footer /> 12 | </main> 13 | ); 14 | } 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /src/Title.css: -------------------------------------------------------------------------------- 1 | hgroup { 2 | margin-bottom: 2em; 3 | } 4 | 5 | h1 { 6 | margin-top: 2rem; 7 | margin-bottom: 0.5rem; 8 | display: flex; 9 | gap: 0.75rem; 10 | align-items: center; 11 | } 12 | 13 | hgroup p { 14 | font-size: 125%; 15 | margin-top: 0; 16 | } 17 | 18 | .logo { 19 | height: 1.1em; 20 | } 21 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Repo Overseer", 3 | "name": "Repo Overseer", 4 | "icons": [ 5 | { 6 | "src": "logo.png", 7 | "type": "image/png", 8 | "sizes": "320x320" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#5e9aff", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/Footer.tsx: -------------------------------------------------------------------------------- 1 | import './Footer.css'; 2 | 3 | export default function Footer() { 4 | return ( 5 | <footer> 6 | Made by <a href="https://github.com/BrandonXLF">Brandon Fowler</a>.{' '} 7 | <a href="https://github.com/BrandonXLF/repo-overseer"> 8 | View source code 9 | </a> 10 | {''}. 11 | </footer> 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot( 7 | document.getElementById('root') as HTMLElement, 8 | ); 9 | root.render( 10 | <React.StrictMode> 11 | <App /> 12 | </React.StrictMode>, 13 | ); 14 | -------------------------------------------------------------------------------- /src/Title.tsx: -------------------------------------------------------------------------------- 1 | import './Title.css'; 2 | 3 | export default function Title() { 4 | return ( 5 | <hgroup> 6 | <h1> 7 | <img 8 | src="logo-half.png" 9 | className="logo" 10 | alt="Repo Overseer logo" 11 | /> 12 | <span>Repo Overseer</span> 13 | </h1> 14 | <p> 15 | View issues and pull requests in all your GitHub repositories. 16 | </p> 17 | </hgroup> 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .config.php 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/NextStatus.tsx: -------------------------------------------------------------------------------- 1 | import ErrorDetails from './ErrorDetails'; 2 | import { RequestStatus } from './ListArea'; 3 | 4 | export default function NextStatus({ 5 | status, 6 | }: Readonly<{ status: RequestStatus | undefined }>) { 7 | switch (status?.state) { 8 | case 'loading': 9 | return <div>Loading...</div>; 10 | case 'error': 11 | return <ErrorDetails error={status.error} reset={status.reset} />; 12 | case undefined: 13 | return null; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/issue.svg: -------------------------------------------------------------------------------- 1 | <svg 2 | xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" 3 | stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" 4 | class="icon icon-tabler icons-tabler-outline icon-tabler-circle-dot" 5 | > 6 | <title>Issue 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/ErrorDetails.tsx: -------------------------------------------------------------------------------- 1 | import './ErrorDetails.css'; 2 | 3 | export default function ErrorDetails({ 4 | error, 5 | reset, 6 | }: Readonly<{ error: string; reset?: string }>) { 7 | const resetTime = reset 8 | ? new Intl.DateTimeFormat(navigator.language, { 9 | timeStyle: 'medium', 10 | }).format(new Date(+reset * 1000)) 11 | : ''; 12 | 13 | return ( 14 |
15 |

Error

16 |
{error}
17 | {reset &&

Rate limit resets at {resetTime}

} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/ListTabs.css: -------------------------------------------------------------------------------- 1 | .list-tabs { 2 | display: flex; 3 | } 4 | 5 | .tab { 6 | border: none; 7 | background: var(--background); 8 | font: inherit; 9 | padding: 4px 0.5em; 10 | border-top: 1px solid transparent; 11 | border-bottom: 1px solid var(--tab-color); 12 | border-radius: 0; 13 | } 14 | 15 | select.tab { 16 | max-width: 8em; 17 | } 18 | 19 | .tab.selected { 20 | padding: 4px 0.5em; 21 | border: 1px solid var(--tab-color); 22 | border-bottom: 1px solid transparent; 23 | } 24 | 25 | .tab-filler { 26 | flex: 1; 27 | border-bottom: 1px solid var(--tab-color); 28 | } 29 | -------------------------------------------------------------------------------- /src/pr.svg: -------------------------------------------------------------------------------- 1 | 6 | Pull Request 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | Repo Overseer 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Issue.css: -------------------------------------------------------------------------------- 1 | .issue:not(:first-child) { 2 | padding-top: 0.75em; 3 | border-top: 1px solid var(--separator-color); 4 | } 5 | 6 | .issue-repo { 7 | display: flex; 8 | align-items: center; 9 | gap: 0.35em; 10 | } 11 | 12 | .issue-repo svg { 13 | width: unset; 14 | height: 1.25em; 15 | transform: translateY(1px); 16 | color: var(--color-dimmer); 17 | } 18 | 19 | .issue.open .issue-repo svg { 20 | color: var(--button-background); 21 | } 22 | 23 | .issue-repo a { 24 | font-size: 85%; 25 | color: var(--color-dim); 26 | text-decoration-color: var(--color-dimmer); 27 | } 28 | 29 | .issue-repo, 30 | .issue-title { 31 | margin-bottom: 6px; 32 | } 33 | 34 | .issue-title a { 35 | text-decoration: none; 36 | font-weight: 600; 37 | } 38 | 39 | .issue-info { 40 | font-size: 85%; 41 | color: var(--color-dim); 42 | } 43 | -------------------------------------------------------------------------------- /server/callback/index.php: -------------------------------------------------------------------------------- 1 | window.close();'); 10 | } 11 | 12 | curl_setopt($ch, CURLOPT_URL, 'https://github.com/login/oauth/access_token'); 13 | curl_setopt($ch, CURLOPT_POST, true); 14 | curl_setopt($ch, CURLOPT_HTTPHEADER, [ 15 | 'Content-Type: application/json', 16 | 'Accept: application/json' 17 | ]); 18 | curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 19 | 'client_id' => $CLIENT_ID, 20 | 'client_secret' => $CLIENT_SECRET, 21 | 'code' => $_GET['code'] 22 | ])); 23 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 24 | 25 | $res = json_decode(curl_exec($ch)); 26 | 27 | ?> 28 | 29 | 37 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --tab-color: #5e9aff; 3 | --separator-color: #616161; 4 | --input-border-color: #666; 5 | --background: #222; 6 | --button-background: #5e9aff; 7 | --select-background: #444; 8 | --color: #fff; 9 | --input-background: #111; 10 | --link-color: #5e9aff; 11 | --color-dim: #d9d9d9; 12 | --color-dimmer: #888; 13 | } 14 | 15 | body { 16 | background: var(--background); 17 | color: var(--color); 18 | font-family: 19 | -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 20 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 21 | sans-serif; 22 | } 23 | 24 | a, 25 | button, 26 | input, 27 | select { 28 | color: var(--color); 29 | } 30 | 31 | a, 32 | button { 33 | cursor: pointer; 34 | } 35 | 36 | button, 37 | input, 38 | select { 39 | border-radius: 4px; 40 | padding: 4px 8px; 41 | } 42 | 43 | button { 44 | background: var(--button-background); 45 | border: none; 46 | } 47 | 48 | input { 49 | background: var(--input-background); 50 | border: 1px solid var(--input-border-color); 51 | } 52 | 53 | select { 54 | background: var(--select-background); 55 | border: 1px solid var(--input-border-color); 56 | padding: 2px 2px; 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repo-overseer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "./", 6 | "dependencies": { 7 | "@octokit/openapi-types": "^25.1.0", 8 | "@octokit/request": "^10.0.3", 9 | "@octokit/request-error": "^7.0.0", 10 | "@types/react": "^19.1.9", 11 | "@types/react-dom": "^19.1.7", 12 | "classnames": "^2.5.1", 13 | "http-proxy-middleware": "^3.0.0", 14 | "prettier": "^3.3.2", 15 | "react": "^19.1.1", 16 | "react-dom": "^19.1.1", 17 | "react-scripts": "^5.1.0-next.26", 18 | "typescript": "^4.9.5" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "eject": "react-scripts eject", 24 | "format": "prettier . --write", 25 | "server": "cd server && php -S localhost:8972" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "prettier": { 45 | "useTabs": true, 46 | "tabWidth": 4, 47 | "singleQuote": true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Repo Overseer 2 | 3 | ## View issues and pull requests in all your GitHub repositories. 4 | 5 | Icon for Repo Overseer 6 | 7 | Repo Overseer allows you to view open issues and pull requests for all repositories owned by a user or organization. It can be accessed from https://www.brandonfowler.me/repo-overseer/. 8 | 9 | ## Development 10 | 11 | ### Requirements 12 | 13 | node.js, npm, php 14 | 15 | ### Commands 16 | 17 | Install dependencies using `npm install`. Once installed, the app can be launched in development mode by running `npm start`. It can be viewed by opening [http://localhost:3000](http://localhost:3000) in your browser. To build a production build, run `npm run build`. Use `npm run format` to format the code base. 18 | 19 | ### OAuth 20 | 21 | To enable GitHub login, a OAuth app will need to be created. See [Creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). 22 | 23 | ### Configuration 24 | 25 | Both `.config.php` and an local `.env` file should be populated with configuration values. See `.config.example.php` and `.env` for details. See [Adding Custom Environment Variables](https://create-react-app.dev/docs/adding-custom-environment-variables) for details in creating local `.env` files. 26 | -------------------------------------------------------------------------------- /src/Issue.tsx: -------------------------------------------------------------------------------- 1 | import { components } from '@octokit/openapi-types'; 2 | import './Issue.css'; 3 | import { ReactComponent as IssueIcon } from './issue.svg'; 4 | import { ReactComponent as PrIcon } from './pr.svg'; 5 | import classNames from 'classnames'; 6 | 7 | const repoRegex = /.*\/([^/]+\/[^/]+)$/; 8 | 9 | function formatDate(dateStr: string) { 10 | return new Intl.DateTimeFormat(navigator.language, { 11 | dateStyle: 'medium', 12 | timeStyle: 'short', 13 | }).format(new Date(dateStr)); 14 | } 15 | 16 | export default function Issue({ 17 | item, 18 | }: Readonly<{ 19 | item: components['schemas']['issue-search-result-item']; 20 | }>) { 21 | const repoName = repoRegex.exec(item.repository_url)?.[1]; 22 | 23 | return ( 24 |
30 |
31 | {item.pull_request ? : } 32 | {repoName} 33 |
34 |
35 | {item.title} 36 |
37 |
38 | #{item.number} opened on {formatDate(item.created_at)} by{' '} 39 | {item.user?.login}. Last updated {formatDate(item.updated_at)}. 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/IssueList.tsx: -------------------------------------------------------------------------------- 1 | import ErrorDetails from './ErrorDetails'; 2 | import Issue from './Issue'; 3 | import { List, RequestStatus } from './ListArea'; 4 | import NextStatus from './NextStatus'; 5 | import './IssueList.css'; 6 | 7 | export default function IssueList({ 8 | list, 9 | nextStatus, 10 | loadMore, 11 | }: Readonly<{ 12 | list: List; 13 | nextStatus: RequestStatus | undefined; 14 | loadMore: (() => void) | undefined; 15 | }>) { 16 | switch (list.state) { 17 | case 'unset': 18 | return
Search for a user above to get started!
; 19 | case 'loading': 20 | return
Loading...
; 21 | case 'error': 22 | return ; 23 | case 'loaded': 24 | if (!list.items.length) { 25 | return
No results found.
; 26 | } 27 | 28 | return ( 29 |
30 |
31 | {list.items.map((item) => ( 32 | 33 | ))} 34 |
35 |
36 | 37 |
38 | {loadMore && nextStatus?.state !== 'loading' && ( 39 |
40 | 43 |
44 | )} 45 |
46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | import './TopActions.css'; 3 | 4 | export default function SearchForm({ 5 | apiUser, 6 | onRepoOwnerChanged, 7 | }: Readonly<{ 8 | apiUser: string; 9 | onRepoOwnerChanged: (repoOwner: string) => void; 10 | }>) { 11 | const inputRef = useRef(null); 12 | 13 | const processOwnerInput = useCallback(() => { 14 | const repoOwner = inputRef.current?.value ?? ''; 15 | localStorage.setItem('repo-overseer-owner', repoOwner); 16 | onRepoOwnerChanged(inputRef.current?.value ?? ''); 17 | }, [onRepoOwnerChanged]); 18 | 19 | const setOwnerInput = useCallback( 20 | (newOwner: string) => { 21 | inputRef.current!.value = newOwner; 22 | processOwnerInput(); 23 | }, 24 | [processOwnerInput], 25 | ); 26 | 27 | // Load saved repo owner 28 | useEffect(() => { 29 | const initialOwner = localStorage.getItem('repo-overseer-owner') ?? ''; 30 | inputRef.current!.value = initialOwner; 31 | onRepoOwnerChanged(initialOwner); 32 | }, [onRepoOwnerChanged]); 33 | 34 | // Replace empty repo owner with logged-in user 35 | useEffect(() => { 36 | if (inputRef.current!.value || !apiUser) return; 37 | setOwnerInput(apiUser); 38 | }, [apiUser, setOwnerInput]); 39 | 40 | return ( 41 |
{ 44 | e.preventDefault(); 45 | processOwnerInput(); 46 | }} 47 | > 48 | 49 | 50 | {apiUser && ( 51 | 54 | )} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/ListTabs.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useEffect, useState } from 'react'; 3 | import './ListTabs.css'; 4 | 5 | const separatorText = '-------------'; 6 | 7 | const types = [ 8 | { 9 | name: 'Combined', 10 | filter: '', 11 | }, 12 | { 13 | name: 'Issues', 14 | filter: 'is:issue', 15 | }, 16 | { 17 | name: 'Pull Requests', 18 | filter: 'is:pr', 19 | }, 20 | ]; 21 | 22 | const states = [ 23 | { 24 | name: 'All open', 25 | filter: 'is:open', 26 | }, 27 | { 28 | name: 'Unassigned', 29 | filter: 'is:open no:assignee', 30 | }, 31 | { 32 | name: 'My tasks', 33 | filter: 'is:open assignee:__ME__', 34 | requireLogin: true, 35 | }, 36 | { 37 | name: 'By me', 38 | filter: 'is:open author:__ME__', 39 | requireLogin: true, 40 | }, 41 | { 42 | name: 'By others', 43 | filter: 'is:open -author:__ME__', 44 | requireLogin: true, 45 | }, 46 | { 47 | separator: true, 48 | }, 49 | { 50 | name: 'Closed', 51 | filter: 'is:closed', 52 | }, 53 | { 54 | separator: true, 55 | }, 56 | { 57 | name: 'Everything', 58 | filter: '', 59 | }, 60 | ]; 61 | 62 | const defaultStateFilter = states[0].filter as string; 63 | 64 | export default function ListTabs({ 65 | apiUser, 66 | onTypeFilterSet, 67 | onStateFilterSet, 68 | }: Readonly<{ 69 | apiUser: string; 70 | onTypeFilterSet: (typeFilter: string) => void; 71 | onStateFilterSet: (stateFilter: string) => void; 72 | }>) { 73 | const [typeFilter, setTypeFilter] = useState(types[0].filter); 74 | 75 | useEffect(() => { 76 | onStateFilterSet(defaultStateFilter); 77 | }, [onStateFilterSet]); 78 | 79 | useEffect(() => { 80 | onTypeFilterSet(typeFilter); 81 | }, [onTypeFilterSet, typeFilter]); 82 | 83 | return ( 84 |
85 | {types.map((type) => ( 86 | 96 | ))} 97 |
98 | 124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /src/UserActions.tsx: -------------------------------------------------------------------------------- 1 | import { request } from '@octokit/request'; 2 | import { useEffect, useState } from 'react'; 3 | import './UserActions.css'; 4 | import './TopActions.css'; 5 | 6 | const ALL_SCOPE = 'repo'; 7 | 8 | const modes = { 9 | ALL: 'Public + private repos', 10 | PUBLIC: 'Public repos only', 11 | }; 12 | 13 | async function doAuth(allRepos?: boolean) { 14 | window.open( 15 | `https://github.com/login/oauth/authorize?client_id=${process.env.REACT_APP_CLIENT_ID}&scope=${allRepos ? ALL_SCOPE : ''}`, 16 | ); 17 | } 18 | 19 | interface User { 20 | name: string; 21 | all: boolean; 22 | } 23 | 24 | export default function UserActions({ 25 | onUserChanged, 26 | onAuthToken, 27 | }: Readonly<{ 28 | onUserChanged: (apiUser: string) => void; 29 | onAuthToken: (auth: string) => void; 30 | }>) { 31 | const [signInAllRepos, setSignInAllRepos] = useState(true); 32 | const [auth, setAuth] = useState( 33 | () => localStorage.getItem('repo-overseer-auth') ?? '', 34 | ); 35 | const [user, setUser] = useState(); 36 | 37 | // Process OAuth callbacks 38 | useEffect(() => { 39 | const onMessage = async (e: MessageEvent) => { 40 | if ( 41 | e.origin !== window.location.origin || 42 | e.data.source !== 'github-auth' 43 | ) 44 | return; 45 | 46 | setAuth(`Bearer ${e.data.token}`); 47 | }; 48 | 49 | window.addEventListener('message', onMessage); 50 | return () => window.removeEventListener('message', onMessage); 51 | }, []); 52 | 53 | // Update user when auth changes 54 | useEffect(() => { 55 | if (!auth) { 56 | setUser(undefined); 57 | return; 58 | } 59 | 60 | (async () => { 61 | const res = await request('GET /user', { 62 | headers: { 63 | authorization: auth, 64 | }, 65 | }); 66 | 67 | setUser({ 68 | name: res.data.login, 69 | all: 70 | res.headers['x-oauth-scopes'] 71 | ?.split(',') 72 | .includes(ALL_SCOPE) ?? false, 73 | }); 74 | })(); 75 | }, [auth]); 76 | 77 | // Dispatch event and save authorization token 78 | useEffect(() => { 79 | onAuthToken(auth); 80 | localStorage.setItem('repo-overseer-auth', auth); 81 | }, [onAuthToken, auth]); 82 | 83 | // Dispatch user changed event 84 | useEffect(() => { 85 | onUserChanged(user?.name ?? ''); 86 | }, [onUserChanged, user]); 87 | 88 | return ( 89 |
90 | {auth ? ( 91 | <> 92 | {user ? ( 93 | <> 94 | {user.name ?? 'Loading...'} 95 | 96 | ({user.all ? modes.ALL : modes.PUBLIC}) 97 | 98 | 99 | ) : ( 100 | 'Loading...' 101 | )} 102 | 110 | 111 | ) : ( 112 | <> 113 | 122 | 125 | 126 | )} 127 |
128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /src/ListArea.tsx: -------------------------------------------------------------------------------- 1 | import { request } from '@octokit/request'; 2 | import { components } from '@octokit/openapi-types'; 3 | import { useCallback, useEffect, useState } from 'react'; 4 | import { RequestError } from '@octokit/request-error'; 5 | import UserActions from './UserActions'; 6 | import SearchForm from './SearchForm'; 7 | import ListTabs from './ListTabs'; 8 | import IssueList from './IssueList'; 9 | import './ListArea.css'; 10 | 11 | export type RequestStatus = 12 | | { 13 | state: 'loading'; 14 | } 15 | | { 16 | state: 'error'; 17 | error: string; 18 | reset?: string; 19 | }; 20 | 21 | export type List = 22 | | RequestStatus 23 | | { 24 | state: 'unset'; 25 | } 26 | | { 27 | state: 'error'; 28 | error: string; 29 | reset?: string; 30 | } 31 | | { 32 | state: 'loaded'; 33 | items: components['schemas']['issue-search-result-item'][]; 34 | }; 35 | 36 | const nextPattern = /(?<=<)(\S*)(?=>; rel="Next")/i; 37 | 38 | export default function ListArea() { 39 | const [list, setList] = useState({ state: 'unset' }); 40 | 41 | const [typeFilter, setTypeFilter] = useState(''); 42 | const [stateFilter, setStateFilter] = useState(''); 43 | const [repoOwner, setRepoOwner] = useState(''); 44 | const [auth, setAuth] = useState(''); 45 | const [apiUser, setApiUser] = useState(''); 46 | const [nextStatus, setNextStatus] = useState( 47 | undefined, 48 | ); 49 | const [nextPage, setNextPage] = useState(null); 50 | 51 | const processNewOwner = useCallback((newOwner: string) => { 52 | setRepoOwner(newOwner); 53 | }, []); 54 | 55 | const makeRequest = useCallback( 56 | async (loadMoreRequest?: boolean) => { 57 | if (!loadMoreRequest) { 58 | setNextStatus(undefined); 59 | } 60 | 61 | if (!repoOwner) { 62 | setList({ state: 'unset' }); 63 | return; 64 | } 65 | 66 | const setStatus = loadMoreRequest ? setNextStatus : setList; 67 | setStatus({ state: 'loading' }); 68 | 69 | try { 70 | const res = await request('GET /search/issues', { 71 | q: `user:${repoOwner} ${stateFilter} ${typeFilter} sort:updated-desc`, 72 | headers: { 73 | authorization: auth, 74 | }, 75 | page: loadMoreRequest ? (nextPage ?? 1) : undefined, 76 | }); 77 | 78 | const nextUrl = res.headers.link?.match(nextPattern)?.[0]; 79 | const newNextPage = 80 | nextUrl && new URL(nextUrl).searchParams.get('page'); 81 | setNextPage(newNextPage ? +newNextPage : null); 82 | 83 | if (loadMoreRequest) { 84 | setList((prevList) => { 85 | if (prevList.state !== 'loaded') { 86 | return prevList; 87 | } 88 | 89 | return { 90 | state: 'loaded', 91 | items: [...prevList.items, ...res.data.items], 92 | }; 93 | }); 94 | 95 | setNextStatus(undefined); 96 | } else { 97 | setList({ 98 | state: 'loaded', 99 | items: res.data.items, 100 | }); 101 | } 102 | } catch (e) { 103 | if (!(e instanceof RequestError)) throw e; 104 | 105 | const obj: { 106 | state: 'error'; 107 | error: string; 108 | reset?: string; 109 | } = { 110 | state: 'error', 111 | error: e.message, 112 | }; 113 | 114 | if (e.message.includes('rate limit')) { 115 | obj.reset = e.response?.headers['x-ratelimit-reset'] ?? ''; 116 | } 117 | 118 | setStatus(obj); 119 | } 120 | }, 121 | [auth, nextPage, repoOwner, stateFilter, typeFilter], 122 | ); 123 | 124 | useEffect(() => { 125 | makeRequest(); 126 | // eslint-disable-next-line react-hooks/exhaustive-deps 127 | }, [auth, repoOwner, stateFilter, typeFilter]); 128 | 129 | return ( 130 |
131 |
132 | 136 | 137 |
138 |
139 | 144 |
145 |
146 | makeRequest(true) : undefined 151 | } 152 | /> 153 |
154 |
155 | ); 156 | } 157 | --------------------------------------------------------------------------------