├── .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 |
11 |
12 |
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 |
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 |
11 |
12 | ,
13 | );
14 |
--------------------------------------------------------------------------------
/src/Title.tsx:
--------------------------------------------------------------------------------
1 | import './Title.css';
2 |
3 | export default function Title() {
4 | return (
5 |
6 |
7 |
12 | Repo Overseer
13 |
14 |
15 | View issues and pull requests in all your GitHub repositories.
16 |
17 |
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 Loading...
;
10 | case 'error':
11 | return ;
12 | case undefined:
13 | return null;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/issue.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------