@tanishqkancharla/diffmap 0.1.3 → 0.1.4
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/README.md +13 -10
- package/package.json +2 -2
- package/src/cli.ts +94 -54
- package/src/contentPlugin.ts +5 -3
- package/src/errors.ts +1 -1
- package/src/gist/GistApp.tsx +31 -19
- package/src/gist/GistStatus.tsx +22 -8
- package/src/gist/main.tsx +2 -2
- package/src/gist/pin.ts +14 -0
- package/src/github/GitHubApp.tsx +235 -0
- package/src/github/fetchRepo.test.ts +17 -0
- package/src/github/fetchRepo.ts +300 -0
- package/src/github/patch.test.ts +30 -0
- package/src/github/patch.ts +40 -0
- package/src/github/pin.ts +14 -0
- package/src/github/route.test.ts +68 -0
- package/src/github/route.ts +175 -0
- package/src/host/HostApp.tsx +22 -0
- package/src/index.ts +1 -0
- package/src/mermaid.ts +29 -1
- package/src/parseViewer.test.ts +185 -0
- package/src/parseViewer.ts +27 -3
- package/src/serve.ts +9 -1
- package/src/share.ts +5 -1
- package/src/viewer/CallStackDiff.tsx +26 -6
- package/src/viewer/FileExcerpt.tsx +28 -1
- package/src/viewer/SourceDiffPanel.tsx +131 -54
- package/src/viewer/TableOfContents.tsx +1 -0
- package/src/viewer/ViewerApp.tsx +4 -1
- package/src/viewer/main.tsx +10 -3
- package/src/viewer/sourceHeader.test.ts +131 -0
- package/src/viewer/sourceHeader.ts +103 -0
- package/src/viewer/sourceNavigation.ts +46 -2
- package/src/viewer/viewerMode.ts +1 -1
- package/src/viewer/vite-env.d.ts +2 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Link, P } from "maui";
|
|
3
|
+
import { init } from "md4x/standalone";
|
|
4
|
+
import { parseViewerDocument } from "../parseViewer.ts";
|
|
5
|
+
import type { ViewerDocument } from "../parseViewer.ts";
|
|
6
|
+
import { ViewerApp } from "../viewer/ViewerApp.tsx";
|
|
7
|
+
import { GistLoading, GistStatus } from "../gist/GistStatus.tsx";
|
|
8
|
+
import { loadPinnedSpec, type PinnedSpec } from "./fetchRepo.ts";
|
|
9
|
+
import { GitHubPinContext } from "./pin.ts";
|
|
10
|
+
import {
|
|
11
|
+
githubBlobUrl,
|
|
12
|
+
githubCommitPath,
|
|
13
|
+
githubPullUrl,
|
|
14
|
+
parseGitHubPath,
|
|
15
|
+
type GitHubRoute,
|
|
16
|
+
} from "./route.ts";
|
|
17
|
+
|
|
18
|
+
let parserReady: Promise<void> | undefined;
|
|
19
|
+
|
|
20
|
+
function readyParser() {
|
|
21
|
+
parserReady ??= init();
|
|
22
|
+
return parserReady;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type GitHubView =
|
|
26
|
+
| { kind: "not-found" }
|
|
27
|
+
| { kind: "forbidden" }
|
|
28
|
+
| { kind: "error"; message: string }
|
|
29
|
+
| { kind: "parse-error"; htmlUrl: string; message: string }
|
|
30
|
+
| { kind: "ready"; spec: PinnedSpec; document: ViewerDocument };
|
|
31
|
+
|
|
32
|
+
export function GitHubApp() {
|
|
33
|
+
const [route, setRoute] = useState(readGitHubRoute);
|
|
34
|
+
const routeKey = gitHubRouteKey(route);
|
|
35
|
+
const [loaded, setLoaded] = useState<{ key: string; view: GitHubView }>();
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const onPop = () => setRoute(readGitHubRoute());
|
|
39
|
+
window.addEventListener("popstate", onPop);
|
|
40
|
+
return () => window.removeEventListener("popstate", onPop);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (route.kind === "github-invalid") return;
|
|
45
|
+
const key = gitHubRouteKey(route);
|
|
46
|
+
const abort = new AbortController();
|
|
47
|
+
// oxlint-disable-next-line typescript/no-floating-promises -- The effect owns this load; abort on cleanup.
|
|
48
|
+
void loadView(route, abort.signal).then(
|
|
49
|
+
(next) => {
|
|
50
|
+
if (!abort.signal.aborted) setLoaded({ key, view: next });
|
|
51
|
+
},
|
|
52
|
+
(cause: unknown) => {
|
|
53
|
+
if (abort.signal.aborted) return;
|
|
54
|
+
setLoaded({
|
|
55
|
+
key,
|
|
56
|
+
view: {
|
|
57
|
+
kind: "error",
|
|
58
|
+
message:
|
|
59
|
+
cause instanceof Error
|
|
60
|
+
? cause.message
|
|
61
|
+
: "Failed to load this spec.",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
return () => abort.abort();
|
|
67
|
+
}, [route]);
|
|
68
|
+
|
|
69
|
+
if (route.kind === "github-invalid") {
|
|
70
|
+
return (
|
|
71
|
+
<GistStatus title="Not a hosted spec">
|
|
72
|
+
<P>{route.message}</P>
|
|
73
|
+
<P>
|
|
74
|
+
Use{" "}
|
|
75
|
+
<code>
|
|
76
|
+
/<owner>/<repo>/pull/<n>/<path.md>
|
|
77
|
+
</code>{" "}
|
|
78
|
+
or{" "}
|
|
79
|
+
<code>
|
|
80
|
+
/<owner>/<repo>/commit/<sha>/<path.md>
|
|
81
|
+
</code>
|
|
82
|
+
.
|
|
83
|
+
</P>
|
|
84
|
+
</GistStatus>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (loaded === undefined || loaded.key !== routeKey) {
|
|
89
|
+
return <GistLoading title="Loading spec…" />;
|
|
90
|
+
}
|
|
91
|
+
const view = loaded.view;
|
|
92
|
+
if (view.kind === "not-found") {
|
|
93
|
+
return (
|
|
94
|
+
<GistStatus title="Spec not found">
|
|
95
|
+
<P>
|
|
96
|
+
GitHub has no public file at this pull or commit. Private repos are
|
|
97
|
+
out of scope.
|
|
98
|
+
</P>
|
|
99
|
+
</GistStatus>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (view.kind === "forbidden") {
|
|
103
|
+
return (
|
|
104
|
+
<GistStatus title="Failed to load">
|
|
105
|
+
<P>
|
|
106
|
+
GitHub refused this request. Unauthenticated API calls are limited to
|
|
107
|
+
60 per hour per IP. Wait and refresh, or open the file on GitHub.
|
|
108
|
+
</P>
|
|
109
|
+
</GistStatus>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (view.kind === "error") {
|
|
113
|
+
return (
|
|
114
|
+
<GistStatus title="Failed to load">
|
|
115
|
+
<P>{view.message}</P>
|
|
116
|
+
</GistStatus>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (view.kind === "parse-error") {
|
|
120
|
+
return (
|
|
121
|
+
<GistStatus
|
|
122
|
+
title="Could not parse this walkthrough"
|
|
123
|
+
gistUrl={view.htmlUrl}
|
|
124
|
+
linkLabel="Open file on GitHub"
|
|
125
|
+
>
|
|
126
|
+
<P>{view.message}</P>
|
|
127
|
+
</GistStatus>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const spec = view.spec;
|
|
132
|
+
return (
|
|
133
|
+
<GitHubPinContext.Provider value={spec}>
|
|
134
|
+
<ViewerApp
|
|
135
|
+
key={`${spec.owner}/${spec.repo}@${spec.sha}:${spec.path}`}
|
|
136
|
+
document={view.document}
|
|
137
|
+
mode="github"
|
|
138
|
+
headerActions={
|
|
139
|
+
<GitHubHeader
|
|
140
|
+
owner={spec.owner}
|
|
141
|
+
repo={spec.repo}
|
|
142
|
+
sha={spec.sha}
|
|
143
|
+
path={spec.path}
|
|
144
|
+
pullNumber={spec.pullNumber}
|
|
145
|
+
pullUrl={spec.pullUrl}
|
|
146
|
+
/>
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
</GitHubPinContext.Provider>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function loadView(
|
|
154
|
+
route: Exclude<GitHubRoute, { kind: "github-invalid" }>,
|
|
155
|
+
signal: AbortSignal,
|
|
156
|
+
): Promise<GitHubView> {
|
|
157
|
+
await readyParser();
|
|
158
|
+
const loaded = await loadPinnedSpec({
|
|
159
|
+
owner: route.owner,
|
|
160
|
+
repo: route.repo,
|
|
161
|
+
path: route.path,
|
|
162
|
+
sha: route.kind === "github-commit" ? route.sha : undefined,
|
|
163
|
+
pull: route.kind === "github-pull" ? route.pull : undefined,
|
|
164
|
+
signal,
|
|
165
|
+
});
|
|
166
|
+
if (loaded.status === "not-found") return { kind: "not-found" };
|
|
167
|
+
if (loaded.status === "forbidden") return { kind: "forbidden" };
|
|
168
|
+
if (loaded.status === "error") {
|
|
169
|
+
return { kind: "error", message: loaded.message };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const spec = loaded.spec;
|
|
173
|
+
if (route.kind === "github-pull") {
|
|
174
|
+
const pinned = githubCommitPath(spec.owner, spec.repo, spec.sha, spec.path);
|
|
175
|
+
if (window.location.pathname !== pinned) {
|
|
176
|
+
window.history.replaceState({}, "", `${pinned}${window.location.hash}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const parsed = parseViewerDocument(spec.content, spec.path);
|
|
181
|
+
if (parsed instanceof Error) {
|
|
182
|
+
return {
|
|
183
|
+
kind: "parse-error",
|
|
184
|
+
htmlUrl: spec.htmlUrl,
|
|
185
|
+
message: parsed.message,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const document =
|
|
189
|
+
parsed.sourceDiffs.length > 0
|
|
190
|
+
? parsed
|
|
191
|
+
: { ...parsed, sourceDiffs: spec.pullDiffs };
|
|
192
|
+
return { kind: "ready", spec, document };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readGitHubRoute(): GitHubRoute {
|
|
196
|
+
const parsed = parseGitHubPath(window.location.pathname);
|
|
197
|
+
if (parsed.kind === "home") {
|
|
198
|
+
return {
|
|
199
|
+
kind: "github-invalid",
|
|
200
|
+
message: "Not a GitHub-hosted spec URL.",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return parsed;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function gitHubRouteKey(route: GitHubRoute) {
|
|
207
|
+
if (route.kind === "github-invalid") return `invalid:${route.message}`;
|
|
208
|
+
if (route.kind === "github-pull") {
|
|
209
|
+
return `pull:${route.owner}/${route.repo}/${String(route.pull)}/${route.path}`;
|
|
210
|
+
}
|
|
211
|
+
return `commit:${route.owner}/${route.repo}/${route.sha}/${route.path}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function GitHubHeader(props: {
|
|
215
|
+
owner: string;
|
|
216
|
+
repo: string;
|
|
217
|
+
sha: string;
|
|
218
|
+
path: string;
|
|
219
|
+
pullNumber?: number;
|
|
220
|
+
pullUrl?: string;
|
|
221
|
+
}) {
|
|
222
|
+
const shortSha = props.sha.slice(0, 7);
|
|
223
|
+
const blobUrl = githubBlobUrl(props.owner, props.repo, props.sha, props.path);
|
|
224
|
+
const pullHref =
|
|
225
|
+
props.pullUrl ??
|
|
226
|
+
(props.pullNumber === undefined
|
|
227
|
+
? undefined
|
|
228
|
+
: githubPullUrl(props.owner, props.repo, props.pullNumber));
|
|
229
|
+
return (
|
|
230
|
+
<>
|
|
231
|
+
{pullHref !== undefined && <Link href={pullHref}>PR</Link>}
|
|
232
|
+
<Link href={blobUrl}>{`${props.owner}/${props.repo}@${shortSha}`}</Link>
|
|
233
|
+
</>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { findSymbolLineRange } from "./fetchRepo.ts";
|
|
4
|
+
|
|
5
|
+
test("findSymbolLineRange highlights a function declaration", () => {
|
|
6
|
+
const source = ["const x = 1;", "export function startServer() {", "}"].join(
|
|
7
|
+
"\n",
|
|
8
|
+
);
|
|
9
|
+
assert.deepEqual(findSymbolLineRange(source, "startServer"), {
|
|
10
|
+
start: 2,
|
|
11
|
+
end: 2,
|
|
12
|
+
});
|
|
13
|
+
assert.deepEqual(findSymbolLineRange(source, "Serve.startServer"), {
|
|
14
|
+
start: 2,
|
|
15
|
+
end: 2,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin model (locked): fetch the spec and every referenced file from one commit
|
|
3
|
+
* SHA. A PR number is only used to choose that SHA (head at load time). Never
|
|
4
|
+
* fetch `ref=main` / latest for spec markdown, [[path]] targets, excerpts, or
|
|
5
|
+
* hunks. Private repos are out of scope (GitHub 404).
|
|
6
|
+
*/
|
|
7
|
+
import type { CodeViewDiffItem } from "@pierre/diffs";
|
|
8
|
+
import { pullFilesToSourceDiffs, type GitHubPullFile } from "./patch.ts";
|
|
9
|
+
|
|
10
|
+
const GITHUB_API = "https://api.github.com";
|
|
11
|
+
const GITHUB_JSON = {
|
|
12
|
+
Accept: "application/vnd.github+json",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type PinnedSpec = {
|
|
16
|
+
owner: string;
|
|
17
|
+
repo: string;
|
|
18
|
+
sha: string;
|
|
19
|
+
path: string;
|
|
20
|
+
content: string;
|
|
21
|
+
htmlUrl: string;
|
|
22
|
+
pullNumber?: number;
|
|
23
|
+
pullUrl?: string;
|
|
24
|
+
pullDiffs: CodeViewDiffItem[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type GitHubSpecResult =
|
|
28
|
+
| { status: "ok"; spec: PinnedSpec }
|
|
29
|
+
| { status: "not-found" }
|
|
30
|
+
| { status: "forbidden" }
|
|
31
|
+
| { status: "error"; message: string };
|
|
32
|
+
|
|
33
|
+
type PullApi = {
|
|
34
|
+
html_url?: string;
|
|
35
|
+
head?: { sha?: string };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ContentsApi = {
|
|
39
|
+
type?: string;
|
|
40
|
+
encoding?: string;
|
|
41
|
+
content?: string;
|
|
42
|
+
download_url?: string | null;
|
|
43
|
+
size?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const blobCache = new Map<string, Promise<string | Error>>();
|
|
47
|
+
|
|
48
|
+
export async function loadPinnedSpec(input: {
|
|
49
|
+
owner: string;
|
|
50
|
+
repo: string;
|
|
51
|
+
path: string;
|
|
52
|
+
sha?: string;
|
|
53
|
+
pull?: number;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}): Promise<GitHubSpecResult> {
|
|
56
|
+
let sha = input.sha;
|
|
57
|
+
let pullNumber = input.pull;
|
|
58
|
+
let pullUrl: string | undefined;
|
|
59
|
+
if (sha === undefined) {
|
|
60
|
+
if (pullNumber === undefined) {
|
|
61
|
+
return { status: "error", message: "Need a pull number or commit SHA." };
|
|
62
|
+
}
|
|
63
|
+
const resolved = await resolvePullHead(
|
|
64
|
+
input.owner,
|
|
65
|
+
input.repo,
|
|
66
|
+
pullNumber,
|
|
67
|
+
input.signal,
|
|
68
|
+
);
|
|
69
|
+
if (resolved.status !== "ok") return resolved;
|
|
70
|
+
sha = resolved.sha;
|
|
71
|
+
pullUrl = resolved.htmlUrl;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const blob = await fetchPinnedBlob(
|
|
75
|
+
{ owner: input.owner, repo: input.repo, sha },
|
|
76
|
+
input.path,
|
|
77
|
+
input.signal,
|
|
78
|
+
);
|
|
79
|
+
if (blob instanceof Error) {
|
|
80
|
+
if (blob.message === "not-found") return { status: "not-found" };
|
|
81
|
+
if (blob.message === "forbidden") return { status: "forbidden" };
|
|
82
|
+
return { status: "error", message: blob.message };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let pullDiffs: CodeViewDiffItem[] = [];
|
|
86
|
+
if (pullNumber !== undefined) {
|
|
87
|
+
const files = await fetchPullFiles(
|
|
88
|
+
input.owner,
|
|
89
|
+
input.repo,
|
|
90
|
+
pullNumber,
|
|
91
|
+
input.signal,
|
|
92
|
+
);
|
|
93
|
+
if (files instanceof Error) {
|
|
94
|
+
if (files.message === "forbidden") return { status: "forbidden" };
|
|
95
|
+
if (files.message !== "not-found") {
|
|
96
|
+
return { status: "error", message: files.message };
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
pullDiffs = pullFilesToSourceDiffs(files);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
status: "ok",
|
|
105
|
+
spec: {
|
|
106
|
+
owner: input.owner,
|
|
107
|
+
repo: input.repo,
|
|
108
|
+
sha,
|
|
109
|
+
path: input.path,
|
|
110
|
+
content: blob,
|
|
111
|
+
htmlUrl: `https://github.com/${input.owner}/${input.repo}/blob/${sha}/${input.path}`,
|
|
112
|
+
pullNumber,
|
|
113
|
+
pullUrl,
|
|
114
|
+
pullDiffs,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function fetchPinnedBlob(
|
|
120
|
+
pin: { owner: string; repo: string; sha: string },
|
|
121
|
+
filePath: string,
|
|
122
|
+
signal?: AbortSignal,
|
|
123
|
+
): Promise<string | Error> {
|
|
124
|
+
const key = `${pin.owner}/${pin.repo}@${pin.sha}:${filePath}`;
|
|
125
|
+
const pending = blobCache.get(key);
|
|
126
|
+
if (pending !== undefined) return pending;
|
|
127
|
+
|
|
128
|
+
const request = readContents(pin, filePath, signal).catch(
|
|
129
|
+
(cause: unknown) => {
|
|
130
|
+
blobCache.delete(key);
|
|
131
|
+
if (cause instanceof Error && cause.name === "AbortError") throw cause;
|
|
132
|
+
return cause instanceof Error
|
|
133
|
+
? cause
|
|
134
|
+
: new Error("Failed to load this file from GitHub.");
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
blobCache.set(key, request);
|
|
138
|
+
return request;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function findSymbolLineRange(contents: string, symbol: string) {
|
|
142
|
+
const name = symbol.includes(".")
|
|
143
|
+
? symbol.slice(symbol.lastIndexOf(".") + 1)
|
|
144
|
+
: symbol;
|
|
145
|
+
const lines = contents.split(/\r?\n/);
|
|
146
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
147
|
+
const pattern = new RegExp(
|
|
148
|
+
`(?:function|class|const|let|var|type|interface|enum|#)\\s+${escaped}\\b|\\b${escaped}\\s*[=:(]`,
|
|
149
|
+
);
|
|
150
|
+
for (let index = 0; index < lines.length; index++) {
|
|
151
|
+
if (pattern.test(lines[index]!)) {
|
|
152
|
+
return { start: index + 1, end: index + 1 };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { start: 1, end: Math.max(1, lines.length) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolvePullHead(
|
|
159
|
+
owner: string,
|
|
160
|
+
repo: string,
|
|
161
|
+
pull: number,
|
|
162
|
+
signal?: AbortSignal,
|
|
163
|
+
): Promise<
|
|
164
|
+
| { status: "ok"; sha: string; htmlUrl: string }
|
|
165
|
+
| { status: "not-found" }
|
|
166
|
+
| { status: "forbidden" }
|
|
167
|
+
| { status: "error"; message: string }
|
|
168
|
+
> {
|
|
169
|
+
const response = await githubFetch(
|
|
170
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${String(pull)}`,
|
|
171
|
+
signal,
|
|
172
|
+
);
|
|
173
|
+
if (response.status !== "ok") return response;
|
|
174
|
+
const payload = (await response.body.json().catch(() => undefined)) as
|
|
175
|
+
| PullApi
|
|
176
|
+
| undefined;
|
|
177
|
+
const sha = payload?.head?.sha;
|
|
178
|
+
if (typeof sha !== "string" || sha.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
status: "error",
|
|
181
|
+
message: "GitHub did not return a pull head SHA.",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
status: "ok",
|
|
186
|
+
sha,
|
|
187
|
+
htmlUrl:
|
|
188
|
+
typeof payload?.html_url === "string"
|
|
189
|
+
? payload.html_url
|
|
190
|
+
: `https://github.com/${owner}/${repo}/pull/${String(pull)}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function fetchPullFiles(
|
|
195
|
+
owner: string,
|
|
196
|
+
repo: string,
|
|
197
|
+
pull: number,
|
|
198
|
+
signal?: AbortSignal,
|
|
199
|
+
): Promise<GitHubPullFile[] | Error> {
|
|
200
|
+
const files: GitHubPullFile[] = [];
|
|
201
|
+
for (let page = 1; page <= 10; page++) {
|
|
202
|
+
const response = await githubFetch(
|
|
203
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${String(pull)}/files?per_page=100&page=${String(page)}`,
|
|
204
|
+
signal,
|
|
205
|
+
);
|
|
206
|
+
if (response.status === "not-found") return new Error("not-found");
|
|
207
|
+
if (response.status === "forbidden") return new Error("forbidden");
|
|
208
|
+
if (response.status === "error") return new Error(response.message);
|
|
209
|
+
const payload = (await response.body.json().catch(() => undefined)) as
|
|
210
|
+
| GitHubPullFile[]
|
|
211
|
+
| undefined;
|
|
212
|
+
if (!Array.isArray(payload)) {
|
|
213
|
+
return new Error("GitHub returned invalid pull files.");
|
|
214
|
+
}
|
|
215
|
+
files.push(...payload);
|
|
216
|
+
if (payload.length < 100) break;
|
|
217
|
+
}
|
|
218
|
+
return files;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function readContents(
|
|
222
|
+
pin: { owner: string; repo: string; sha: string },
|
|
223
|
+
filePath: string,
|
|
224
|
+
signal?: AbortSignal,
|
|
225
|
+
): Promise<string | Error> {
|
|
226
|
+
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
227
|
+
const response = await githubFetch(
|
|
228
|
+
`/repos/${encodeURIComponent(pin.owner)}/${encodeURIComponent(pin.repo)}/contents/${encodedPath}?ref=${encodeURIComponent(pin.sha)}`,
|
|
229
|
+
signal,
|
|
230
|
+
);
|
|
231
|
+
if (response.status === "not-found") return new Error("not-found");
|
|
232
|
+
if (response.status === "forbidden") return new Error("forbidden");
|
|
233
|
+
if (response.status === "error") return new Error(response.message);
|
|
234
|
+
const payload = (await response.body.json().catch(() => undefined)) as
|
|
235
|
+
| ContentsApi
|
|
236
|
+
| ContentsApi[]
|
|
237
|
+
| undefined;
|
|
238
|
+
if (Array.isArray(payload)) {
|
|
239
|
+
return new Error(`"${filePath}" is a directory at this commit.`);
|
|
240
|
+
}
|
|
241
|
+
if (payload === undefined) {
|
|
242
|
+
return new Error(`Could not read "${filePath}" at this commit.`);
|
|
243
|
+
}
|
|
244
|
+
if (payload.type !== undefined && payload.type !== "file") {
|
|
245
|
+
return new Error(`"${filePath}" is not a file at this commit.`);
|
|
246
|
+
}
|
|
247
|
+
if (payload.encoding === "base64" && typeof payload.content === "string") {
|
|
248
|
+
return decodeBase64(payload.content.replace(/\n/g, ""));
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
typeof payload.download_url === "string" &&
|
|
252
|
+
payload.download_url.length > 0
|
|
253
|
+
) {
|
|
254
|
+
const raw = await fetch(payload.download_url, { signal }).catch(
|
|
255
|
+
(cause: unknown) => cause,
|
|
256
|
+
);
|
|
257
|
+
if (raw instanceof Error) {
|
|
258
|
+
if (raw.name === "AbortError") throw raw;
|
|
259
|
+
return new Error(`Could not fetch "${filePath}" at this commit.`);
|
|
260
|
+
}
|
|
261
|
+
if (!(raw instanceof Response) || !raw.ok) {
|
|
262
|
+
return new Error(`Could not fetch "${filePath}" at this commit.`);
|
|
263
|
+
}
|
|
264
|
+
return raw.text();
|
|
265
|
+
}
|
|
266
|
+
return new Error(`Could not read "${filePath}" at this commit.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function decodeBase64(value: string) {
|
|
270
|
+
try {
|
|
271
|
+
const binary = atob(value);
|
|
272
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
273
|
+
return new TextDecoder().decode(bytes);
|
|
274
|
+
} catch (cause) {
|
|
275
|
+
return new Error("GitHub returned invalid file encoding.", { cause });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function githubFetch(path: string, signal?: AbortSignal) {
|
|
280
|
+
const response = await fetch(`${GITHUB_API}${path}`, {
|
|
281
|
+
headers: GITHUB_JSON,
|
|
282
|
+
signal,
|
|
283
|
+
}).catch((cause: unknown) => cause);
|
|
284
|
+
if (response instanceof Error) {
|
|
285
|
+
if (response.name === "AbortError") throw response;
|
|
286
|
+
return { status: "error" as const, message: "Failed to reach GitHub." };
|
|
287
|
+
}
|
|
288
|
+
if (!(response instanceof Response)) {
|
|
289
|
+
return { status: "error" as const, message: "Failed to reach GitHub." };
|
|
290
|
+
}
|
|
291
|
+
if (response.status === 404) return { status: "not-found" as const };
|
|
292
|
+
if (response.status === 403) return { status: "forbidden" as const };
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
return {
|
|
295
|
+
status: "error" as const,
|
|
296
|
+
message: `GitHub returned ${String(response.status)}.`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return { status: "ok" as const, body: response };
|
|
300
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parsePatchFiles } from "@pierre/diffs";
|
|
4
|
+
import { pullFilesToSourceDiffs, wrapGitHubPullPatch } from "./patch.ts";
|
|
5
|
+
|
|
6
|
+
test("wrapGitHubPullPatch builds a unified diff Pierre can parse", () => {
|
|
7
|
+
const wrapped = wrapGitHubPullPatch({
|
|
8
|
+
filename: "src/cli.ts",
|
|
9
|
+
status: "modified",
|
|
10
|
+
patch: ["@@ -1,3 +1,4 @@", " line", "+added", " line", " line"].join("\n"),
|
|
11
|
+
});
|
|
12
|
+
assert.equal(typeof wrapped, "string");
|
|
13
|
+
const patches = parsePatchFiles(wrapped!, undefined, true);
|
|
14
|
+
assert.equal(patches[0]?.files[0]?.name, "src/cli.ts");
|
|
15
|
+
assert.ok((patches[0]?.files[0]?.hunks.length ?? 0) > 0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("pullFilesToSourceDiffs skips files without a patch", () => {
|
|
19
|
+
const diffs = pullFilesToSourceDiffs([
|
|
20
|
+
{ filename: "icon.png", status: "added" },
|
|
21
|
+
{
|
|
22
|
+
filename: "src/cli.ts",
|
|
23
|
+
status: "modified",
|
|
24
|
+
patch: ["@@ -1,2 +1,3 @@", " a", "+b", " c"].join("\n"),
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
assert.equal(diffs.length, 1);
|
|
28
|
+
assert.equal(diffs[0]?.id, "pr:src/cli.ts");
|
|
29
|
+
assert.equal(diffs[0]?.fileDiff.name, "src/cli.ts");
|
|
30
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parsePatchFiles, type CodeViewDiffItem } from "@pierre/diffs";
|
|
2
|
+
|
|
3
|
+
export type GitHubPullFile = {
|
|
4
|
+
filename: string;
|
|
5
|
+
previous_filename?: string;
|
|
6
|
+
status: string;
|
|
7
|
+
patch?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Wrap a GitHub pull-files `patch` (hunks only) as a unified diff Pierre can parse. */
|
|
11
|
+
export function wrapGitHubPullPatch(file: GitHubPullFile) {
|
|
12
|
+
if (file.patch === undefined || file.patch.length === 0) return undefined;
|
|
13
|
+
const oldPath = file.previous_filename ?? file.filename;
|
|
14
|
+
const newPath = file.filename;
|
|
15
|
+
const oldHeader = file.status === "added" ? "/dev/null" : `a/${oldPath}`;
|
|
16
|
+
const newHeader = file.status === "removed" ? "/dev/null" : `b/${newPath}`;
|
|
17
|
+
return [
|
|
18
|
+
`diff --git a/${oldPath} b/${newPath}`,
|
|
19
|
+
`--- ${oldHeader}`,
|
|
20
|
+
`+++ ${newHeader}`,
|
|
21
|
+
file.patch,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pullFilesToSourceDiffs(files: GitHubPullFile[]) {
|
|
26
|
+
const diffs: CodeViewDiffItem[] = [];
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const unified = wrapGitHubPullPatch(file);
|
|
29
|
+
if (unified === undefined) continue;
|
|
30
|
+
const patches = parsePatchFiles(unified, undefined, false);
|
|
31
|
+
const fileDiff = patches[0]?.files[0];
|
|
32
|
+
if (fileDiff === undefined || fileDiff.hunks.length === 0) continue;
|
|
33
|
+
diffs.push({
|
|
34
|
+
id: `pr:${file.filename}`,
|
|
35
|
+
type: "diff",
|
|
36
|
+
fileDiff,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return diffs;
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/** Pinned commit: every blob fetch for this page uses this SHA, never main. */
|
|
4
|
+
export type GitHubPin = {
|
|
5
|
+
owner: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
sha: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const GitHubPinContext = createContext<GitHubPin | undefined>(undefined);
|
|
11
|
+
|
|
12
|
+
export function useGitHubPin() {
|
|
13
|
+
return useContext(GitHubPinContext);
|
|
14
|
+
}
|