@thurstonsand/pi-librarian 0.1.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/AGENTS.md +21 -0
- package/CONTEXT.md +32 -0
- package/DEV.md +41 -0
- package/README.md +43 -0
- package/RELEASE.md +13 -0
- package/docs/designs/01-librarian.md +215 -0
- package/docs/designs/02-continuable-runs.md +101 -0
- package/docs/designs/03-librarian-research-tool-hardening.md +141 -0
- package/docs/release.md +22 -0
- package/extensions/librarian/attach.ts +41 -0
- package/extensions/librarian/checkout.ts +239 -0
- package/extensions/librarian/github.ts +364 -0
- package/extensions/librarian/grep-app.ts +237 -0
- package/extensions/librarian/model.ts +59 -0
- package/extensions/librarian/prompt.ts +54 -0
- package/extensions/librarian/results.ts +63 -0
- package/extensions/librarian/run.ts +248 -0
- package/extensions/librarian/settings.ts +119 -0
- package/extensions/librarian/tools/checkout-repo.ts +88 -0
- package/extensions/librarian/tools/names.ts +21 -0
- package/extensions/librarian/tools/provide-results.ts +60 -0
- package/extensions/librarian/tools/read-github-file.ts +122 -0
- package/extensions/librarian/tools/search-code.ts +99 -0
- package/extensions/librarian/tools/search-github-code.ts +125 -0
- package/extensions/librarian/tools/search-repos.ts +101 -0
- package/extensions/librarian/trace.ts +76 -0
- package/extensions/librarian/view.ts +307 -0
- package/extensions/librarian.ts +181 -0
- package/extensions/shared/typebox.ts +31 -0
- package/package.json +73 -0
package/docs/release.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Release
|
|
2
|
+
|
|
3
|
+
`@thurstonsand/pi-librarian` publishes to npm from GitHub Actions when a stable `v*` tag is pushed.
|
|
4
|
+
|
|
5
|
+
## Release flow
|
|
6
|
+
|
|
7
|
+
1. Prepare `RELEASE.md` with a new `## X.Y.Z` entry.
|
|
8
|
+
2. Verify locally with `npm run check` and `npm pack --dry-run`.
|
|
9
|
+
3. Commit the release prep.
|
|
10
|
+
4. Create an annotated `vX.Y.Z` tag using the matching `RELEASE.md` entry as the tag body.
|
|
11
|
+
5. Push `main` and the tag.
|
|
12
|
+
6. The `Release` workflow runs CI, sets the package version from the tag, packs the package, and publishes it to npm with the `latest` dist-tag.
|
|
13
|
+
|
|
14
|
+
## Release note extraction
|
|
15
|
+
|
|
16
|
+
Use the helper script to extract the exact release entry for the git tag body:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
VERSION=X.Y.Z
|
|
20
|
+
scripts/extract-release-notes.sh "v${VERSION}" > "/tmp/pi-librarian-v${VERSION}-notes.md"
|
|
21
|
+
git tag -a "v${VERSION}" --cleanup=verbatim -F "/tmp/pi-librarian-v${VERSION}-notes.md"
|
|
22
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { safeParseTypeBoxValue } from "../shared/typebox.ts";
|
|
4
|
+
import { ATTACHABLE_TOOL_NAMES } from "./tools/names.ts";
|
|
5
|
+
|
|
6
|
+
export const ATTACH_ENTRY_TYPE = "pi-librarian:attach";
|
|
7
|
+
|
|
8
|
+
const ATTACH_STATE_SCHEMA = Type.Object({
|
|
9
|
+
attached: Type.Boolean(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function readAttachState(ctx: ExtensionContext): boolean {
|
|
13
|
+
let attached = false;
|
|
14
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
15
|
+
if (entry.type !== "custom" || entry.customType !== ATTACH_ENTRY_TYPE) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const parsed = safeParseTypeBoxValue(ATTACH_STATE_SCHEMA, entry.data);
|
|
19
|
+
if (parsed) {
|
|
20
|
+
attached = parsed.attached;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return attached;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function applyAttachState(pi: ExtensionAPI, attached: boolean): void {
|
|
27
|
+
const active = new Set(pi.getActiveTools());
|
|
28
|
+
for (const name of ATTACHABLE_TOOL_NAMES) {
|
|
29
|
+
if (attached) {
|
|
30
|
+
active.add(name);
|
|
31
|
+
} else {
|
|
32
|
+
active.delete(name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
pi.setActiveTools([...active]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setAttachState(pi: ExtensionAPI, attached: boolean): void {
|
|
39
|
+
applyAttachState(pi, attached);
|
|
40
|
+
pi.appendEntry(ATTACH_ENTRY_TYPE, { attached });
|
|
41
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
const FETCH_DEBOUNCE_MS = 15 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
export class CheckoutError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "CheckoutError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CheckoutResult {
|
|
18
|
+
repo: string;
|
|
19
|
+
path: string;
|
|
20
|
+
headSha: string;
|
|
21
|
+
ref: string;
|
|
22
|
+
fileCount: number;
|
|
23
|
+
reusedClone: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RepoReference {
|
|
27
|
+
key: string;
|
|
28
|
+
cloneUrls: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function repoCachePath(cacheDir: string, repo: string): string {
|
|
32
|
+
return path.join(cacheDir, "repos", ...repo.split("/"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isGitHubHost(host: string): boolean {
|
|
36
|
+
return host === "github.com" || host === "www.github.com";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeRepoPath(rawPath: string): string | undefined {
|
|
40
|
+
const repoPath = rawPath.replace(/^\/+/, "").replace(/\.git$/, "");
|
|
41
|
+
const parts = repoPath.split("/");
|
|
42
|
+
if (parts.length < 2 || parts.some((part) => !/^[\w.-]+$/.test(part))) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return parts.join("/");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cloneKey(host: string, repoPath: string): string {
|
|
49
|
+
return isGitHubHost(host) ? repoPath : `${host}/${repoPath}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseRepoReference(input: string): RepoReference {
|
|
53
|
+
const trimmed = input.trim().replace(/\/+$/, "");
|
|
54
|
+
const githubPath = normalizeRepoPath(trimmed);
|
|
55
|
+
if (githubPath && githubPath.split("/").length === 2) {
|
|
56
|
+
return {
|
|
57
|
+
key: githubPath,
|
|
58
|
+
cloneUrls: [`https://github.com/${githubPath}.git`, `git@github.com:${githubPath}.git`],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const scpMatch = /^(?<user>[\w.-]+)@(?<host>[\w.-]+):(?<path>[\w./-]+?)(?:\.git)?$/.exec(trimmed);
|
|
63
|
+
if (scpMatch?.groups) {
|
|
64
|
+
const user = scpMatch.groups.user;
|
|
65
|
+
const host = scpMatch.groups.host;
|
|
66
|
+
const repoPath = scpMatch.groups.path ? normalizeRepoPath(scpMatch.groups.path) : undefined;
|
|
67
|
+
if (user && host && repoPath && (!isGitHubHost(host) || repoPath.split("/").length === 2)) {
|
|
68
|
+
return {
|
|
69
|
+
key: cloneKey(host, repoPath),
|
|
70
|
+
cloneUrls: [`${user}@${host}:${repoPath}.git`],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let url: URL;
|
|
76
|
+
try {
|
|
77
|
+
url = new URL(trimmed.replace(/\.git$/, ""));
|
|
78
|
+
} catch {
|
|
79
|
+
throw new CheckoutError(
|
|
80
|
+
`Invalid repository "${input}". Use owner/repo or an HTTPS/SSH repository URL.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (url.protocol !== "https:" && url.protocol !== "ssh:") {
|
|
85
|
+
throw new CheckoutError(
|
|
86
|
+
`Invalid repository "${input}". Use owner/repo or an HTTPS/SSH repository URL.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const repoPath = normalizeRepoPath(url.pathname);
|
|
91
|
+
if (!repoPath || (isGitHubHost(url.hostname) && repoPath.split("/").length !== 2)) {
|
|
92
|
+
throw new CheckoutError(
|
|
93
|
+
`Invalid repository "${input}". Use owner/repo or an HTTPS/SSH repository URL.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const key = cloneKey(url.hostname, repoPath);
|
|
98
|
+
if (url.protocol === "ssh:") {
|
|
99
|
+
const user = url.username || "git";
|
|
100
|
+
return { key, cloneUrls: [`ssh://${user}@${url.hostname}/${repoPath}.git`] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (isGitHubHost(url.hostname)) {
|
|
104
|
+
return {
|
|
105
|
+
key,
|
|
106
|
+
cloneUrls: [`https://github.com/${repoPath}.git`, `git@github.com:${repoPath}.git`],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
key,
|
|
112
|
+
cloneUrls: [`https://${url.hostname}/${repoPath}.git`, `git@${url.hostname}:${repoPath}.git`],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function parseRepoName(input: string): string {
|
|
117
|
+
return parseRepoReference(input).key;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function git(
|
|
121
|
+
args: string[],
|
|
122
|
+
cwd: string | undefined,
|
|
123
|
+
signal: AbortSignal | undefined,
|
|
124
|
+
): Promise<string> {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
127
|
+
...(cwd ? { cwd } : {}),
|
|
128
|
+
...(signal ? { signal } : {}),
|
|
129
|
+
timeout: 300_000,
|
|
130
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
131
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
132
|
+
});
|
|
133
|
+
return stdout.trim();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const stderr =
|
|
136
|
+
error && typeof error === "object" && "stderr" in error ? String(error.stderr) : "";
|
|
137
|
+
const message = stderr.trim() || (error instanceof Error ? error.message : String(error));
|
|
138
|
+
throw new CheckoutError(`git ${args[0]} failed: ${message.slice(0, 500)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function pathExists(target: string): Promise<boolean> {
|
|
143
|
+
try {
|
|
144
|
+
await fs.access(target);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function clone(
|
|
152
|
+
reference: RepoReference,
|
|
153
|
+
dest: string,
|
|
154
|
+
signal: AbortSignal | undefined,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
157
|
+
|
|
158
|
+
let lastError: unknown;
|
|
159
|
+
for (const url of reference.cloneUrls) {
|
|
160
|
+
try {
|
|
161
|
+
await git(["clone", "--filter=blob:none", url, dest], undefined, signal);
|
|
162
|
+
return;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
lastError = error;
|
|
165
|
+
await fs.rm(dest, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw lastError instanceof Error
|
|
170
|
+
? lastError
|
|
171
|
+
: new CheckoutError(`Failed to clone ${reference.key}.`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function fetchIfStale(dest: string, signal: AbortSignal | undefined): Promise<void> {
|
|
175
|
+
let lastFetched = 0;
|
|
176
|
+
try {
|
|
177
|
+
const stat = await fs.stat(path.join(dest, ".git", "FETCH_HEAD"));
|
|
178
|
+
lastFetched = stat.mtimeMs;
|
|
179
|
+
} catch {
|
|
180
|
+
// Never fetched since clone; the clone itself counts only via HEAD age, so fetch.
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (Date.now() - lastFetched < FETCH_DEBOUNCE_MS) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await git(["fetch", "origin", "--prune"], dest, signal);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function defaultBranch(dest: string, signal: AbortSignal | undefined): Promise<string> {
|
|
191
|
+
try {
|
|
192
|
+
const ref = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], dest, signal);
|
|
193
|
+
return ref.replace("refs/remotes/origin/", "");
|
|
194
|
+
} catch {
|
|
195
|
+
await git(["remote", "set-head", "origin", "--auto"], dest, signal);
|
|
196
|
+
const ref = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], dest, signal);
|
|
197
|
+
return ref.replace("refs/remotes/origin/", "");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function checkoutRepo(
|
|
202
|
+
repoInput: string,
|
|
203
|
+
ref: string | undefined,
|
|
204
|
+
cacheDir: string,
|
|
205
|
+
signal: AbortSignal | undefined,
|
|
206
|
+
): Promise<CheckoutResult> {
|
|
207
|
+
const repo = parseRepoReference(repoInput);
|
|
208
|
+
const dest = repoCachePath(cacheDir, repo.key);
|
|
209
|
+
|
|
210
|
+
const reusedClone = await pathExists(path.join(dest, ".git"));
|
|
211
|
+
if (reusedClone) {
|
|
212
|
+
await fetchIfStale(dest, signal);
|
|
213
|
+
} else {
|
|
214
|
+
await clone(repo, dest, signal);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let checkedOutRef: string;
|
|
218
|
+
if (ref) {
|
|
219
|
+
try {
|
|
220
|
+
await git(["fetch", "origin", ref], dest, signal);
|
|
221
|
+
await git(["checkout", "--force", "--detach", "FETCH_HEAD"], dest, signal);
|
|
222
|
+
} catch {
|
|
223
|
+
// Refs already fetched (e.g. a sha reachable from an earlier fetch) check out directly.
|
|
224
|
+
await git(["checkout", "--force", "--detach", ref], dest, signal);
|
|
225
|
+
}
|
|
226
|
+
checkedOutRef = ref;
|
|
227
|
+
} else {
|
|
228
|
+
const branch = await defaultBranch(dest, signal);
|
|
229
|
+
await git(["checkout", "--force", branch], dest, signal);
|
|
230
|
+
await git(["reset", "--hard", `origin/${branch}`], dest, signal);
|
|
231
|
+
checkedOutRef = branch;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const headSha = await git(["rev-parse", "HEAD"], dest, signal);
|
|
235
|
+
const files = await git(["ls-files"], dest, signal);
|
|
236
|
+
const fileCount = files.length === 0 ? 0 : files.split("\n").length;
|
|
237
|
+
|
|
238
|
+
return { repo: repo.key, path: dest, headSha, ref: checkedOutRef, fileCount, reusedClone };
|
|
239
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { RequestError } from "@octokit/request-error";
|
|
4
|
+
import { Octokit } from "@octokit/rest";
|
|
5
|
+
import { type Static, Type } from "typebox";
|
|
6
|
+
import { safeParseTypeBoxValue } from "../shared/typebox.ts";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export class GitHubApiError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
message: string,
|
|
13
|
+
readonly status: number,
|
|
14
|
+
readonly retryAfterSeconds: number | undefined,
|
|
15
|
+
override readonly cause?: unknown,
|
|
16
|
+
) {
|
|
17
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
18
|
+
this.name = "GitHubApiError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GitHubRepoSlug {
|
|
23
|
+
owner: string;
|
|
24
|
+
repo: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function repoFullName(repo: GitHubRepoSlug): string {
|
|
28
|
+
return `${repo.owner}/${repo.repo}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type GitHubClientProvider = () => Promise<GitHubClient>;
|
|
32
|
+
|
|
33
|
+
export async function resolveGitHubToken(): Promise<string | undefined> {
|
|
34
|
+
const envToken = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
|
|
35
|
+
if (envToken) {
|
|
36
|
+
return envToken;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execFileAsync("gh", ["auth", "token"], { timeout: 10_000 });
|
|
41
|
+
const token = stdout.trim();
|
|
42
|
+
return token.length > 0 ? token : undefined;
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createGitHubClient(token: string | undefined): GitHubClient {
|
|
49
|
+
return new GitHubClient(
|
|
50
|
+
new Octokit({
|
|
51
|
+
...(token ? { auth: token } : {}),
|
|
52
|
+
userAgent: "pi-librarian",
|
|
53
|
+
log: {
|
|
54
|
+
debug: () => {},
|
|
55
|
+
info: () => {},
|
|
56
|
+
warn: () => {},
|
|
57
|
+
error: () => {},
|
|
58
|
+
},
|
|
59
|
+
request: {
|
|
60
|
+
headers: {
|
|
61
|
+
"x-github-api-version": "2022-11-28",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createGitHubClientProvider(
|
|
69
|
+
tokenResolver: () => Promise<string | undefined> = resolveGitHubToken,
|
|
70
|
+
): GitHubClientProvider {
|
|
71
|
+
let client: Promise<GitHubClient> | undefined;
|
|
72
|
+
return () => {
|
|
73
|
+
client ??= tokenResolver().then((token) => createGitHubClient(token));
|
|
74
|
+
return client;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function retryAfterSeconds(
|
|
79
|
+
headers: Record<string, string | number | undefined> | undefined,
|
|
80
|
+
): number | undefined {
|
|
81
|
+
const retryAfter = headers?.["retry-after"];
|
|
82
|
+
const rateReset = headers?.["x-ratelimit-reset"];
|
|
83
|
+
|
|
84
|
+
let seconds: number | undefined;
|
|
85
|
+
if (retryAfter) {
|
|
86
|
+
seconds = Number(retryAfter);
|
|
87
|
+
} else if (rateReset) {
|
|
88
|
+
seconds = Math.max(0, Number(rateReset) - Math.floor(Date.now() / 1000));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Number.isFinite(seconds) ? seconds : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const GITHUB_ERROR_DATA_SCHEMA = Type.Object({
|
|
95
|
+
message: Type.Optional(Type.String()),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function errorDetail(error: RequestError): string {
|
|
99
|
+
const parsed = safeParseTypeBoxValue(GITHUB_ERROR_DATA_SCHEMA, error.response?.data);
|
|
100
|
+
if (parsed?.message) {
|
|
101
|
+
return parsed.message;
|
|
102
|
+
}
|
|
103
|
+
return error.message;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeGitHubError(error: unknown, operation: string): never {
|
|
107
|
+
if (error instanceof RequestError) {
|
|
108
|
+
const detail = errorDetail(error).slice(0, 300);
|
|
109
|
+
throw new GitHubApiError(
|
|
110
|
+
`GitHub API ${error.status} during ${operation}${detail ? `: ${detail}` : ""}`,
|
|
111
|
+
error.status,
|
|
112
|
+
retryAfterSeconds(error.response?.headers),
|
|
113
|
+
error,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const REPO_SEARCH_ITEM_SCHEMA = Type.Object({
|
|
121
|
+
full_name: Type.String(),
|
|
122
|
+
description: Type.Union([Type.String(), Type.Null()]),
|
|
123
|
+
stargazers_count: Type.Number(),
|
|
124
|
+
language: Type.Union([Type.String(), Type.Null()]),
|
|
125
|
+
topics: Type.Optional(Type.Array(Type.String())),
|
|
126
|
+
pushed_at: Type.Union([Type.String(), Type.Null()]),
|
|
127
|
+
archived: Type.Boolean(),
|
|
128
|
+
fork: Type.Boolean(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const REPO_SEARCH_RESPONSE_SCHEMA = Type.Object({
|
|
132
|
+
total_count: Type.Number(),
|
|
133
|
+
items: Type.Array(Type.Unknown()),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export interface RepoSearchHit {
|
|
137
|
+
repo: string;
|
|
138
|
+
description: string | undefined;
|
|
139
|
+
stars: number;
|
|
140
|
+
language: string | undefined;
|
|
141
|
+
topics: string[];
|
|
142
|
+
pushedAt: string | undefined;
|
|
143
|
+
archived: boolean;
|
|
144
|
+
fork: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface RepoSearchResult {
|
|
148
|
+
totalCount: number;
|
|
149
|
+
hits: RepoSearchHit[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface SearchRepositoriesParams {
|
|
153
|
+
query: string;
|
|
154
|
+
sort: "stars" | "updated" | "best-match";
|
|
155
|
+
limit: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const CODE_SEARCH_MATCH_SCHEMA = Type.Object({
|
|
159
|
+
fragment: Type.Optional(Type.String()),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const CODE_SEARCH_ITEM_SCHEMA = Type.Object({
|
|
163
|
+
path: Type.String(),
|
|
164
|
+
repository: Type.Object({ full_name: Type.String() }),
|
|
165
|
+
text_matches: Type.Optional(Type.Array(Type.Unknown())),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const CODE_SEARCH_RESPONSE_SCHEMA = Type.Object({
|
|
169
|
+
total_count: Type.Number(),
|
|
170
|
+
items: Type.Array(Type.Unknown()),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export interface CodeSearchHit {
|
|
174
|
+
repo: string;
|
|
175
|
+
path: string;
|
|
176
|
+
fragments: string[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface GitHubCodeSearchResult {
|
|
180
|
+
totalCount: number;
|
|
181
|
+
hits: CodeSearchHit[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface SearchGitHubCodeParams {
|
|
185
|
+
pattern: string;
|
|
186
|
+
repos?: GitHubRepoSlug[];
|
|
187
|
+
owners?: string[];
|
|
188
|
+
language?: string;
|
|
189
|
+
path?: string;
|
|
190
|
+
limit: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const CONTENTS_DIR_ENTRY_SCHEMA = Type.Object({
|
|
194
|
+
type: Type.String(),
|
|
195
|
+
path: Type.String(),
|
|
196
|
+
size: Type.Optional(Type.Number()),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export type FileContents = {
|
|
200
|
+
kind: "file";
|
|
201
|
+
text: string;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export type DirectoryContents = {
|
|
205
|
+
kind: "directory";
|
|
206
|
+
entries: { type: string; path: string; size: number | undefined }[];
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export interface ReadContentsParams {
|
|
210
|
+
repo: GitHubRepoSlug;
|
|
211
|
+
path: string;
|
|
212
|
+
ref: string | undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const REPO_META_SCHEMA = Type.Object({
|
|
216
|
+
default_branch: Type.String(),
|
|
217
|
+
private: Type.Boolean(),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
export interface RepoMeta {
|
|
221
|
+
defaultBranch: string;
|
|
222
|
+
isPrivate: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildGitHubCodeQuery(params: SearchGitHubCodeParams): string {
|
|
226
|
+
const parts: string[] = [params.pattern.replace(/^\/|\/$/g, "")];
|
|
227
|
+
|
|
228
|
+
for (const repo of params.repos ?? []) {
|
|
229
|
+
parts.push(`repo:${repoFullName(repo)}`);
|
|
230
|
+
}
|
|
231
|
+
for (const owner of params.owners ?? []) {
|
|
232
|
+
parts.push(`user:${owner}`);
|
|
233
|
+
}
|
|
234
|
+
if (params.language) {
|
|
235
|
+
parts.push(`language:${params.language}`);
|
|
236
|
+
}
|
|
237
|
+
if (params.path) {
|
|
238
|
+
parts.push(`path:${params.path}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return parts.join(" ");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export class GitHubClient {
|
|
245
|
+
constructor(private readonly octokit: Octokit) {}
|
|
246
|
+
|
|
247
|
+
async searchRepositories(params: SearchRepositoriesParams): Promise<RepoSearchResult> {
|
|
248
|
+
try {
|
|
249
|
+
const response = await this.octokit.rest.search.repos({
|
|
250
|
+
q: params.query,
|
|
251
|
+
per_page: params.limit,
|
|
252
|
+
...(params.sort === "best-match" ? {} : { sort: params.sort, order: "desc" as const }),
|
|
253
|
+
});
|
|
254
|
+
const payload = safeParseTypeBoxValue(REPO_SEARCH_RESPONSE_SCHEMA, response.data);
|
|
255
|
+
if (!payload) {
|
|
256
|
+
throw new Error("GitHub repository search returned an unexpected payload shape.");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const hits: RepoSearchHit[] = [];
|
|
260
|
+
for (const item of payload.items) {
|
|
261
|
+
const parsed = safeParseTypeBoxValue(REPO_SEARCH_ITEM_SCHEMA, item);
|
|
262
|
+
if (!parsed) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
hits.push({
|
|
267
|
+
repo: parsed.full_name,
|
|
268
|
+
description: parsed.description ?? undefined,
|
|
269
|
+
stars: parsed.stargazers_count,
|
|
270
|
+
language: parsed.language ?? undefined,
|
|
271
|
+
topics: parsed.topics ?? [],
|
|
272
|
+
pushedAt: parsed.pushed_at ?? undefined,
|
|
273
|
+
archived: parsed.archived,
|
|
274
|
+
fork: parsed.fork,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { totalCount: payload.total_count, hits };
|
|
279
|
+
} catch (error) {
|
|
280
|
+
normalizeGitHubError(error, "repository search");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async searchCode(params: SearchGitHubCodeParams): Promise<GitHubCodeSearchResult> {
|
|
285
|
+
try {
|
|
286
|
+
const response = await this.octokit.rest.search.code({
|
|
287
|
+
q: buildGitHubCodeQuery(params),
|
|
288
|
+
per_page: params.limit,
|
|
289
|
+
mediaType: { format: "text-match" },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const payload = safeParseTypeBoxValue(CODE_SEARCH_RESPONSE_SCHEMA, response.data);
|
|
293
|
+
if (!payload) {
|
|
294
|
+
throw new Error("GitHub code search returned an unexpected payload shape.");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const hits: CodeSearchHit[] = [];
|
|
298
|
+
for (const item of payload.items) {
|
|
299
|
+
const parsed = safeParseTypeBoxValue(CODE_SEARCH_ITEM_SCHEMA, item);
|
|
300
|
+
if (!parsed) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const fragments: string[] = [];
|
|
305
|
+
for (const match of parsed.text_matches ?? []) {
|
|
306
|
+
const parsedMatch = safeParseTypeBoxValue(CODE_SEARCH_MATCH_SCHEMA, match);
|
|
307
|
+
if (parsedMatch?.fragment) {
|
|
308
|
+
fragments.push(parsedMatch.fragment);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
hits.push({ repo: parsed.repository.full_name, path: parsed.path, fragments });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { totalCount: payload.total_count, hits };
|
|
316
|
+
} catch (error) {
|
|
317
|
+
normalizeGitHubError(error, "code search");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async readContents(params: ReadContentsParams): Promise<FileContents | DirectoryContents> {
|
|
322
|
+
try {
|
|
323
|
+
const response = await this.octokit.rest.repos.getContent({
|
|
324
|
+
owner: params.repo.owner,
|
|
325
|
+
repo: params.repo.repo,
|
|
326
|
+
path: params.path,
|
|
327
|
+
...(params.ref ? { ref: params.ref } : {}),
|
|
328
|
+
mediaType: { format: "raw" },
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (Array.isArray(response.data)) {
|
|
332
|
+
const entries = response.data
|
|
333
|
+
.map((entry) => safeParseTypeBoxValue(CONTENTS_DIR_ENTRY_SCHEMA, entry))
|
|
334
|
+
.filter((entry): entry is Static<typeof CONTENTS_DIR_ENTRY_SCHEMA> => entry !== undefined)
|
|
335
|
+
.map((entry) => ({ type: entry.type, path: entry.path, size: entry.size }));
|
|
336
|
+
return { kind: "directory", entries };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (typeof response.data === "string") {
|
|
340
|
+
return { kind: "file", text: response.data };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
throw new Error(`Unexpected payload reading ${repoFullName(params.repo)}:${params.path}.`);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
normalizeGitHubError(error, `read ${repoFullName(params.repo)}:${params.path}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async getRepo(repo: GitHubRepoSlug): Promise<RepoMeta> {
|
|
350
|
+
try {
|
|
351
|
+
const response = await this.octokit.rest.repos.get({ owner: repo.owner, repo: repo.repo });
|
|
352
|
+
const payload = safeParseTypeBoxValue(REPO_META_SCHEMA, response.data);
|
|
353
|
+
if (!payload) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`GitHub repo metadata for ${repoFullName(repo)} returned an unexpected payload shape.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { defaultBranch: payload.default_branch, isPrivate: payload.private };
|
|
360
|
+
} catch (error) {
|
|
361
|
+
normalizeGitHubError(error, `repo metadata for ${repoFullName(repo)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|