History

Add GitHub sync and self-host custom tools wiring

Rhys Sullivan committed 4 months ago

Commit 275446e1 parent 41eb3ae19 files · +1665 −40Browse files

Showing the changes to packages/plugins/apps/src/source/github-source.test.ts (1 of 19 files). Show all

Unified · Split

packages/plugins/apps/src/source/github-source.test.ts+211−0
@@ -0,0 +1,211 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { describe, expect, it } from "vitest";
6+import { Effect } from "effect";
7+
8+import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime";
9+import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
10+import { PUBLISH_LIMITS } from "../pipeline/publish";
11+import { fetchGitHubSource, syncGitHubSource } from "./github-source";
12+
13+const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
14+
15+const toolSource = (name = "ok"): string => `import { defineTool } from "executor:app";
16+import { z } from "zod";
17+export default defineTool({
18+ description: "${name}",
19+ input: z.object({ value: z.string().default("${name}") }),
20+ async handler(input) { return { value: input.value }; },
21+});`;
22+
23+const makeRuntime = () =>
24+ makeSelfHostAppsRuntime({
25+ dataDir: mkdtempSync(join(tmpdir(), "apps-gh-src-")),
26+ store: makeInMemoryAppsStore(),
27+ resolver: makeTestResolver({}),
28+ inMemory: true,
29+ });
30+
31+const json = (value: unknown, status = 200): Response =>
32+ new Response(JSON.stringify(value), {
33+ status,
34+ headers: { "content-type": "application/json" },
35+ });
36+
37+const makeGitHubFetch = (input: {
38+ readonly files: ReadonlyMap<string, string>;
39+ readonly upstreamSha?: string;
40+ readonly treeSha?: string;
41+}) => {
42+ const repoPath = "/repos/acme/tools";
43+ const upstreamSha = input.upstreamSha ?? "commit-1";
44+ const treeSha = input.treeSha ?? "tree-1";
45+ const blobBySha = new Map<string, { path: string; content: string }>();
46+ let index = 0;
47+ for (const [path, content] of input.files) {
48+ blobBySha.set(`blob-${++index}`, { path, content });
49+ }
50+ let blobCalls = 0;
51+ const fetch = (async (rawUrl: string) => {
52+ const url = new URL(rawUrl);
53+ if (url.pathname === repoPath) return json({ default_branch: "main" });
54+ if (url.pathname === `${repoPath}/git/ref/heads%2Fmain`) {
55+ return json({ object: { sha: upstreamSha } });
56+ }
57+ if (url.pathname === `${repoPath}/git/commits/${upstreamSha}`) {
58+ return json({ sha: upstreamSha, tree: { sha: treeSha } });
59+ }
60+ if (url.pathname === `${repoPath}/git/trees/${treeSha}`) {
61+ return json({
62+ tree: [...blobBySha.entries()].map(([sha, blob]) => ({
63+ path: blob.path,
64+ type: "blob",
65+ sha,
66+ size: Buffer.byteLength(blob.content, "utf8"),
67+ })),
68+ });
69+ }
70+ const blobPrefix = `${repoPath}/git/blobs/`;
71+ if (url.pathname.startsWith(blobPrefix)) {
72+ blobCalls++;
73+ const sha = decodeURIComponent(url.pathname.slice(blobPrefix.length));
74+ const blob = blobBySha.get(sha);
75+ if (!blob) return json({ message: "not found" }, 404);
76+ return json({
77+ encoding: "base64",
78+ content: Buffer.from(blob.content, "utf8").toString("base64"),
79+ });
80+ }
81+ return json({ message: "not found" }, 404);
82+ }) as typeof globalThis.fetch;
83+ return {
84+ fetch,
85+ blobCalls: () => blobCalls,
86+ };
87+};
88+
89+describe("GitHub custom-tools source", () => {
90+ it("fetches a repo fileset and publishes provenance, description, and skipped entries", async () => {
91+ const files = new Map<string, string>([
92+ [
93+ "executor.json",
94+ JSON.stringify({
95+ $schema: "https://example.test/schema",
96+ description: "Acme tools",
97+ ignored: true,
98+ }),
99+ ],
100+ ["tools/hello.ts", toolSource("hello")],
101+ ["workflows/deferred.ts", "export default {};"],
102+ ["docs/readme.md", "ignored"],
103+ ]);
104+ const github = makeGitHubFetch({ files, upstreamSha: "commit-a" });
105+ const snapshot = await run(fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }));
106+ expect([...snapshot.files.keys()].sort()).toEqual([
107+ "executor.json",
108+ "tools/hello.ts",
109+ "workflows/deferred.ts",
110+ ]);
111+ expect(snapshot.description).toBe("Acme tools");
112+
113+ const host = makeRuntime();
114+ const result = await run(
115+ syncGitHubSource({
116+ runtime: host.runtime,
117+ scope: "githubTools",
118+ repo: "acme/tools",
119+ fetch: github.fetch,
120+ }),
121+ );
122+ expect(result.status).toBe("published");
123+ expect(result.tools).toEqual(["hello"]);
124+ expect(result.skipped).toEqual([
125+ { path: "workflows/deferred.ts", reason: "not supported yet" },
126+ ]);
127+ const descriptor = await run(host.runtime.getDescriptor("githubTools"));
128+ expect(descriptor?.description).toBe("Acme tools");
129+ expect(descriptor?.source).toEqual({
130+ kind: "github",
131+ repo: "acme/tools",
132+ ref: "main",
133+ upstreamSha: "commit-a",
134+ });
135+ await host.close();
136+ });
137+
138+ it("reports up-to-date when the upstream commit SHA is unchanged", async () => {
139+ const github = makeGitHubFetch({
140+ files: new Map([["tools/hello.ts", toolSource("hello")]]),
141+ upstreamSha: "same-sha",
142+ });
143+ const host = makeRuntime();
144+ const first = await run(
145+ syncGitHubSource({
146+ runtime: host.runtime,
147+ scope: "githubTools",
148+ repo: "acme/tools",
149+ fetch: github.fetch,
150+ }),
151+ );
152+ const second = await run(
153+ syncGitHubSource({
154+ runtime: host.runtime,
155+ scope: "githubTools",
156+ repo: "acme/tools",
157+ fetch: github.fetch,
158+ }),
159+ );
160+ expect(first.status).toBe("published");
161+ expect(second).toEqual({
162+ status: "up-to-date",
163+ upstreamSha: "same-sha",
164+ tools: ["hello"],
165+ skipped: [],
166+ });
167+ await host.close();
168+ });
169+
170+ it("rejects oversized trees before fetching blobs", async () => {
171+ const files = new Map<string, string>();
172+ for (let i = 0; i < PUBLISH_LIMITS.maxFiles + 1; i++) {
173+ files.set(`tools/t${i}.ts`, toolSource(`t${i}`));
174+ }
175+ const github = makeGitHubFetch({ files });
176+ const exit = await Effect.runPromiseExit(
177+ fetchGitHubSource({ repo: "acme/tools", fetch: github.fetch }),
178+ );
179+ expect(exit._tag).toBe("Failure");
180+ expect(JSON.stringify(exit)).toContain("exceeding the limit");
181+ expect(github.blobCalls()).toBe(0);
182+ });
183+
184+ it("returns typed failure data for publish errors", async () => {
185+ const github = makeGitHubFetch({
186+ files: new Map([
187+ [
188+ "tools/bad.ts",
189+ `import { defineTool } from "executor:app";
190+import { chunk } from "lodash";
191+export default defineTool({ description: "bad", input: { type: "object" }, async handler(){ return chunk([1], 1); } });`,
192+ ],
193+ ]),
194+ upstreamSha: "bad-sha",
195+ });
196+ const host = makeRuntime();
197+ const result = await run(
198+ syncGitHubSource({
199+ runtime: host.runtime,
200+ scope: "githubTools",
201+ repo: "acme/tools",
202+ fetch: github.fetch,
203+ }),
204+ );
205+ expect(result.status).toBe("failed");
206+ expect(result.upstreamSha).toBe("bad-sha");
207+ expect(result.errors?.[0]?.stage).toBe("bundle");
208+ expect(result.errors?.[0]?.message).toContain('bare import "lodash" is not allowed');
209+ await host.close();
210+ });
211+});