@kubohiroya/turbowarp-plus-editor-git 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.ja.md +13 -3
  2. package/README.md +12 -2
  3. package/dist/branch-button.d.ts +47 -0
  4. package/dist/change-marks.d.ts +8 -0
  5. package/dist/connection-settings.d.ts +3 -1
  6. package/dist/github-pull-requests.d.ts +2 -2
  7. package/dist/github-review-api.d.ts +112 -0
  8. package/dist/index.d.ts +29 -5
  9. package/dist/index.js +830 -303
  10. package/dist/messages.d.ts +109 -2
  11. package/dist/{project-asset-paths-nL5sV35A.js → project-asset-paths-DNaoWs3E.js} +2 -2
  12. package/dist/{project-comparison-DjMlwyp8.js → project-comparison-B_3QZ6M-.js} +1414 -33
  13. package/dist/project-source-diff.d.ts +5 -0
  14. package/dist/project-source-diff.js +4 -4
  15. package/dist/project-source-review.d.ts +6 -1
  16. package/dist/pull-request-panel.d.ts +2 -0
  17. package/dist/remote-operations.d.ts +1 -1
  18. package/dist/{repository-xIv0iZkj.js → repository-DXRZEtYk.js} +26 -10
  19. package/dist/repository.d.ts +7 -0
  20. package/dist/repository.js +1 -1
  21. package/dist/review-composer.d.ts +23 -0
  22. package/dist/review-submit.d.ts +144 -0
  23. package/dist/{smart-http-BmHEkE7K.js → smart-http-eARzlXHO.js} +101 -2
  24. package/dist/smart-http.d.ts +2 -0
  25. package/dist/smart-http.js +1 -1
  26. package/dist/style.css +51 -0
  27. package/package.json +1 -1
  28. package/src/branch-button.tsx +289 -0
  29. package/src/change-marks.ts +22 -0
  30. package/src/connection-settings.tsx +9 -2
  31. package/src/github-review-api.ts +298 -0
  32. package/src/index.tsx +322 -66
  33. package/src/messages.ts +80 -4
  34. package/src/project-source-diff.css +10 -0
  35. package/src/project-source-diff.tsx +6 -0
  36. package/src/project-source-review.tsx +33 -5
  37. package/src/pull-request-panel.tsx +27 -6
  38. package/src/remote-operations.ts +1 -1
  39. package/src/repository.ts +22 -8
  40. package/src/review-composer.tsx +166 -0
  41. package/src/review-submit.ts +519 -0
  42. package/src/smart-http.ts +2 -2
  43. package/src/style.css +41 -0
  44. package/dist/objectSpread2-Dkd68nJH.js +0 -101
