@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 0.7.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.
- package/LICENSE +373 -0
- package/README.ja.md +105 -0
- package/README.md +35 -2
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/dist/THIRD_PARTY_NOTICES.md +1186 -0
- package/dist/block-diff-location.d.ts +22 -0
- package/dist/block-diff-preview.d.ts +17 -0
- package/dist/browser.d.ts +4 -0
- package/dist/connection-settings.d.ts +35 -0
- package/dist/file-diff-view.d.ts +16 -0
- package/dist/filesystem.d.ts +89 -0
- package/dist/github-pull-requests.d.ts +38 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +3556 -0
- package/dist/libesm-OWRWzmu_.js +739 -0
- package/dist/messages.d.ts +112 -0
- package/dist/mutation-fs.d.ts +58 -0
- package/dist/objectSpread2-Dkd68nJH.js +101 -0
- package/dist/project-source-diff.d.ts +22 -0
- package/dist/project-source-diff.js +169 -0
- package/dist/project-source-patch.d.ts +39 -0
- package/dist/project-source-review-model.d.ts +20 -0
- package/dist/project-source-review.d.ts +34 -0
- package/dist/pull-request-panel.d.ts +8 -0
- package/dist/remote-operations.d.ts +5 -0
- package/dist/repository-DplMQiuo.js +17212 -0
- package/dist/repository.d.ts +61 -0
- package/dist/repository.js +1 -0
- package/dist/scratch-block-diff-renderer-CLYoOIB2.js +1464 -0
- package/dist/scratch-block-diff-renderer.d.ts +65 -0
- package/dist/smart-http-BmHEkE7K.js +303 -0
- package/dist/smart-http.d.ts +60 -0
- package/dist/smart-http.js +1 -0
- package/dist/style.css +109 -0
- package/package.json +60 -4
- package/src/block-diff-location.ts +63 -0
- package/src/block-diff-preview.tsx +36 -0
- package/src/browser.ts +5 -0
- package/src/connection-settings.tsx +103 -0
- package/src/file-diff-view.tsx +43 -0
- package/src/filesystem.ts +125 -0
- package/src/github-pull-requests.ts +162 -0
- package/src/index.tsx +349 -0
- package/src/messages.ts +64 -0
- package/src/mutation-fs.ts +204 -0
- package/src/project-source-diff.css +79 -0
- package/src/project-source-diff.tsx +132 -0
- package/src/project-source-patch.ts +126 -0
- package/src/project-source-review-model.ts +113 -0
- package/src/project-source-review.tsx +217 -0
- package/src/pull-request-panel.tsx +134 -0
- package/src/remote-operations.ts +90 -0
- package/src/repository.ts +308 -0
- package/src/scratch-block-diff-renderer.ts +213 -0
- package/src/smart-http.ts +190 -0
- package/src/style.css +29 -0
- package/src/styles.d.ts +1 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +26 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React, {useEffect, useRef, useState} from 'react';
|
|
2
|
+
import type {BlockPart, BlockSide} from './project-source-patch.js';
|
|
3
|
+
import type {ProjectBlockRenderer, ScriptBlockMark} from './scratch-block-diff-renderer.js';
|
|
4
|
+
|
|
5
|
+
export type LocationStatus = 'locating' | 'located' | 'missingLocation';
|
|
6
|
+
type PreviewLabels = Record<'absent' | 'loading' | 'unavailable', string> & Partial<Record<LocationStatus | 'locate', string>>;
|
|
7
|
+
|
|
8
|
+
export function BlockPreview({side, title, parts, marks, renderBlock, withContext, labels, onLocate, locationStatus}: {
|
|
9
|
+
side?: BlockSide; title: string; parts?: readonly BlockPart[]; marks?: readonly ScriptBlockMark[]; renderBlock: ProjectBlockRenderer;
|
|
10
|
+
withContext: boolean; labels: PreviewLabels; onLocate?: () => void; locationStatus?: LocationStatus;
|
|
11
|
+
}) {
|
|
12
|
+
const host = useRef<HTMLDivElement>(null);
|
|
13
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>('loading');
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!side || !host.current) return;
|
|
16
|
+
const container = host.current;
|
|
17
|
+
const abort = new AbortController();
|
|
18
|
+
let dispose: (() => void) | undefined;
|
|
19
|
+
setStatus('loading');
|
|
20
|
+
void Promise.resolve().then(() => renderBlock({...side, container, withContext, parts, marks, signal: abort.signal})).then(cleanup => {
|
|
21
|
+
if (abort.signal.aborted) cleanup?.();
|
|
22
|
+
else {dispose = cleanup; setStatus('ready');}
|
|
23
|
+
}, () => {if (!abort.signal.aborted) setStatus('failed');});
|
|
24
|
+
return () => {abort.abort(); dispose?.();};
|
|
25
|
+
}, [side, renderBlock, withContext, parts, marks]);
|
|
26
|
+
return <div className="twgit-block-diff-side">
|
|
27
|
+
<h4>{title}</h4>
|
|
28
|
+
{!side && <p>{labels.absent}</p>}
|
|
29
|
+
{side && status === 'loading' && <p role="status">{labels.loading}</p>}
|
|
30
|
+
{side && status === 'failed' && <p role="status">{labels.unavailable}</p>}
|
|
31
|
+
<div ref={host} className="twgit-block-diff-preview"/>
|
|
32
|
+
{side && onLocate && <button type="button" aria-label={`${title} · ${labels.locate}`}
|
|
33
|
+
onClick={onLocate}>{labels.locate}</button>}
|
|
34
|
+
{locationStatus && <p role="status">{labels[locationStatus]}</p>}
|
|
35
|
+
</div>;
|
|
36
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, {useEffect, useRef, useState} from 'react';
|
|
2
|
+
import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
|
|
3
|
+
|
|
4
|
+
type Mode = 'desktop' | 'cloudflare';
|
|
5
|
+
type Status = 'disconnected' | 'connecting' | 'connected' | 'reauth-required';
|
|
6
|
+
export interface GitConnectionSettings {
|
|
7
|
+
modes: readonly Mode[];
|
|
8
|
+
getView(): {
|
|
9
|
+
mode: Mode | null;
|
|
10
|
+
state: {status: Status; account?: {login: string}; expiresAt?: number};
|
|
11
|
+
proxyOrigin?: string;
|
|
12
|
+
write?: boolean;
|
|
13
|
+
authorization?: {url: string; userCode?: string};
|
|
14
|
+
};
|
|
15
|
+
select(mode: Mode): Promise<void>;
|
|
16
|
+
connect(remote: string, write?: boolean): Promise<void>;
|
|
17
|
+
refresh(): Promise<void>;
|
|
18
|
+
disconnect(): Promise<void>;
|
|
19
|
+
getSession?: GetRemoteSession;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const en = {
|
|
23
|
+
title: 'GitHub connection', mode: 'Connection mode', select: 'Choose a connection',
|
|
24
|
+
desktop: 'Desktop local', cloudflare: 'Cloudflare', proxy: 'Proxy', account: 'Account',
|
|
25
|
+
'disconnected': 'Disconnected', 'connecting': 'Waiting for GitHub', 'connected': 'Connected',
|
|
26
|
+
'reauth-required': 'Sign in again', connect: 'Connect to GitHub', open: 'Open GitHub authorization',
|
|
27
|
+
check: 'Check connection', disconnect: 'Disconnect', noRemote: 'Choose a repository with a GitHub HTTPS origin.',
|
|
28
|
+
unavailable: 'No GitHub connection is configured.', changed: 'Switching disconnects the current session.',
|
|
29
|
+
error: 'GitHub connection failed. Retry using the selected connection.',
|
|
30
|
+
invalid: 'The selected repository needs a GitHub HTTPS origin.',
|
|
31
|
+
allowPush: 'Authorize push', fetch: 'Fetch', pull: 'Pull (fast-forward only)', push: 'Push'
|
|
32
|
+
};
|
|
33
|
+
const ja: typeof en = {
|
|
34
|
+
title: 'GitHub 接続', mode: '接続方式', select: '接続方式を選択',
|
|
35
|
+
desktop: 'Desktop ローカル', cloudflare: 'Cloudflare', proxy: 'プロキシ', account: 'アカウント',
|
|
36
|
+
'disconnected': '未接続', 'connecting': 'GitHub の認証待ち', 'connected': '接続済み',
|
|
37
|
+
'reauth-required': '再認証が必要', connect: 'GitHub に接続', open: 'GitHub の認証を開く',
|
|
38
|
+
check: '接続を確認', disconnect: '切断', noRemote: 'GitHub の HTTPS リモート origin があるリポジトリを選択してください。',
|
|
39
|
+
unavailable: 'GitHub 接続先が設定されていません。', changed: '方式を切り替えると現在のセッションを切断します。',
|
|
40
|
+
error: 'GitHub 接続に失敗しました。選択中の方式で再試行してください。',
|
|
41
|
+
invalid: '選択したリポジトリには GitHub の HTTPS リモート origin が必要です。',
|
|
42
|
+
allowPush: 'push を許可する', fetch: 'Fetch', pull: 'Pull(fast-forward のみ)', push: 'Push'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function ConnectionSettings({settings, locale, readOrigin, runOperation}: {
|
|
46
|
+
settings: GitConnectionSettings; locale: string; readOrigin?: () => Promise<string | undefined>;
|
|
47
|
+
runOperation?: (operation: RemoteOperation) => Promise<void>;
|
|
48
|
+
}) {
|
|
49
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
50
|
+
const [, update] = useState(0);
|
|
51
|
+
const [busy, setBusy] = useState(false);
|
|
52
|
+
const [allowPush, setAllowPush] = useState(false);
|
|
53
|
+
const [error, setError] = useState<'error' | 'invalid' | null>(null);
|
|
54
|
+
const active = useRef(true);
|
|
55
|
+
useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
|
|
56
|
+
const run = async (action: () => Promise<void>) => {
|
|
57
|
+
if (busy) return;
|
|
58
|
+
setBusy(true);
|
|
59
|
+
setError(null);
|
|
60
|
+
try {await action();} catch (failure) {
|
|
61
|
+
if (active.current) setError((failure as Error).message === 'INVALID_REMOTE' ? 'invalid' : 'error');
|
|
62
|
+
} finally {
|
|
63
|
+
if (active.current) {setBusy(false); update(value => value + 1);}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const {mode, state, proxyOrigin, authorization, write} = settings.getView();
|
|
67
|
+
const connect = () => run(async () => {
|
|
68
|
+
const remote = await readOrigin?.();
|
|
69
|
+
if (!remote) throw new Error('INVALID_REMOTE');
|
|
70
|
+
await settings.connect(remote, allowPush);
|
|
71
|
+
});
|
|
72
|
+
return <details className="twgit-connection">
|
|
73
|
+
<summary>{labels.title}</summary>
|
|
74
|
+
{!settings.modes.length ? <p>{labels.unavailable}</p> : <>
|
|
75
|
+
<label>{labels.mode} <select value={mode ?? ''} disabled={busy}
|
|
76
|
+
onChange={event => {const choice = event.currentTarget.value as Mode; void run(() => settings.select(choice));}}>
|
|
77
|
+
<option value="" disabled>{labels.select}</option>
|
|
78
|
+
{settings.modes.map(choice => <option key={choice} value={choice}>{labels[choice]}</option>)}
|
|
79
|
+
</select></label>
|
|
80
|
+
<p>{labels.changed}</p>
|
|
81
|
+
<p data-git-connection-state="" role="status">{labels[state.status]}</p>
|
|
82
|
+
{proxyOrigin && <p>{labels.proxy} · <code>{proxyOrigin}</code></p>}
|
|
83
|
+
{state.account && <p>{labels.account} · {state.account.login}</p>}
|
|
84
|
+
{!readOrigin && <p>{labels.noRemote}</p>}
|
|
85
|
+
{mode && <>
|
|
86
|
+
{runOperation && <label><input type="checkbox" checked={allowPush}
|
|
87
|
+
disabled={busy || state.status === 'connecting' || state.status === 'connected'}
|
|
88
|
+
onChange={event => setAllowPush(event.currentTarget.checked)}/> {labels.allowPush}</label>}
|
|
89
|
+
<button disabled={busy || !readOrigin || state.status === 'connecting' || state.status === 'connected'}
|
|
90
|
+
onClick={connect}>{labels.connect}</button>
|
|
91
|
+
{authorization && <p>{authorization.userCode && <code>{authorization.userCode} </code>}
|
|
92
|
+
<a href={authorization.url} target="_blank" rel="noopener noreferrer">{labels.open}</a></p>}
|
|
93
|
+
{state.status !== 'disconnected' && <button disabled={busy}
|
|
94
|
+
onClick={() => void run(settings.refresh)}>{labels.check}</button>}
|
|
95
|
+
<button disabled={busy} onClick={() => void run(settings.disconnect)}>{labels.disconnect}</button>
|
|
96
|
+
{runOperation && state.status === 'connected' && (['fetch', 'pull', 'push'] as const).map(operation =>
|
|
97
|
+
<button key={operation} disabled={busy || !readOrigin || (operation === 'push' && !write)}
|
|
98
|
+
onClick={() => void run(() => runOperation(operation))}>{labels[operation]}</button>)}
|
|
99
|
+
</>}
|
|
100
|
+
{error && <p className="twgit-error" role="alert">{labels[error]}</p>}
|
|
101
|
+
</>}
|
|
102
|
+
</details>;
|
|
103
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React, {useState} from 'react';
|
|
2
|
+
import {Diff, Hunk, parseDiff} from 'react-diff-view';
|
|
3
|
+
import {ProjectSourceComparison} from './project-source-review.js';
|
|
4
|
+
import type {FileChange, FileDiff} from './repository.js';
|
|
5
|
+
import type {labelsFor} from './messages.js';
|
|
6
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
7
|
+
|
|
8
|
+
const en = {view: 'Show changes as', blocks: 'Blocks', text: 'Text'};
|
|
9
|
+
const ja: typeof en = {view: '変更の表示', blocks: 'ブロック', text: 'テキスト'};
|
|
10
|
+
|
|
11
|
+
/** Project sources are the files the panel opens as projects. */
|
|
12
|
+
export const isProjectSource = (path: string) => path.split('/').pop() === 'project.source.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shows one changed file. Project sources are compared as blocks by default, with their text diff one switch away.
|
|
16
|
+
*/
|
|
17
|
+
export function FileDiffView({change, diff, labels, locale, renderBlock}: {
|
|
18
|
+
change: FileChange; diff: FileDiff | null; labels: ReturnType<typeof labelsFor>; locale: string;
|
|
19
|
+
renderBlock?: ProjectBlockRenderer;
|
|
20
|
+
}) {
|
|
21
|
+
const [view, setView] = useState<'blocks' | 'text'>('blocks');
|
|
22
|
+
const names = locale.startsWith('ja') ? ja : en;
|
|
23
|
+
const parsed = diff?.patch ? parseDiff(diff.patch)[0] : null;
|
|
24
|
+
const text = <>
|
|
25
|
+
<p>{labels.mode} · {change.oldMode?.toString(8) || '—'} → {change.newMode?.toString(8) || '—'}</p>
|
|
26
|
+
{diff && <p>{diff.beforeSize} → {diff.afterSize} {labels.bytes}</p>}
|
|
27
|
+
{diff?.binary && <p>{labels.binary}</p>}
|
|
28
|
+
{diff?.tooLarge && <p>{labels.large}</p>}
|
|
29
|
+
{parsed && <Diff viewType="unified" diffType={parsed.type} hunks={parsed.hunks}>
|
|
30
|
+
{hunks => hunks.map(hunk => <Hunk key={hunk.content} hunk={hunk}/>)}</Diff>}
|
|
31
|
+
{diff && !diff.binary && !diff.tooLarge && !parsed?.hunks.length && <p>{labels.unchanged}</p>}
|
|
32
|
+
</>;
|
|
33
|
+
const sources = renderBlock && diff?.text && isProjectSource(change.path) ? diff.text : null;
|
|
34
|
+
return <>
|
|
35
|
+
<h3>{change.path}</h3>
|
|
36
|
+
{sources && <div className="twgit-view-switch" role="group" aria-label={names.view}>
|
|
37
|
+
{(['blocks', 'text'] as const).map(mode => <button key={mode} type="button" aria-pressed={view === mode}
|
|
38
|
+
data-git-view={mode} onClick={() => setView(mode)}>{names[mode]}</button>)}
|
|
39
|
+
</div>}
|
|
40
|
+
{sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text}
|
|
41
|
+
before={change.before === null ? null : sources.before} after={change.after === null ? null : sources.after}/> : text}
|
|
42
|
+
</>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export class GitPanelError extends Error {
|
|
2
|
+
constructor(public code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' |
|
|
3
|
+
'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed', message: string = code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'GitPanelError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const fileError = (code: string, path: string) => Object.assign(new Error(`${code}: ${path}`), {code});
|
|
10
|
+
export const errorCode = (error: unknown) => (error as {code?: string})?.code;
|
|
11
|
+
|
|
12
|
+
export function pathParts(path: string): string[] {
|
|
13
|
+
const parts = path.split('/').filter(part => part && part !== '.');
|
|
14
|
+
if (parts.some(part => part === '..' || part.includes('\0'))) throw fileError('EACCES', path);
|
|
15
|
+
return parts;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fileStat(file: {size: number; lastModified: number} | null) {
|
|
19
|
+
const time = file?.lastModified ?? 0;
|
|
20
|
+
return {
|
|
21
|
+
isFile: () => file !== null, isDirectory: () => file === null, isSymbolicLink: () => false,
|
|
22
|
+
size: file?.size ?? 0, mode: file ? 0o100644 : 0o40755,
|
|
23
|
+
mtimeMs: time, ctimeMs: time, atimeMs: time, birthtimeMs: time,
|
|
24
|
+
mtime: new Date(time), ctime: new Date(time), atime: new Date(time), birthtime: new Date(time),
|
|
25
|
+
dev: 0, ino: 0, uid: 0, gid: 0
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Adapt granted handles without exposing any filesystem mutation. */
|
|
30
|
+
export function createReadOnlyFs(root: FileSystemDirectoryHandle) {
|
|
31
|
+
const directory = async (parts: string[]) => {
|
|
32
|
+
let handle = root;
|
|
33
|
+
for (const name of parts) handle = await handle.getDirectoryHandle(name);
|
|
34
|
+
return handle;
|
|
35
|
+
};
|
|
36
|
+
const handle = async (path: string): Promise<FileSystemDirectoryHandle | FileSystemFileHandle> => {
|
|
37
|
+
const parts = pathParts(path);
|
|
38
|
+
if (!parts.length) return root;
|
|
39
|
+
const name = parts.pop()!;
|
|
40
|
+
try {
|
|
41
|
+
const parent = await directory(parts);
|
|
42
|
+
try {
|
|
43
|
+
return await parent.getFileHandle(name);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as DOMException).name !== 'TypeMismatchError') throw error;
|
|
46
|
+
return await parent.getDirectoryHandle(name);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as DOMException).name === 'NotFoundError') throw fileError('ENOENT', path);
|
|
50
|
+
if ((error as DOMException).name === 'TypeMismatchError') throw fileError('ENOTDIR', path);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const reads = new Map<string, {size: number; modified: number}>();
|
|
55
|
+
const listings = new Map<string, string[]>();
|
|
56
|
+
const observe = (path: string, file: File) => {
|
|
57
|
+
const previous = reads.get(path);
|
|
58
|
+
if (previous && (previous.size !== file.size || previous.modified !== file.lastModified)) {
|
|
59
|
+
throw new GitPanelError('changed');
|
|
60
|
+
}
|
|
61
|
+
reads.set(path, {size: file.size, modified: file.lastModified});
|
|
62
|
+
};
|
|
63
|
+
const readFile = async (path: string, options?: string | {encoding?: string}) => {
|
|
64
|
+
const item = await handle(path);
|
|
65
|
+
if (item.kind !== 'file') throw fileError('EISDIR', path);
|
|
66
|
+
const file = await item.getFile();
|
|
67
|
+
observe(path, file);
|
|
68
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
69
|
+
const encoding = typeof options === 'string' ? options : options?.encoding;
|
|
70
|
+
return encoding ? new TextDecoder(encoding).decode(bytes) : bytes;
|
|
71
|
+
};
|
|
72
|
+
const stat = async (path: string) => {
|
|
73
|
+
const item = await handle(path);
|
|
74
|
+
const file = item.kind === 'file' ? await item.getFile() : null;
|
|
75
|
+
if (file) observe(path, file);
|
|
76
|
+
return fileStat(file);
|
|
77
|
+
};
|
|
78
|
+
const rejectWrite = async (path: string): Promise<never> => {throw fileError('EROFS', path);};
|
|
79
|
+
return {
|
|
80
|
+
clearReads() {reads.clear(); listings.clear();},
|
|
81
|
+
async checkReads() {
|
|
82
|
+
for (const [path, expected] of reads) {
|
|
83
|
+
const item = await handle(path);
|
|
84
|
+
if (item.kind !== 'file') throw new GitPanelError('changed');
|
|
85
|
+
const file = await item.getFile();
|
|
86
|
+
if (file.size !== expected.size || file.lastModified !== expected.modified) throw new GitPanelError('changed');
|
|
87
|
+
}
|
|
88
|
+
for (const [path, expected] of listings) {
|
|
89
|
+
const item = await directory(pathParts(path));
|
|
90
|
+
const names: string[] = [];
|
|
91
|
+
for await (const name of (item as FileSystemDirectoryHandle & {keys(): AsyncIterable<string>}).keys()) names.push(name);
|
|
92
|
+
if (JSON.stringify(names.sort()) !== JSON.stringify(expected)) throw new GitPanelError('changed');
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
promises: {
|
|
96
|
+
readFile,
|
|
97
|
+
stat,
|
|
98
|
+
lstat: stat,
|
|
99
|
+
async readdir(path: string) {
|
|
100
|
+
const item = await handle(path);
|
|
101
|
+
if (item.kind !== 'directory') throw fileError('ENOTDIR', path);
|
|
102
|
+
const names: string[] = [];
|
|
103
|
+
for await (const name of (item as FileSystemDirectoryHandle & {keys(): AsyncIterable<string>}).keys()) {
|
|
104
|
+
names.push(name);
|
|
105
|
+
}
|
|
106
|
+
listings.set(path, names.sort());
|
|
107
|
+
return names;
|
|
108
|
+
},
|
|
109
|
+
writeFile: rejectWrite, unlink: rejectWrite, mkdir: rejectWrite, rmdir: rejectWrite,
|
|
110
|
+
symlink: rejectWrite, chmod: rejectWrite,
|
|
111
|
+
async readlink(): Promise<never> {throw new GitPanelError('unsupported');}
|
|
112
|
+
}};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function readOptional(fs: ReturnType<typeof createReadOnlyFs>, path: string): Promise<Uint8Array | null> {
|
|
116
|
+
try {
|
|
117
|
+
return await fs.promises.readFile(path) as Uint8Array;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (errorCode(error) === 'ENOENT') return null;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const sameBytes = (left: Uint8Array | null, right: Uint8Array | null) =>
|
|
125
|
+
left === right || Boolean(left && right && left.length === right.length && left.every((byte, i) => byte === right[i]));
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {createProjectSourceDiff, isRecord} from './project-source-patch.js';
|
|
2
|
+
import type {ProjectSourceReviewFile} from './project-source-review.js';
|
|
3
|
+
|
|
4
|
+
type ErrorCode = 'unavailable' | 'rateLimit' | 'failed' | 'changed' | 'tooManyFiles';
|
|
5
|
+
export class PullRequestError extends Error {
|
|
6
|
+
constructor(readonly code: ErrorCode) {super(code);}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const repositoryName = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
10
|
+
const validRepository = (value: unknown): value is string => typeof value === 'string' &&
|
|
11
|
+
repositoryName.test(value) && value.split('/').every(segment => segment !== '.' && segment !== '..');
|
|
12
|
+
const isSha = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{40}$/i.test(value);
|
|
13
|
+
|
|
14
|
+
export function gitHubRepositoryFromOrigin(origin: string | undefined) {
|
|
15
|
+
const name = origin?.trim().match(/^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^/?#]+\/[^/?#]+)\/?$/i)?.[1];
|
|
16
|
+
const repository = name?.replace(/\.git$/i, '');
|
|
17
|
+
return validRepository(repository) ? repository : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function repoPath(repository: string) {
|
|
21
|
+
return '/repos/' + repository.split('/').map(encodeURIComponent).join('/');
|
|
22
|
+
}
|
|
23
|
+
const invalidResponse = () => new PullRequestError('failed');
|
|
24
|
+
const emptyProject = '{"targets":[]}\n';
|
|
25
|
+
const sourceLimit = 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
async function readText(response: Response, limit: number) {
|
|
28
|
+
if (Number(response.headers.get('Content-Length')) > limit || !response.body) throw invalidResponse();
|
|
29
|
+
const reader = response.body.getReader();
|
|
30
|
+
const decoder = new TextDecoder('utf-8', {fatal: true});
|
|
31
|
+
const chunks: string[] = [];
|
|
32
|
+
let length = 0;
|
|
33
|
+
try {
|
|
34
|
+
for (;;) {
|
|
35
|
+
const {done, value} = await reader.read();
|
|
36
|
+
if (done) return chunks.join('') + decoder.decode();
|
|
37
|
+
length += value.byteLength;
|
|
38
|
+
if (length > limit) throw invalidResponse();
|
|
39
|
+
chunks.push(decoder.decode(value, {stream: true}));
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
void reader.cancel().catch(() => {});
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readSummary(value: unknown) {
|
|
48
|
+
if (!isRecord(value) || !Number.isSafeInteger(value.number) || Number(value.number) < 1 ||
|
|
49
|
+
typeof value.title !== 'string') throw invalidResponse();
|
|
50
|
+
return {number: Number(value.number), title: value.title};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readRevision(value: unknown) {
|
|
54
|
+
if (!isRecord(value) || !isSha(value.sha) || typeof value.ref !== 'string') throw invalidResponse();
|
|
55
|
+
const repository = isRecord(value.repo) ? value.repo.full_name : null;
|
|
56
|
+
if (repository !== null && !validRepository(repository)) throw invalidResponse();
|
|
57
|
+
return {sha: value.sha, ref: value.ref, repository};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readPullRequest(value: unknown) {
|
|
61
|
+
const summary = readSummary(value);
|
|
62
|
+
if (!isRecord(value) || (value.state !== 'open' && value.state !== 'closed') ||
|
|
63
|
+
!Number.isSafeInteger(value.changed_files) || Number(value.changed_files) < 0) throw invalidResponse();
|
|
64
|
+
const author = isRecord(value.user) && typeof value.user.login === 'string' ? value.user.login : undefined;
|
|
65
|
+
const description = typeof value.body_text === 'string' ? value.body_text : typeof value.body === 'string' ? value.body : '';
|
|
66
|
+
const state: 'open' | 'closed' | 'merged' | 'draft' = value.merged_at ? 'merged' : value.draft ? 'draft' : value.state;
|
|
67
|
+
return {...summary, author, description, base: readRevision(value.base), head: readRevision(value.head), state,
|
|
68
|
+
changedFiles: Number(value.changed_files)};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readFile(value: unknown) {
|
|
72
|
+
if (!isRecord(value) || typeof value.filename !== 'string' || typeof value.status !== 'string') throw invalidResponse();
|
|
73
|
+
return {path: value.filename, previous: typeof value.previous_filename === 'string' ? value.previous_filename : undefined,
|
|
74
|
+
status: value.status};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function contentPath(path: string) {
|
|
78
|
+
const parts = path.split('/');
|
|
79
|
+
if (parts.some(part => !part || part === '.' || part === '..')) throw invalidResponse();
|
|
80
|
+
return parts.map(encodeURIComponent).join('/');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createGitHubPullRequests(repository: string, send: typeof fetch = fetch) {
|
|
84
|
+
if (!validRepository(repository)) throw invalidResponse();
|
|
85
|
+
const api = 'https://api.github.com';
|
|
86
|
+
const repo = repoPath(repository);
|
|
87
|
+
const request = async (path: string, signal: AbortSignal, accept = 'application/vnd.github+json') => {
|
|
88
|
+
const response = await send(api + path, {headers: {Accept: accept}, credentials: 'omit', redirect: 'error', signal});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
if (response.status === 429 || (response.status === 403 && (response.headers.get('X-RateLimit-Remaining') === '0' ||
|
|
91
|
+
response.headers.has('Retry-After')))) throw new PullRequestError('rateLimit');
|
|
92
|
+
if (response.status === 404 || response.status === 403) throw new PullRequestError('unavailable');
|
|
93
|
+
throw invalidResponse();
|
|
94
|
+
}
|
|
95
|
+
return response;
|
|
96
|
+
};
|
|
97
|
+
const json = async (path: string, signal: AbortSignal, accept?: string): Promise<unknown> =>
|
|
98
|
+
JSON.parse(await readText(await request(path, signal, accept), 12 * sourceLimit));
|
|
99
|
+
const metadata = async (number: number, signal: AbortSignal) => readPullRequest(await json(
|
|
100
|
+
repo + '/pulls/' + number, signal, 'application/vnd.github.text+json'));
|
|
101
|
+
const source = async (name: string, path: string, revision: string, signal: AbortSignal) => readText(
|
|
102
|
+
await request(repoPath(name) + '/contents/' + contentPath(path) + '?ref=' + revision, signal, 'application/vnd.github.raw+json'), sourceLimit);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
async list(page: number, signal: AbortSignal) {
|
|
106
|
+
const result = await json(repo + '/pulls?state=all&sort=updated&per_page=20&page=' + page, signal);
|
|
107
|
+
if (!Array.isArray(result)) throw invalidResponse();
|
|
108
|
+
return {items: result.map(readSummary), more: result.length === 20};
|
|
109
|
+
},
|
|
110
|
+
async load(number: number, signal: AbortSignal) {
|
|
111
|
+
if (!Number.isSafeInteger(number) || number < 1) throw invalidResponse();
|
|
112
|
+
const pr = await metadata(number, signal);
|
|
113
|
+
if (pr.number !== number) throw invalidResponse();
|
|
114
|
+
if (pr.changedFiles > 3000) throw new PullRequestError('tooManyFiles');
|
|
115
|
+
const records: ReturnType<typeof readFile>[] = [];
|
|
116
|
+
for (let page = 1; page <= Math.ceil(pr.changedFiles / 100); page++) {
|
|
117
|
+
const result = await json(repo + '/pulls/' + number + '/files?per_page=100&page=' + page, signal);
|
|
118
|
+
if (!Array.isArray(result)) throw invalidResponse();
|
|
119
|
+
records.push(...result.map(readFile));
|
|
120
|
+
}
|
|
121
|
+
const current = await metadata(number, signal);
|
|
122
|
+
if (current.base.sha !== pr.base.sha || current.head.sha !== pr.head.sha ||
|
|
123
|
+
current.changedFiles !== pr.changedFiles || records.length !== pr.changedFiles) {
|
|
124
|
+
throw new PullRequestError('changed');
|
|
125
|
+
}
|
|
126
|
+
let base: string | undefined;
|
|
127
|
+
if (records.some(file => file.path.split('/').pop() === 'project.source.json' && file.status !== 'added' && file.status !== 'copied')) {
|
|
128
|
+
try {
|
|
129
|
+
const comparison = await json(repo + '/compare/' + pr.base.sha + '...' + pr.head.sha + '?per_page=1', signal);
|
|
130
|
+
const merge = isRecord(comparison) ? comparison.merge_base_commit : null;
|
|
131
|
+
if (isRecord(merge) && isSha(merge.sha)) base = merge.sha;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (signal.aborted) throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const files: ProjectSourceReviewFile[] = [];
|
|
138
|
+
for (const file of records) {
|
|
139
|
+
const status = file.status === 'removed' ? 'removed' : file.status === 'added' || file.status === 'copied' ? 'added' : 'updated';
|
|
140
|
+
const related = {path: file.path, status, previousPath: file.previous} as const;
|
|
141
|
+
if (file.path.split('/').pop() !== 'project.source.json') {
|
|
142
|
+
files.push(related);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
if (status !== 'added' && !base) throw invalidResponse();
|
|
147
|
+
const [before, after] = await Promise.all([
|
|
148
|
+
status === 'added' ? emptyProject : source(repository, file.previous ?? file.path, base!, signal),
|
|
149
|
+
status === 'removed' ? emptyProject : source(pr.head.repository ?? repository, file.path, pr.head.sha, signal)
|
|
150
|
+
]);
|
|
151
|
+
files.push({path: file.path, previousPath: file.previous, patch: createProjectSourceDiff(before, after)});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (signal.aborted) throw error;
|
|
154
|
+
files.push({...related, unavailable: true});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {...pr, files, url: 'https://github.com/' + repository + '/pull/' + number};
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type GitHubPullRequests = ReturnType<typeof createGitHubPullRequests>;
|