diff --git a/web/src/fixtures/pull.ts b/web/src/fixtures/pull.ts index d22284cc0..8241a9c3f 100644 --- a/web/src/fixtures/pull.ts +++ b/web/src/fixtures/pull.ts @@ -305,7 +305,7 @@ export const mockPullFetch = (options: PullFetchOptions = {}): typeof fetch => return notFound(); } - case "sh.tangled.git.temp.getBranch": + case "org.tangled.temp.git.getBranch": return json({ reference: { name: param("name") }, hash: @@ -314,7 +314,7 @@ export const mockPullFetch = (options: PullFetchOptions = {}): typeof fetch => : targetHead }); - case "sh.tangled.git.temp.getMergeBase": + case "org.tangled.temp.git.getMergeBase": return json({ commit: base }); case "sh.tangled.git.temp2.mergeCheck": @@ -358,7 +358,7 @@ export const mockPullFetch = (options: PullFetchOptions = {}): typeof fetch => }); } - case "sh.tangled.git.temp.getEntry": { + case "org.tangled.temp.git.getEntry": { const file = files.find( (entry) => entry.path === param("path") || entry.oldPath === param("path") ); diff --git a/web/src/lib/api/gitclient.test.ts b/web/src/lib/api/gitclient.test.ts index eac12ba24..c1fb07fdd 100644 --- a/web/src/lib/api/gitclient.test.ts +++ b/web/src/lib/api/gitclient.test.ts @@ -129,15 +129,16 @@ describe("endpoint routing", () => { expect(url.searchParams.get("cursor")).toBe("abc"); }); - it("sends tree to the mirror's getTree with the path param", async () => { + it("sends tree to bobbin's getTree with the path param", async () => { const fetchMock = vi .fn() - .mockResolvedValue(jsonResponse({ files: [] })); + .mockResolvedValue(jsonResponse({ entries: [] })); await tree(mirrorTarget(fetchMock), { ref: "main", path: "src/lib" }); const url = calledUrl(fetchMock); - expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.getTree"); + expect(url.origin).toBe("https://bobbin.example"); + expect(url.pathname).toBe("/xrpc/org.tangled.temp.git.getTree"); expect(url.searchParams.get("repo")).toBe(repo.repoDid); expect(url.searchParams.get("path")).toBe("src/lib"); }); diff --git a/web/src/lib/api/gitclient.ts b/web/src/lib/api/gitclient.ts index fae652e0d..b4da08f10 100644 --- a/web/src/lib/api/gitclient.ts +++ b/web/src/lib/api/gitclient.ts @@ -27,6 +27,7 @@ export interface GitServiceConfig { export interface GitTarget { ctx: BobbinContext; + bobbin: BobbinContext; repo: Did | ResourceUri; via: "mirror" | "bobbin"; } @@ -39,6 +40,7 @@ export const knotMirrorTarget = ( if (!config.knotMirrorUrl) throw new Error("No knot mirror is configured"); return { ctx: createKnotMirrorClient(config.knotMirrorUrl, fetch), + bobbin: createBobbinClient({ serviceUrl: config.bobbinUrl, fetch }), repo: repo.repoDid as Did, via: "mirror" }; @@ -48,14 +50,13 @@ export const gitTarget = ( config: GitServiceConfig, repo: { uri: string; repoDid?: string }, fetch: typeof globalThis.fetch -): GitTarget => - config.knotMirrorUrl && repo.repoDid - ? knotMirrorTarget(config, { repoDid: repo.repoDid }, fetch) - : { - ctx: createBobbinClient({ serviceUrl: config.bobbinUrl, fetch }), - repo: repo.uri as ResourceUri, - via: "bobbin" - }; +): GitTarget => { + if (config.knotMirrorUrl && repo.repoDid) { + return knotMirrorTarget(config, { repoDid: repo.repoDid }, fetch); + } + const bobbin = createBobbinClient({ serviceUrl: config.bobbinUrl, fetch }); + return { ctx: bobbin, bobbin, repo: repo.uri as ResourceUri, via: "bobbin" }; +}; // the mirror has no getDefaultBranch, the default falls out of the branch // list's is_default flag instead @@ -83,7 +84,7 @@ export const resolveDefaultBranch = ( export const tree = (target: GitTarget, params: { ref: string; path?: string }) => target.via === "mirror" - ? knotMirrorTree(target.ctx, { repo: target.repo, ...params }) + ? knotMirrorTree(target.bobbin, target.ctx, { repo: target.repo, ...params }) : knotTree(target.ctx, { repo: target.repo, ...params }); export const log = (target: GitTarget, params: { ref: string; limit: number; cursor?: string }) => @@ -118,7 +119,7 @@ export const languages = ( // past it export const blob = (target: GitTarget, params: { ref: string; path: string }, maxBytes: number) => target.via === "mirror" - ? knotMirrorBlob(target.ctx, { repo: target.repo, ...params }, maxBytes) + ? knotMirrorBlob(target.bobbin, target.ctx, { repo: target.repo, ...params }, maxBytes) : knotBlob(target.ctx, { repo: target.repo as ResourceUri, ...params }); export const blobRawUrl = (target: GitTarget, params: { ref: string; path: string }): string => diff --git a/web/src/lib/api/knotmirror.test.ts b/web/src/lib/api/knotmirror.test.ts index 6cbbd1f68..68dd58796 100644 --- a/web/src/lib/api/knotmirror.test.ts +++ b/web/src/lib/api/knotmirror.test.ts @@ -13,16 +13,24 @@ describe("knotmirror.tree", () => { const url = new URL(String(input)); if (url.pathname.endsWith("getTree")) { return jsonResponse({ - ref: "main", - files: [ + entries: [ { name: "README.md", - mode: "100644", + mode: "0100644", size: 12, - last_commit: { + lastCommit: { hash: "abc", message: "docs", - when: "2026-07-01T00:00:00Z" + author: { + name: "dawn", + email: "dawn@tangled.org", + when: "2026-07-01T00:00:00Z" + }, + committer: { + name: "dawn", + email: "dawn@tangled.org", + when: "2026-07-02T00:00:00Z" + } } } ] @@ -34,17 +42,25 @@ describe("knotmirror.tree", () => { }); }); + const bobbin = knotmirror.createKnotMirrorClient("https://bobbin.test", fetchMock); const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); - const tree = await knotmirror.tree(ctx, { + const tree = await knotmirror.tree(bobbin, ctx, { repo: "did:plc:repo", ref: "main", path: "docs" }); expect(tree.readme).toEqual({ filename: "README.md", contents: "# hello" }); - expect(tree.files[0]).toMatchObject({ name: "README.md", mode: "100644" }); + expect(tree.files[0]).toMatchObject({ name: "README.md", mode: "0100644" }); + expect(tree.ref).toBe("main"); + expect(tree.files[0].last_commit?.when).toBe("2026-07-02T00:00:00Z"); expect(fetchMock).toHaveBeenCalledTimes(2); + + const treeUrl = new URL(String(fetchMock.mock.calls[0][0])); + expect(treeUrl.origin).toBe("https://bobbin.test"); + expect(treeUrl.pathname).toBe("/xrpc/org.tangled.temp.git.getTree"); const blobUrl = new URL(String(fetchMock.mock.calls[1][0])); + expect(blobUrl.origin).toBe("https://mirror.test"); expect(blobUrl.pathname).toBe("/xrpc/sh.tangled.git.temp.getBlob"); expect(blobUrl.searchParams.get("path")).toBe("docs/README.md"); }); @@ -88,9 +104,11 @@ describe("knotmirror.blob", () => { it("creates the repo blob shape from getEntry plus raw content", async () => { const fetchMock = blobFetch(new TextEncoder().encode("hello")); + const bobbin = knotmirror.createKnotMirrorClient("https://bobbin.test", fetchMock); const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); const output = await knotmirror.blob( + bobbin, ctx, { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 1024 @@ -104,6 +122,12 @@ describe("knotmirror.blob", () => { content: "hello", lastCommit: { hash: "abc", when: "2026-07-01T00:00:00Z" } }); + const entryUrl = new URL(String(fetchMock.mock.calls[0][0])); + expect(entryUrl.origin).toBe("https://bobbin.test"); + expect(entryUrl.pathname).toBe("/xrpc/org.tangled.temp.git.getEntry"); + const blobUrl = new URL(String(fetchMock.mock.calls[1][0])); + expect(blobUrl.origin).toBe("https://mirror.test"); + expect(blobUrl.pathname).toBe("/xrpc/sh.tangled.git.temp.getBlob"); }); it("skips the content fetch when the entry is over the inline cap", async () => { @@ -111,9 +135,11 @@ describe("knotmirror.blob", () => { ...entryBody, size: 2048 }); + const bobbin = knotmirror.createKnotMirrorClient("https://bobbin.test", fetchMock); const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); const output = await knotmirror.blob( + bobbin, ctx, { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 1024 @@ -126,9 +152,11 @@ describe("knotmirror.blob", () => { it("marks content with null bytes as binary", async () => { const fetchMock = blobFetch(new Uint8Array([0x50, 0x4b, 0x00, 0x00])); + const bobbin = knotmirror.createKnotMirrorClient("https://bobbin.test", fetchMock); const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); const output = await knotmirror.blob( + bobbin, ctx, { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 1024 @@ -140,9 +168,11 @@ describe("knotmirror.blob", () => { it("treats a gitlink mode as a submodule", async () => { const fetchMock = blobFetch(new Uint8Array(), { ...entryBody, mode: "0160000" }); + const bobbin = knotmirror.createKnotMirrorClient("https://bobbin.test", fetchMock); const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); const output = await knotmirror.blob( + bobbin, ctx, { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 1024 diff --git a/web/src/lib/api/knotmirror.ts b/web/src/lib/api/knotmirror.ts index 090fae2a1..2889b7e36 100644 --- a/web/src/lib/api/knotmirror.ts +++ b/web/src/lib/api/knotmirror.ts @@ -3,7 +3,10 @@ import { createBobbinClient } from "$lib/api/client"; import { buildUrl, jsonGet, rawGet } from "$lib/api/_request"; import type { RepoTagResponse } from "$lib/api/repo"; import type * as RepoBlob from "$lib/api/lexicons/types/sh/tangled/repo/blob"; -import type * as LegacyTree from "$lib/api/lexicons/types/sh/tangled/git/temp/getTree"; +import type * as GitDefs from "$lib/api/lexicons/types/sh/tangled/git/temp/defs"; +import type * as MirrorBranch from "$lib/api/lexicons/types/org/tangled/temp/git/getBranch"; +import type * as MirrorEntry from "$lib/api/lexicons/types/org/tangled/temp/git/getEntry"; +import type * as MirrorTree from "$lib/api/lexicons/types/org/tangled/temp/git/getTree"; import type * as Tree from "$lib/api/lexicons/types/sh/tangled/repo/tree"; import { treeEntryKind, @@ -12,8 +15,11 @@ import { type TagsResponse } from "$lib/api/repo"; -const TREE_NSID = "sh.tangled.git.temp.getTree"; -const ENTRY_NSID = "sh.tangled.git.temp.getEntry"; +const TREE_NSID = "org.tangled.temp.git.getTree"; +const ENTRY_NSID = "org.tangled.temp.git.getEntry"; +const MERGE_BASE_NSID = "org.tangled.temp.git.getMergeBase"; +const GET_BRANCH_NSID = "org.tangled.temp.git.getBranch"; + const BLOB_NSID = "sh.tangled.git.temp.getBlob"; const FORMAT_PATCH_NSID = "sh.tangled.git.temp.formatPatch"; const LOG_NSID = "sh.tangled.git.temp.listCommits"; @@ -21,8 +27,6 @@ const BRANCHES_NSID = "sh.tangled.git.temp.listBranches"; const TAGS_NSID = "sh.tangled.git.temp.listTags"; const LANGUAGES_NSID = "sh.tangled.git.temp.listLanguages"; const GET_TAG_NSID = "sh.tangled.git.temp.getTag"; -const MERGE_BASE_NSID = "sh.tangled.git.temp.getMergeBase"; -const GET_BRANCH_NSID = "sh.tangled.git.temp.getBranch"; const MAX_README_BYTES = 1 << 20; @@ -35,24 +39,11 @@ export interface KnotMirrorLanguagesResponse { export const createKnotMirrorClient = (serviceUrl: string, fetch: typeof globalThis.fetch) => createBobbinClient({ serviceUrl, fetch }); -export interface KnotMirrorEntry { - name: string; - mode: string; - oid?: string; - size?: number; - lastCommit?: { - hash: string; - message?: string; - author?: { name: string; email: string; when: string }; - committer?: { name: string; email: string; when: string }; - }; -} - export const entry = ( - ctx: BobbinContext, + bobbin: BobbinContext, params: { repo: string; ref: string; path: string }, init?: XrpcRequestInit -) => jsonGet(ctx, ENTRY_NSID, params, init); +) => jsonGet(bobbin, ENTRY_NSID, params, init); export const blobRawUrl = ( serviceUrl: string, @@ -67,27 +58,54 @@ export const formatPatchUrl = ( params: { repo: string; from: string; to: string } ): string => buildUrl(serviceUrl, FORMAT_PATCH_NSID, params).toString(); +type LastCommit = { + hash: string; + message: string; + when: string; + author?: { name: string; email: string; when: string }; +}; + +const normalizeCommit = (commit: GitDefs.Commit | undefined): LastCommit | undefined => + commit + ? { + hash: commit.hash, + message: commit.message, + when: commit.committer?.when ?? commit.author?.when ?? "", + author: commit.author + ? { + name: commit.author.name, + email: commit.author.email, + when: commit.author.when + } + : undefined + } + : undefined; + +const normalizeTree = (tree: MirrorTree.$output, ref: string): Tree.$output => ({ + ref, + lastCommit: normalizeCommit(tree.lastCommit), + files: tree.entries.map((entry) => ({ + name: entry.name, + mode: entry.mode, + size: entry.size, + last_commit: normalizeCommit(entry.lastCommit) + })) +}); + // same heuristic as git: a null byte in the first chunk means binary const looksBinary = (bytes: Uint8Array): boolean => bytes.subarray(0, 8000).includes(0); // temp.getBlob answers raw bytes only, metadata comes from getEntry and // the binary flag from a content sniff export const blob = async ( + bobbin: BobbinContext, ctx: BobbinContext, params: { repo: string; ref: string; path: string }, maxBytes: number, init?: XrpcRequestInit ): Promise => { - const meta = await entry(ctx, params, init); - - const lastCommit = meta.lastCommit - ? { - hash: meta.lastCommit.hash, - message: meta.lastCommit.message ?? "", - when: meta.lastCommit.committer?.when ?? meta.lastCommit.author?.when ?? "", - author: meta.lastCommit.author - } - : undefined; + const meta = await entry(bobbin, params, init); + const lastCommit = normalizeCommit(meta.lastCommit); // a gitlink is a submodule pointer, not a file if (treeEntryKind(meta.mode) === "submodule") { @@ -131,34 +149,6 @@ export const blob = async ( }; }; -const normalizeSignature = ( - signature: LegacyTree.Signature | undefined -): Tree.Signature | undefined => - signature ? { name: signature.name, email: signature.email, when: signature.when } : undefined; - -const normalizeCommit = (commit: LegacyTree.LastCommit | undefined): Tree.LastCommit | undefined => - commit - ? { - hash: commit.hash, - message: commit.message, - when: commit.when, - author: normalizeSignature(commit.author) - } - : undefined; - -const normalizeTree = (tree: LegacyTree.$output): Tree.$output => ({ - ref: tree.ref, - parent: tree.parent, - dotdot: tree.dotdot, - lastCommit: normalizeCommit(tree.lastCommit), - files: tree.files.map((entry) => ({ - name: entry.name, - mode: entry.mode, - size: entry.size, - last_commit: normalizeCommit(entry.last_commit) - })) -}); - const isReadmeFile = (name: string, mode: string): boolean => /^readme(?:\.[^.]+)?$/i.test(name) && treeEntryKind(mode) === "file"; @@ -166,13 +156,14 @@ const pathInTree = (path: string | undefined, name: string): string => path ? `${path}/${name}` : name; export const tree = async ( + bobbin: BobbinContext, ctx: BobbinContext, params: { repo: string; ref: string; path?: string }, init?: XrpcRequestInit ): Promise => { - const response = await jsonGet(ctx, TREE_NSID, params, init); - const result = normalizeTree(response); - const readme = response.files.find((entry) => isReadmeFile(entry.name, entry.mode)); + const response = await jsonGet(bobbin, TREE_NSID, params, init); + const result = normalizeTree(response, params.ref); + const readme = response.entries.find((entry) => isReadmeFile(entry.name, entry.mode)); if (!readme) return result; if (readme.size !== undefined && readme.size > MAX_README_BYTES) return result; @@ -220,21 +211,13 @@ export const languages = ( ) => jsonGet(ctx, LANGUAGES_NSID, params, init); export const getMergeBase = ( - ctx: BobbinContext, + bobbin: BobbinContext, params: { repo: string; base: string; head: string }, init?: XrpcRequestInit -) => jsonGet<{ commit: string }>(ctx, MERGE_BASE_NSID, params, init); - -export interface KnotMirrorBranch { - name: string; - hash: string; - when: string; - message?: string; - author?: { name: string; email: string; when: string }; -} +) => jsonGet<{ commit: string }>(bobbin, MERGE_BASE_NSID, params, init); export const getBranch = ( - ctx: BobbinContext, + bobbin: BobbinContext, params: { repo: string; name: string }, init?: XrpcRequestInit -) => jsonGet(ctx, GET_BRANCH_NSID, params, init); +) => jsonGet(bobbin, GET_BRANCH_NSID, params, init); diff --git a/web/src/lib/api/pull.ts b/web/src/lib/api/pull.ts index 986491ac7..eaf5f319f 100644 --- a/web/src/lib/api/pull.ts +++ b/web/src/lib/api/pull.ts @@ -187,8 +187,8 @@ export const mergeCommit = ( export const deleteBranch = ( agent: OAuthUserAgent, - mirror: string, + bobbin: string, knot: string, input: { repo: string; branch: string }, fetch: typeof globalThis.fetch = globalThis.fetch -): Promise => mirrorPush(agent, mirror, knot, DELETE_BRANCH_NSID, input, fetch); +): Promise => mirrorPush(agent, bobbin, knot, DELETE_BRANCH_NSID, input, fetch); diff --git a/web/src/lib/api/pullCompose.ts b/web/src/lib/api/pullCompose.ts index b6366ca7d..0b0633f63 100644 --- a/web/src/lib/api/pullCompose.ts +++ b/web/src/lib/api/pullCompose.ts @@ -193,7 +193,6 @@ export const loadCompose = async (args: { let commitsError = ""; let mergeBase = ""; if (hasComparison && source !== "patch") { - const mirror = createBobbinClient({ serviceUrl: config.knotMirrorUrl, fetch: fetchFn }); try { const [page, base] = await Promise.all([ listCommits(ctx, { @@ -202,7 +201,7 @@ export const loadCompose = async (args: { limit: COMMIT_LIMIT, includeStats: true }), - getMergeBase(mirror, { + getMergeBase(ctx, { repo: sourceRepoDid, base: targetBranch, head: sourceBranch diff --git a/web/src/lib/components/repo/pulls/PullCompose.svelte b/web/src/lib/components/repo/pulls/PullCompose.svelte index d0561d152..a023cd515 100644 --- a/web/src/lib/components/repo/pulls/PullCompose.svelte +++ b/web/src/lib/components/repo/pulls/PullCompose.svelte @@ -262,24 +262,20 @@ if (view.sourceRepoDid === repoDid && view.sourceBranch === view.targetBranch) { throw new Error("Source and target branch must be different."); } - if (!knotMirrorUrl) { - throw new Error("No knot mirror is configured, so branch refs can't be resolved."); - } - const agent = await auth.agentFor(acting.did as Did); // keepCommit pins against the record's at-uri, so the rkey is minted first const rkey = tidNow(); const uri = `at://${agent.sub}/sh.tangled.repo.pull/${rkey}`; - const mirror = createBobbinClient({ serviceUrl: knotMirrorUrl, fetch: globalThis.fetch }); + const bobbin = createBobbinClient({ serviceUrl: bobbinUrl, fetch: globalThis.fetch }); const [mergeBase, head] = await Promise.all([ - getMergeBase(mirror, { + getMergeBase(bobbin, { repo: view.sourceRepoDid, base: view.targetBranch, head: view.sourceBranch }), - getBranch(mirror, { repo: view.sourceRepoDid, name: view.sourceBranch }) + getBranch(bobbin, { repo: view.sourceRepoDid, name: view.sourceBranch }) ]); // pin before the record lands, so gc can't drop the head in between diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte index 1dddbdb2b..4ced9f2bd 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte @@ -169,16 +169,12 @@ const check = createLoad(async () => { if (pullState !== "open" || !data.repo.repoDid || !head) return undefined; - const mirror = createBobbinClient({ - serviceUrl: data.publicConfig.knotMirrorUrl, - fetch: globalThis.fetch - }); const bobbin = createBobbinClient({ serviceUrl: data.publicConfig.bobbinUrl, fetch: globalThis.fetch }); // resolve target branch to raw commit ID - const target = await getBranch(mirror, { + const target = await getBranch(bobbin, { repo: data.repo.repoDid, name: pull.targetBranch }); @@ -263,13 +259,13 @@ ); const targetBranchLives = createLoad(async () => { - if (!browser || !data.repo.repoDid || !data.publicConfig.knotMirrorUrl) return false; - const mirror = createBobbinClient({ - serviceUrl: data.publicConfig.knotMirrorUrl, + if (!browser || !data.repo.repoDid) return false; + const bobbin = createBobbinClient({ + serviceUrl: data.publicConfig.bobbinUrl, fetch: globalThis.fetch }); try { - await getBranch(mirror, { repo: data.repo.repoDid, name: pull.targetBranch }); + await getBranch(bobbin, { repo: data.repo.repoDid, name: pull.targetBranch }); return true; } catch { return false; @@ -515,11 +511,11 @@ const doMerge = createAction(async () => { const agent = auth.agent; if (!agent || !head || !data.repo.repoDid) return; - const mirror = createBobbinClient({ - serviceUrl: data.publicConfig.knotMirrorUrl, + const bobbin = createBobbinClient({ + serviceUrl: data.publicConfig.bobbinUrl, fetch: globalThis.fetch }); - const branch = await getBranch(mirror, { + const branch = await getBranch(bobbin, { repo: data.repo.repoDid, name: pull.targetBranch }); @@ -544,7 +540,7 @@ const ctx = createBobbinClient({ serviceUrl: data.publicConfig.bobbinUrl }); return (await getRepoByRepoDid(ctx, data.sourceRepoDid)).value.knot; })(); - await deleteBranch(agent, data.publicConfig.knotMirrorUrl, sourceKnot, { + await deleteBranch(agent, data.publicConfig.bobbinUrl, sourceKnot, { repo: data.sourceRepoDid, branch: sourceBranch }); @@ -584,11 +580,11 @@ // the branch tip lives outside the record, so it has to be resolved live const sourceHead = createLoad(async () => { if (!(isAuthor || canPush) || !sourceBranch) return undefined; - const mirror = createBobbinClient({ - serviceUrl: data.publicConfig.knotMirrorUrl, + const bobbin = createBobbinClient({ + serviceUrl: data.publicConfig.bobbinUrl, fetch: globalThis.fetch }); - const branch = await getBranch(mirror, { + const branch = await getBranch(bobbin, { repo: data.sourceRepoDid, name: sourceBranch }).catch(() => undefined); @@ -607,11 +603,11 @@ const agent = auth.agent; const oid = sourceHead.data; if (!agent || !oid) return; - const mirror = createBobbinClient({ - serviceUrl: data.publicConfig.knotMirrorUrl, + const bobbin = createBobbinClient({ + serviceUrl: data.publicConfig.bobbinUrl, fetch: globalThis.fetch }); - const mergeBase = await getMergeBase(mirror, { + const mergeBase = await getMergeBase(bobbin, { repo: data.sourceRepoDid, base: pull.targetBranch, head: sourceBranch