@@ -0,0 +1,289 @@
1
+ import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
2
+ import {createPortal} from 'react-dom';
3
+ import {CloseButton} from './close-button.js';
4
+ import type {HistoryActionResult} from './history-graph-view.js';
5
+ import {labelsFor} from './messages.js';
6
+ import type {BranchRefs} from './repository.js';
7
+
8
+ /** What the branch button shows: the branches of the chosen repository as the Git panel last read them. */
9
+ export interface BranchButtonState extends BranchRefs {
10
+ /** Whether a repository has been chosen. */
11
+ repository: boolean;
12
+ /** Whether the host lets the panel switch and create branches. */
13
+ writable: boolean;
14
+ /** Whether the panel is running an operation. */
15
+ busy: boolean;
16
+ /** Whether a project folder is selected in the panel, so a switch can offer to reload it. */
17
+ project: boolean;
18
+ }
19
+ /** What the branch button asks the Git panel to do. Branch operations resolve as the panel's own buttons do. */
20
+ export interface BranchButtonActions {
21
+ open(): void;
22
+ choose(): void;
23
+ /** The panel's Reload Project, which asks before replacing unsaved edits. */
24
+ reload(): void;
25
+ switchBranch(name: string): Promise<HistoryActionResult>;
26
+ /** Creates a branch at HEAD. */
27
+ createBranch(name: string, checkout: boolean): Promise<HistoryActionResult>;
28
+ /** Creates a local branch from `origin/<name>` and switches to it. */
29
+ createFromRemote(name: string): Promise<HistoryActionResult>;
30
+ }
31
+ export interface BranchButtonProps extends BranchButtonActions {
32
+ state: BranchButtonState;
33
+ locale?: string;
34
+ }
35
+ interface Item {id: string; label: string; checked?: boolean; disabled?: boolean; separatorBefore?: boolean; run(): void}
36
+
37
+ const menuItems = '[role="menuitem"], [role="menuitemradio"]';
38
+ /** How long a snackbar stays when it is neither hovered nor focused. */
39
+ export const snackbarDuration = 6000;
40
+ interface Snack {id: number; text: string; alert: boolean; action?: {label: string; run(): void}}
41
+
42
+ /**
43
+ * A short result at the bottom of the window, with the project folder's status look. It stays while hovered or
44
+ * focused, Escape or the close button dismisses it, and focus is never moved into it.
45
+ */
46
+ function Snackbar({snack, closeLabel, onClose}: {snack: Snack; closeLabel: string; onClose(focus: boolean): void}) {
47
+ // Kept outside React state, so a pointer or focus arriving just before the timeout stops it at once.
48
+ const held = useRef({hover: false, focus: false});
49
+ const timer = useRef<ReturnType<typeof setTimeout>>();
50
+ const hold = (change: Partial<typeof held.current>) => {
51
+ held.current = {...held.current, ...change};
52
+ clearTimeout(timer.current);
53
+ if (!held.current.hover && !held.current.focus) timer.current = setTimeout(() => onClose(false), snackbarDuration);
54
+ };
55
+ useEffect(() => {
56
+ hold({});
57
+ return () => clearTimeout(timer.current);
58
+ }, []);
59
+ return createPortal(<div className="twgit-snackbar" data-git-snackbar=""
60
+ onMouseEnter={() => hold({hover: true})} onMouseLeave={() => hold({hover: false})}
61
+ onFocus={() => hold({focus: true})}
62
+ onBlur={event => {
63
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) hold({focus: false});
64
+ }}
65
+ onKeyDown={event => {
66
+ if (event.key !== 'Escape') return;
67
+ event.preventDefault();
68
+ event.stopPropagation();
69
+ onClose(true);
70
+ }}>
71
+ <span role={snack.alert ? 'alert' : 'status'}>{snack.text}</span>
72
+ {snack.action && <button type="button" onClick={() => {onClose(true); snack.action!.run();}}>{snack.action.label}</button>}
73
+ <CloseButton label={closeLabel} onClick={() => onClose(true)}/>
74
+ </div>, document.body);
75
+ }
76
+
77
+ /**
78
+ * The results of branch operations in a snackbar, as the branch button and the history view tell them: a switch with
79
+ * Reload Project while a project folder is selected, a new branch, or the reason for a refusal with Open Local Git.
80
+ * A declined confirmation tells nothing. `restoreFocus` takes the focus back when the snackbar closes from inside.
81
+ */
82
+ export function useBranchSnackbar(locale: string, project: boolean, actions: Pick<BranchButtonActions, 'open' | 'reload'>,
83
+ restoreFocus?: () => void) {
84
+ const labels = labelsFor(locale);
85
+ const [snack, setSnack] = useState<Snack | null>(null);
86
+ const snacks = useRef(0);
87
+ const mounted = useRef(true);
88
+ useEffect(() => () => {mounted.current = false;}, []);
89
+ const show = (text: string, alert: boolean, action?: Snack['action']) => {
90
+ if (mounted.current) setSnack({id: ++snacks.current, text, alert, action});
91
+ };
92
+ const report = (result: HistoryActionResult, switched: boolean) => {
93
+ if (result.done) {
94
+ if (!switched) show(labels.branchCreated, false);
95
+ // The switch rewrote the files; the open project shows them after Reload Project.
96
+ else show(labels.switchedShort, false, project ? {label: labels.reload, run: actions.reload} : undefined);
97
+ } else if (result.error) show(result.error, true, {label: labels.openPanel, run: actions.open});
98
+ };
99
+ const element = snack && <Snackbar key={snack.id} snack={snack} closeLabel={labels.close} onClose={focus => {
100
+ setSnack(previous => previous?.id === snack.id ? null : previous);
101
+ // Focus inside the snackbar would be lost with it.
102
+ if (focus) restoreFocus?.();
103
+ }}/>;
104
+ return {report, element};
105
+ }
106
+
107
+ /** Git's branch glyph, drawn in the text color. */
108
+ function BranchIcon() {
109
+ return <svg className="twgit-branch-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
110
+ <g fill="none" stroke="currentColor" strokeWidth="1.5">
111
+ <circle cx="4.5" cy="3.5" r="1.75"/><circle cx="4.5" cy="12.5" r="1.75"/><circle cx="11.5" cy="5.5" r="1.75"/>
112
+ <path d="M4.5 5.25v5.5M11.5 7.25c0 2.5-2.5 2.75-6 3.75"/>
113
+ </g>
114
+ </svg>;
115
+ }
116
+
117
+ /**
118
+ * The current branch as a menu button for the host's menu bar, with the menu of branches. Before a repository is
119
+ * chosen it reads "Git" and offers to choose one. A detached HEAD shows its short commit ID. The menu moves with the
120
+ * arrow keys, Home and End; Escape closes it and returns to the button. Without `writable` it lists the branches only.
121
+ */
122
+ export function BranchButton({state, locale = 'en', ...actions}: BranchButtonProps) {
123
+ const labels = labelsFor(locale);
124
+ const [mode, setMode] = useState<'closed' | 'menu' | 'create'>('closed');
125
+ const [initial, setInitial] = useState<'checked' | 'first' | 'last'>('checked');
126
+ const [name, setName] = useState('');
127
+ const [checkout, setCheckout] = useState(true);
128
+ const [creating, setCreating] = useState(false);
129
+ const [createError, setCreateError] = useState('');
130
+ const root = useRef<HTMLDivElement>(null);
131
+ const trigger = useRef<HTMLButtonElement>(null);
132
+ const popup = useRef<HTMLDivElement>(null);
133
+ const mounted = useRef(true);
134
+ useEffect(() => () => {mounted.current = false;}, []);
135
+ useLayoutEffect(() => {
136
+ const element = popup.current;
137
+ if (!element) return;
138
+ if (mode === 'create') {
139
+ element.querySelector('input')?.focus();
140
+ return;
141
+ }
142
+ const items = [...element.querySelectorAll<HTMLElement>(menuItems)];
143
+ const checked = items.find(item => item.getAttribute('aria-checked') === 'true');
144
+ (initial === 'last' ? items[items.length - 1] : initial === 'checked' && checked || items[0])?.focus();
145
+ }, [mode]);
146
+ // A click elsewhere closes the menu, also where nothing takes the focus.
147
+ useEffect(() => {
148
+ if (mode === 'closed') return;
149
+ const node = root.current!;
150
+ const dismiss = (event: Event) => {
151
+ if (!node.contains(event.target as Node) && !creating) setMode('closed');
152
+ };
153
+ node.ownerDocument.addEventListener('pointerdown', dismiss, true);
154
+ return () => node.ownerDocument.removeEventListener('pointerdown', dismiss, true);
155
+ }, [mode, creating]);
156
+ // After a refused create, the name is selected for correcting.
157
+ useEffect(() => {
158
+ if (!createError || creating) return;
159
+ const input = popup.current?.querySelector('input:not([type])') as HTMLInputElement | null;
160
+ input?.focus();
161
+ input?.select();
162
+ }, [createError, creating]);
163
+
164
+ const {branch, head} = state;
165
+ const unborn = state.repository && !head;
166
+ const text = !state.repository ? 'Git' : branch ?? (head ? head.slice(0, 7) : labels.unborn);
167
+ const accessibleName = !state.repository ? labels.title :
168
+ `${branch ? labels.branch : labels.detached}: ${text}${unborn && branch ? ` (${labels.unborn})` : ''}`;
169
+ const close = (focus = true) => {
170
+ setMode('closed');
171
+ if (focus) trigger.current?.focus({preventScroll: true});
172
+ };
173
+ const openMenu = (from: 'checked' | 'first' | 'last') => {
174
+ setInitial(from);
175
+ setMode('menu');
176
+ };
177
+ const {report, element: snackbar} = useBranchSnackbar(locale, state.project, actions,
178
+ () => trigger.current?.focus({preventScroll: true}));
179
+ const change = async (operation: () => Promise<HistoryActionResult>) => report(await operation(), true);
180
+ const items: Item[] = [];
181
+ if (!state.repository) {
182
+ items.push({id: 'choose', label: labels.choose, run: () => {close(); actions.choose();}});
183
+ } else {
184
+ const switching = state.writable && !state.busy;
185
+ for (const local of state.branches) {
186
+ items.push({id: 'branch:' + local, label: local, checked: local === branch, disabled: !state.writable || state.busy && local !== branch,
187
+ run: () => {
188
+ close();
189
+ if (local !== branch && switching) void change(() => actions.switchBranch(local));
190
+ }});
191
+ }
192
+ if (state.writable) {
193
+ state.remoteBranches.forEach((remote, index) => items.push({id: 'remote:' + remote,
194
+ label: labels.historyCreateFromRemote.replace('{name}', remote), disabled: state.busy, separatorBefore: index === 0,
195
+ run: () => {
196
+ close();
197
+ if (!state.busy) void change(() => actions.createFromRemote(remote));
198
+ }}));
199
+ if (head) items.push({id: 'create', label: labels.newBranchItem, disabled: state.busy, separatorBefore: true, run: () => {
200
+ if (state.busy) return;
201
+ setName('');
202
+ setCheckout(true);
203
+ setCreateError('');
204
+ setMode('create');
205
+ }});
206
+ }
207
+ }
208
+ // "Open Local Git" goes with "New Branch…" and stands apart from the branches.
209
+ items.push({id: 'open', label: labels.openPanel, separatorBefore: items.length > 0 && items[items.length - 1].id !== 'create',
210
+ run: () => {close(); actions.open();}});
211
+
212
+ const onMenuKey = (event: React.KeyboardEvent<HTMLElement>) => {
213
+ if (event.key === 'Escape') {
214
+ event.preventDefault();
215
+ event.stopPropagation();
216
+ if (!creating) close();
217
+ return;
218
+ }
219
+ if (mode !== 'menu') return;
220
+ if (event.key === 'Tab') {close(false); return;}
221
+ const elements = [...event.currentTarget.querySelectorAll<HTMLElement>(menuItems)];
222
+ const index = elements.indexOf(event.target as HTMLElement);
223
+ const target = {ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: elements.length - 1}[event.key];
224
+ if (target === undefined || !elements.length) return;
225
+ event.preventDefault();
226
+ elements[(target + elements.length) % elements.length].focus();
227
+ };
228
+ const submit = async (event: React.FormEvent) => {
229
+ event.preventDefault();
230
+ const branchName = name.trim();
231
+ if (!branchName || creating) return;
232
+ setCreating(true);
233
+ setCreateError('');
234
+ let result: HistoryActionResult;
235
+ try {
236
+ result = await actions.createBranch(branchName, checkout);
237
+ } catch (error) {
238
+ result = {done: false, error: error instanceof Error && error.message ? error.message : labels.failed};
239
+ }
240
+ if (!mounted.current) return;
241
+ setCreating(false);
242
+ if (result.done) {
243
+ close();
244
+ report(result, checkout);
245
+ } else setCreateError(result.error ?? '');
246
+ };
247
+ return <div className="twgit-branch-entry" ref={root} onBlur={event => {
248
+ const next = event.relatedTarget as Node | null;
249
+ if (next && !event.currentTarget.contains(next) && !creating) setMode('closed');
250
+ }}>
251
+ <button type="button" className="twgit-branch-button" ref={trigger} aria-label={accessibleName} title={accessibleName}
252
+ aria-haspopup="menu" aria-expanded={mode === 'menu'} data-git-branch={state.repository ? branch ?? head ?? '' : undefined}
253
+ onClick={() => mode === 'closed' ? openMenu('checked') : close(false)}
254
+ onKeyDown={event => {
255
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
256
+ event.preventDefault();
257
+ openMenu(event.key === 'ArrowUp' ? 'last' : 'checked');
258
+ }}>
259
+ <BranchIcon/>
260
+ <span className="twgit-branch-name">{text}</span>
261
+ {unborn && branch && <small className="twgit-branch-unborn">{labels.unborn}</small>}
262
+ <span className="twgit-branch-caret" aria-hidden="true">▾</span>
263
+ </button>
264
+ {mode === 'menu' && <div ref={popup} className="twgit-branch-popup" role="menu" aria-label={labels.branchMenu} onKeyDown={onMenuKey}>
265
+ {items.map(item => <button key={item.id} type="button" tabIndex={-1} data-action={item.id}
266
+ role={item.checked === undefined ? 'menuitem' : 'menuitemradio'} aria-checked={item.checked}
267
+ aria-disabled={item.disabled || undefined} data-separator-before={item.separatorBefore || undefined}
268
+ onClick={() => {if (!item.disabled || item.checked) item.run();}}>
269
+ {item.checked !== undefined && <span className="twgit-branch-check" aria-hidden="true">{item.checked ? '✓' : ''}</span>}
270
+ {item.label}
271
+ </button>)}
272
+ </div>}
273
+ {mode === 'create' && <div ref={popup} className="twgit-branch-popup" role="dialog" aria-label={labels.newBranchTitle}
274
+ onKeyDown={onMenuKey}>
275
+ <form onSubmit={event => void submit(event)}>
276
+ <label>{labels.newBranch} <input value={name} required disabled={creating} aria-invalid={createError ? true : undefined}
277
+ onChange={event => setName(event.currentTarget.value)}/></label>
278
+ <label><input type="checkbox" checked={checkout} disabled={creating}
279
+ onChange={event => setCheckout(event.currentTarget.checked)}/> {labels.switchAfterCreate}</label>
280
+ {createError && <p className="twgit-graph-form-error" role="alert">{createError}</p>}
281
+ <span className="twgit-graph-form-actions">
282
+ <button type="submit" disabled={creating || state.busy || !name.trim()}>{labels.createBranch}</button>
283
+ <button type="button" disabled={creating} onClick={() => close()}>{labels.cancel}</button>
284
+ </span>
285
+ </form>
286
+ </div>}
287
+ {snackbar}
288
+ </div>;
289
+ }
@@ -0,0 +1,22 @@
1
+ import type {FileChange} from './repository.js';
2
+
3
+ /** How a file stands in Git, as a folder explorer marks it. */
4
+ export type ChangeMark = 'added' | 'modified' | 'deleted' | 'untracked';
5
+
6
+ /**
7
+ * The marks of the working tree's changes under `folder` (a path inside the repository, '' for its root), by path
8
+ * relative to `folder`. A file with both staged and unstaged changes is marked as its working tree stands.
9
+ */
10
+ export function changeMarks(changes: readonly FileChange[], folder: string): Map<string, ChangeMark> {
11
+ const prefix = folder.replace(/^\/+|\/+$/gu, '');
12
+ const marks = new Map<string, ChangeMark>();
13
+ for (const group of ['staged', 'unstaged', 'untracked'] as const) {
14
+ for (const change of changes) {
15
+ if (change.group !== group) continue;
16
+ if (prefix && !change.path.startsWith(prefix + '/')) continue;
17
+ const path = prefix ? change.path.slice(prefix.length + 1) : change.path;
18
+ marks.set(path, group === 'untracked' ? 'untracked' : !change.after ? 'deleted' : !change.before ? 'added' : 'modified');
19
+ }
20
+ }
21
+ return marks;
22
+ }
@@ -1,6 +1,7 @@
1
1
  import React, {useEffect, useRef, useState} from 'react';
2
2
  import type {GitAuthState, GitConnectionMode as Mode} from '@kubohiroya/turbowarp-plus-editor-auth';
3
3
  import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
4
+ import {reviewLabelsFor} from './messages.js';
4
5
 
5
6
  export interface GitConnectionSettings {
6
7
  modes: readonly Mode[];
@@ -9,10 +10,12 @@ export interface GitConnectionSettings {
9
10
  state: GitAuthState;
10
11
  proxyOrigin?: string;
11
12
  write?: boolean;
13
+ /** Whether the connection was made with the grant to send pull request reviews. */
14
+ review?: boolean;
12
15
  authorization?: {url: string; userCode?: string};
13
16
  };
14
17
  select(mode: Mode): Promise<void>;
15
- connect(remote: string, write?: boolean): Promise<void>;
18
+ connect(remote: string, write?: boolean, review?: boolean): Promise<void>;
16
19
  refresh(): Promise<void>;
17
20
  disconnect(): Promise<void>;
18
21
  getSession?: GetRemoteSession;
@@ -49,6 +52,7 @@ export function ConnectionSettings({settings, locale, readOrigin, runOperation}:
49
52
  const [, update] = useState(0);
50
53
  const [busy, setBusy] = useState(false);
51
54
  const [allowPush, setAllowPush] = useState(false);
55
+ const [allowReview, setAllowReview] = useState(false);
52
56
  const [error, setError] = useState<'error' | 'invalid' | null>(null);
53
57
  const active = useRef(true);
54
58
  useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
@@ -66,7 +70,7 @@ export function ConnectionSettings({settings, locale, readOrigin, runOperation}:
66
70
  const connect = () => run(async () => {
67
71
  const remote = await readOrigin?.();
68
72
  if (!remote) throw new Error('INVALID_REMOTE');
69
- await settings.connect(remote, allowPush);
73
+ await (allowReview ? settings.connect(remote, allowPush, true) : settings.connect(remote, allowPush));
70
74
  });
71
75
  const connected = !!mode && state.status === 'connected';
72
76
  // Sync sits beside the GitHub status; the settings open over the list instead of pushing it down.
@@ -92,6 +96,9 @@ export function ConnectionSettings({settings, locale, readOrigin, runOperation}:
92
96
  {runOperation && <label><input type="checkbox" checked={allowPush}
93
97
  disabled={busy || state.status === 'connecting' || state.status === 'connected'}
94
98
  onChange={event => setAllowPush(event.currentTarget.checked)}/> {labels.allowPush}</label>}
99
+ <label><input type="checkbox" data-git-allow-review="" checked={allowReview}
100
+ disabled={busy || state.status === 'connecting' || state.status === 'connected'}
101
+ onChange={event => setAllowReview(event.currentTarget.checked)}/> {reviewLabelsFor(locale).allowReview}</label>
95
102
  <button disabled={busy || !readOrigin || state.status === 'connecting' || state.status === 'connected'}
96
103
  onClick={connect}>{labels.connect}</button>
97
104
  {authorization && <p>{authorization.userCode && <code>{authorization.userCode} </code>}
@@ -0,0 +1,298 @@
1
+ import type {GitProxySession} from '@kubohiroya/turbowarp-plus-editor-auth';
2
+ import {checkConnection, remoteUrl} from './smart-http.js';
3
+
4
+ /**
5
+ * The pull request review routes of the GitHub connection's proxy, under `/v1/github/<owner>/<repo>/`. The proxy keeps
6
+ * the GitHub token and the GitHub node IDs; this client sends only a pull request number, the compared commit, a
7
+ * Review-Submit-Id and the review's words, and reads back the few fields the proxy returns.
8
+ */
9
+
10
+ export type ReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
11
+ export const reviewEvents: readonly ReviewEvent[] = ['COMMENT', 'APPROVE', 'REQUEST_CHANGES'];
12
+
13
+ const proxyCodes = {
14
+ 400: ['BAD_REQUEST'],
15
+ 401: ['SESSION_REQUIRED', 'SESSION_EXPIRED', 'REAUTH_REQUIRED'],
16
+ 403: ['FORBIDDEN'],
17
+ // A proxy without the route group answers 404 too, so an unnamed 404 means the route is unavailable.
18
+ 404: ['GITHUB_API_UNAVAILABLE', 'PULL_REQUEST_NOT_FOUND'],
19
+ 409: ['REVIEW_STEP_CONFLICT', 'REVIEW_DUPLICATE', 'PENDING_REVIEW_EXISTS', 'REDIRECT_REJECTED'],
20
+ 411: ['LENGTH_REQUIRED'],
21
+ 413: ['PAYLOAD_TOO_LARGE'],
22
+ 422: ['REVIEW_REJECTED', 'REVIEW_THREAD_REJECTED'],
23
+ 429: ['RATE_LIMITED'],
24
+ 500: ['GITHUB_API_MISCONFIGURED'],
25
+ 502: ['UPSTREAM_FAILURE'],
26
+ 504: ['UPSTREAM_TIMEOUT']
27
+ } as const;
28
+
29
+ type ProxyCode = (typeof proxyCodes)[keyof typeof proxyCodes][number];
30
+ export type GitHubReviewErrorCode = ProxyCode | 'INVALID_REMOTE' | 'INVALID_CONNECTION' | 'INVALID_SESSION' |
31
+ 'SESSION_UNAVAILABLE' | 'NETWORK_ERROR' | 'INVALID_RESPONSE' | 'ABORTED';
32
+
33
+ // Codes after which a mutation may or may not have reached GitHub.
34
+ const unknownCodes: readonly GitHubReviewErrorCode[] = ['REVIEW_DUPLICATE', 'UPSTREAM_FAILURE', 'UPSTREAM_TIMEOUT',
35
+ 'NETWORK_ERROR', 'INVALID_RESPONSE'];
36
+
37
+ export class GitHubReviewError extends Error {
38
+ constructor(readonly code: GitHubReviewErrorCode, readonly options: {
39
+ status?: number; requestId?: string; retryAfter?: number;
40
+ /** A mutation was sent and GitHub may have applied it. */
41
+ unknown?: boolean;
42
+ } = {}) {
43
+ super(code);
44
+ this.name = 'GitHubReviewError';
45
+ }
46
+ get unknown() {return this.options.unknown === true;}
47
+ get retryAfter() {return this.options.retryAfter;}
48
+ }
49
+
50
+ const isSha = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{40}$/u.test(value);
51
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
52
+ const isCount = (value: unknown, max: number): value is number => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= max;
53
+ const optionalString = (value: unknown) => value === null || typeof value === 'string';
54
+ const submitIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
55
+ const requestIdPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
56
+
57
+ /** A thread path the proxy accepts: a relative path whose last element is exactly project.source.json. */
58
+ export function isReviewThreadPath(path: string) {
59
+ const parts = path.split('/');
60
+ return parts[parts.length - 1] === 'project.source.json' && new TextEncoder().encode(path).byteLength <= 1024 &&
61
+ parts.every(part => part && part !== '.' && part !== '..') && !/[\\\u0000-\u001f\u007f]/u.test(path);
62
+ }
63
+
64
+ export function newReviewSubmitId() {
65
+ const id = crypto.randomUUID();
66
+ if (!submitIdPattern.test(id)) throw new GitHubReviewError('INVALID_RESPONSE');
67
+ return id;
68
+ }
69
+
70
+ export interface ReviewContext {
71
+ headRefOid: string;
72
+ viewer: string;
73
+ pendingReview: {ours: boolean; commitOid: string | null; createdAt: string} | null;
74
+ reviews: {state: string; commitOid: string | null; submittedAt: string | null; ours: boolean}[];
75
+ /** The proxy's record of the submission, when the request named one. */
76
+ submit?: {state: string; threads: number};
77
+ }
78
+
79
+ export interface SubmittedReview {state: string; commitOid: string; submittedAt: string; url: string}
80
+ export interface RestReview {id: number; login: string | null; commitId: string | null; state: string; body: string}
81
+
82
+ function readContext(value: unknown, withSubmit: boolean): ReviewContext {
83
+ if (!isRecord(value) || !isSha(value.headRefOid) || typeof value.viewer !== 'string' || !Array.isArray(value.reviews))
84
+ throw new GitHubReviewError('INVALID_RESPONSE');
85
+ let pendingReview: ReviewContext['pendingReview'] = null;
86
+ if (value.pendingReview !== null) {
87
+ const pending = value.pendingReview;
88
+ if (!isRecord(pending) || typeof pending.ours !== 'boolean' || !(pending.commitOid === null || isSha(pending.commitOid)) ||
89
+ typeof pending.createdAt !== 'string') throw new GitHubReviewError('INVALID_RESPONSE');
90
+ pendingReview = {ours: pending.ours, commitOid: pending.commitOid, createdAt: pending.createdAt};
91
+ }
92
+ const reviews = value.reviews.map(review => {
93
+ if (!isRecord(review) || typeof review.state !== 'string' || !(review.commitOid === null || isSha(review.commitOid)) ||
94
+ !optionalString(review.submittedAt) || typeof review.ours !== 'boolean') throw new GitHubReviewError('INVALID_RESPONSE');
95
+ return {state: review.state, commitOid: review.commitOid as string | null, submittedAt: review.submittedAt as string | null,
96
+ ours: review.ours};
97
+ });
98
+ const context: ReviewContext = {headRefOid: value.headRefOid, viewer: value.viewer, pendingReview, reviews};
99
+ if (withSubmit) {
100
+ const submit = value.submit;
101
+ if (!isRecord(submit) || typeof submit.state !== 'string' || !isCount(submit.threads, 50))
102
+ throw new GitHubReviewError('INVALID_RESPONSE');
103
+ context.submit = {state: submit.state, threads: submit.threads};
104
+ }
105
+ return context;
106
+ }
107
+
108
+ async function readBounded(response: Response, limit: number) {
109
+ if (!response.body) return '';
110
+ const reader = response.body.getReader();
111
+ const decoder = new TextDecoder('utf-8', {fatal: true});
112
+ let text = '', size = 0;
113
+ try {
114
+ for (;;) {
115
+ const {done, value} = await reader.read();
116
+ if (done) return text + decoder.decode();
117
+ size += value.byteLength;
118
+ if (size > limit) throw new GitHubReviewError('INVALID_RESPONSE');
119
+ text += decoder.decode(value, {stream: true});
120
+ }
121
+ } finally {
122
+ void reader.cancel().catch(() => {});
123
+ }
124
+ }
125
+
126
+ export interface GitHubReviewApiOptions {
127
+ /** The repository's GitHub HTTPS remote, as the connection was made for it. */
128
+ readonly remote: string;
129
+ /** The connection's session for `remote`; review calls ask for the review grant. */
130
+ readonly getSession: (remote: string, write: boolean, review?: boolean) => Promise<GitProxySession | null>;
131
+ readonly fetch?: typeof fetch;
132
+ }
133
+
134
+ export function createGitHubReviewApi({remote, getSession, fetch: send = (...args) => fetch(...args)}: GitHubReviewApiOptions) {
135
+ let path: string;
136
+ try {
137
+ path = '/v1/github' + remoteUrl(remote).slice('https://github.com'.length).replace(/\.git$/u, '');
138
+ } catch {
139
+ throw new GitHubReviewError('INVALID_REMOTE');
140
+ }
141
+ /** What the proxy answered about each route group: `false` after `404 GITHUB_API_UNAVAILABLE`. */
142
+ const available: {threads?: boolean; body?: boolean} = {};
143
+ let account: string | null = null;
144
+
145
+ async function session(review: boolean) {
146
+ let value: GitProxySession | null;
147
+ try {
148
+ value = await getSession(remote, false, review);
149
+ } catch {
150
+ throw new GitHubReviewError('SESSION_UNAVAILABLE');
151
+ }
152
+ if (!value) throw new GitHubReviewError('SESSION_REQUIRED');
153
+ if (!Number.isFinite(value.expiresAt) || value.expiresAt <= Date.now()) throw new GitHubReviewError('SESSION_EXPIRED');
154
+ try {
155
+ checkConnection(value.connection);
156
+ } catch {
157
+ throw new GitHubReviewError('INVALID_CONNECTION');
158
+ }
159
+ if (typeof value.bearer !== 'string' || !/^[A-Za-z0-9._~+\/-]+=*$/u.test(value.bearer))
160
+ throw new GitHubReviewError('INVALID_SESSION');
161
+ account = typeof value.account?.login === 'string' ? value.account.login : null;
162
+ return value;
163
+ }
164
+
165
+ async function call(route: string, {method = 'GET', body, submitId, review = false, mutation = false, limit = 256 * 1024,
166
+ group, signal}: {
167
+ method?: 'GET' | 'POST'; body?: unknown; submitId?: string; review?: boolean; mutation?: boolean; limit?: number;
168
+ group: 'threads' | 'body' | 'read'; signal?: AbortSignal;
169
+ }): Promise<unknown> {
170
+ if (signal?.aborted) throw new GitHubReviewError('ABORTED');
171
+ const current = await session(review);
172
+ const headers: Record<string, string> = {Authorization: 'Bearer ' + current.bearer, Accept: 'application/json'};
173
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
174
+ if (submitId !== undefined) {
175
+ if (!submitIdPattern.test(submitId)) throw new GitHubReviewError('BAD_REQUEST');
176
+ headers['Review-Submit-Id'] = submitId;
177
+ }
178
+ let response: Response;
179
+ try {
180
+ response = await send(current.connection.proxyOrigin + path + route, {
181
+ method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal,
182
+ credentials: 'omit', redirect: 'error', cache: 'no-store', referrerPolicy: 'no-referrer'
183
+ });
184
+ } catch {
185
+ if (!mutation && signal?.aborted) throw new GitHubReviewError('ABORTED');
186
+ throw new GitHubReviewError('NETWORK_ERROR', {unknown: mutation});
187
+ }
188
+ const json = response.headers.get('content-type')?.split(';')[0].trim() === 'application/json';
189
+ if (response.status !== 200) {
190
+ const allowed: readonly ProxyCode[] | undefined = proxyCodes[response.status as keyof typeof proxyCodes];
191
+ let code: GitHubReviewErrorCode = allowed?.[0] ?? 'INVALID_RESPONSE';
192
+ let requestId: string | undefined, retryAfter: number | undefined;
193
+ try {
194
+ const parsed: unknown = json ? JSON.parse(await readBounded(response, 4096)) : null;
195
+ if (isRecord(parsed)) {
196
+ if (allowed?.includes(parsed.code as ProxyCode)) code = parsed.code as ProxyCode;
197
+ if (typeof parsed.requestId === 'string' && requestIdPattern.test(parsed.requestId)) requestId = parsed.requestId;
198
+ if (Number.isSafeInteger(parsed.retryAfter) && Number(parsed.retryAfter) >= 0) retryAfter = Number(parsed.retryAfter);
199
+ }
200
+ } catch {
201
+ // An untrusted error body never becomes part of a client error.
202
+ }
203
+ if (code === 'GITHUB_API_UNAVAILABLE' && group !== 'read') available[group] = false;
204
+ if (code === 'GITHUB_API_UNAVAILABLE' && group === 'read') available.threads = false;
205
+ const unknown = mutation && (unknownCodes.includes(code) || !allowed);
206
+ throw new GitHubReviewError(unknown && !allowed ? 'UPSTREAM_FAILURE' : code,
207
+ {status: response.status, requestId, retryAfter, unknown});
208
+ }
209
+ if (group !== 'read') available[group] = true;
210
+ try {
211
+ if (!json) throw new Error();
212
+ return JSON.parse(await readBounded(response, limit));
213
+ } catch {
214
+ throw new GitHubReviewError('INVALID_RESPONSE', {unknown: mutation});
215
+ }
216
+ }
217
+
218
+ const checkNumber = (number: number) => {
219
+ if (!Number.isSafeInteger(number) || number < 1 || number > 2147483647) throw new GitHubReviewError('BAD_REQUEST');
220
+ };
221
+ const graphql = (operation: string, number: number, variables: Record<string, unknown>, submitId: string | undefined,
222
+ signal: AbortSignal | undefined, mutation = true) => {
223
+ checkNumber(number);
224
+ return call('/graphql/' + operation, {method: 'POST', body: {number, ...variables}, submitId, review: mutation, mutation,
225
+ group: mutation ? 'threads' : 'read', signal});
226
+ };
227
+ const shape = <T>(check: boolean, value: T, mutation = true): T => {
228
+ if (!check) throw new GitHubReviewError('INVALID_RESPONSE', {unknown: mutation});
229
+ return value;
230
+ };
231
+
232
+ return {
233
+ available,
234
+ /** The GitHub login of the last session this client used. */
235
+ account: () => account,
236
+ /** Reads the viewer's pending and recent reviews; with `submitId`, resolves that submission's unknown outcome. */
237
+ async context(number: number, submitId?: string, signal?: AbortSignal) {
238
+ const value = await graphql('pullRequestReviewContext', number, {}, submitId, signal, false);
239
+ const context = readContext(value, submitId !== undefined);
240
+ available.threads = true;
241
+ return context;
242
+ },
243
+ /** Step 1: a pending review on `commitOid`. */
244
+ async start(number: number, submitId: string, commitOid: string, signal?: AbortSignal) {
245
+ if (!isSha(commitOid)) throw new GitHubReviewError('BAD_REQUEST');
246
+ const value = await graphql('addPullRequestReview', number, {commitOid}, submitId, signal);
247
+ shape(isRecord(value) && value.state === 'PENDING' && value.commitOid === commitOid, value);
248
+ },
249
+ /** Step 2: one file-level thread on a project.source.json; resolves the number of threads the proxy has accepted. */
250
+ async thread(number: number, submitId: string, threadIndex: number, threadPath: string, body: string, signal?: AbortSignal) {
251
+ if (!isCount(threadIndex, 49) || !isReviewThreadPath(threadPath) || !body || body.length > 65536)
252
+ throw new GitHubReviewError('BAD_REQUEST');
253
+ const value = await graphql('addPullRequestReviewThread', number, {threadIndex, path: threadPath, body}, submitId, signal);
254
+ return shape(isRecord(value) && value.threads === threadIndex + 1, threadIndex + 1);
255
+ },
256
+ /** Step 3: publishes the pending review. */
257
+ async submit(number: number, submitId: string, threadCount: number, event: ReviewEvent, body: string, signal?: AbortSignal) {
258
+ if (!isCount(threadCount, 50) || !reviewEvents.includes(event) || body.length > 65536)
259
+ throw new GitHubReviewError('BAD_REQUEST');
260
+ const value = await graphql('submitPullRequestReview', number, {threadCount, event, body}, submitId, signal);
261
+ const review = (isRecord(value) ? value : {}) as Partial<Record<keyof SubmittedReview, unknown>>;
262
+ return shape(typeof review.state === 'string' && review.state !== 'PENDING' && isSha(review.commitOid) &&
263
+ typeof review.submittedAt === 'string' && typeof review.url === 'string', review as SubmittedReview);
264
+ },
265
+ /** Removes the pending review of the submission. */
266
+ async discard(number: number, submitId: string, signal?: AbortSignal) {
267
+ const value = await graphql('deletePullRequestReview', number, {}, submitId, signal);
268
+ shape(isRecord(value) && value.deleted === true, value);
269
+ },
270
+ /** The review-body route: one page of the pull request's reviews, oldest first. */
271
+ async reviews(number: number, page: number, signal?: AbortSignal): Promise<RestReview[]> {
272
+ checkNumber(number);
273
+ if (!Number.isSafeInteger(page) || page < 1 || page > 10) throw new GitHubReviewError('BAD_REQUEST');
274
+ const value = await call(`/pulls/${number}/reviews?page=${page}`, {group: 'body', limit: 4 * 1024 * 1024, signal});
275
+ if (!Array.isArray(value)) throw new GitHubReviewError('INVALID_RESPONSE');
276
+ return value.map(review => {
277
+ if (!isRecord(review) || !Number.isSafeInteger(review.id) || typeof review.state !== 'string' ||
278
+ !(review.commit_id === null || isSha(review.commit_id)) || !optionalString(review.body))
279
+ throw new GitHubReviewError('INVALID_RESPONSE');
280
+ const login = isRecord(review.user) && typeof review.user.login === 'string' ? review.user.login : null;
281
+ return {id: Number(review.id), login, commitId: review.commit_id as string | null, state: review.state,
282
+ body: typeof review.body === 'string' ? review.body : ''};
283
+ });
284
+ },
285
+ /** The review-body route: creates and publishes a review with a body only. */
286
+ async create(number: number, submitId: string, commitOid: string, event: ReviewEvent, body: string, signal?: AbortSignal) {
287
+ checkNumber(number);
288
+ if (!isSha(commitOid) || !reviewEvents.includes(event) || body.length > 65536 || (!body && event !== 'APPROVE'))
289
+ throw new GitHubReviewError('BAD_REQUEST');
290
+ const value = await call(`/pulls/${number}/reviews`, {method: 'POST', body: {commit_id: commitOid, event, body},
291
+ submitId, review: true, mutation: true, group: 'body', limit: 1024 * 1024, signal});
292
+ return shape(isRecord(value) && typeof value.state === 'string' && typeof value.html_url === 'string',
293
+ {url: (value as {html_url: string}).html_url});
294
+ }
295
+ };
296
+ }
297
+
298
+ export type GitHubReviewApi = ReturnType<typeof createGitHubReviewApi>;