diff --git a/bobbin/worker/src/index.ts b/bobbin/worker/src/index.ts index 4dc9e16da..097bbecdb 100644 --- a/bobbin/worker/src/index.ts +++ b/bobbin/worker/src/index.ts @@ -8,7 +8,7 @@ export interface Env { RATE_LIMITER: RateLimit; BOBBIN_HYDRANT_URL: string; BOBBIN_SLINGSHOT_URL: string; - BOBBIN_MIRROR_URL?: string; + BOBBIN_MIRROR_URL: string; BOBBIN_MIRROR_V2_URL?: string; BOBBIN_SERVICE_DID?: string; BOBBIN_LOG: string; @@ -92,7 +92,7 @@ export class BobbinContainer extends Container { envVars: { BOBBIN_HYDRANT_URL: env.BOBBIN_HYDRANT_URL, BOBBIN_SLINGSHOT_URL: env.BOBBIN_SLINGSHOT_URL, - ...(env.BOBBIN_MIRROR_URL ? { BOBBIN_MIRROR_URL: env.BOBBIN_MIRROR_URL } : {}), + BOBBIN_MIRROR_URL: env.BOBBIN_MIRROR_URL, ...(env.BOBBIN_MIRROR_V2_URL ? { BOBBIN_MIRROR_V2_URL: env.BOBBIN_MIRROR_V2_URL } : {}), ...(env.BOBBIN_SERVICE_DID ? { BOBBIN_SERVICE_DID: env.BOBBIN_SERVICE_DID } : {}), BOBBIN_LOG: env.BOBBIN_LOG, diff --git a/bobbin/worker/wrangler.dev.jsonc b/bobbin/worker/wrangler.dev.jsonc index 5a74dc9d5..a7232aa90 100644 --- a/bobbin/worker/wrangler.dev.jsonc +++ b/bobbin/worker/wrangler.dev.jsonc @@ -54,6 +54,7 @@ "vars": { "BOBBIN_HYDRANT_URL": "https://bobbin-svfe-dev.anirudh-s-account.workers.dev", "BOBBIN_SLINGSHOT_URL": "https://bobbin-svfe-dev.anirudh-s-account.workers.dev", + "BOBBIN_MIRROR_URL": "https://mirror-fsn.tangled.network", "BOBBIN_MIRROR_V2_URL": "https://mirror-fsn.tangled.network", "BOBBIN_SERVICE_DID": "did:web:next.tangled.org", "BOBBIN_LOG": "info", diff --git a/web/src/lib/api/gitclient.test.ts b/web/src/lib/api/gitclient.test.ts index 855bd041b..33aeabaf7 100644 --- a/web/src/lib/api/gitclient.test.ts +++ b/web/src/lib/api/gitclient.test.ts @@ -3,6 +3,7 @@ import { blobRawUrl, branches, gitTarget, + knotMirrorTarget, log, resolveDefaultBranch, tag, @@ -43,6 +44,13 @@ describe("gitTarget", () => { expect(target.repo).toBe(repo.uri); expect(target.via).toBe("bobbin"); }); + + it("can require the mirror without a bobbin fallback", () => { + expect(() => + knotMirrorTarget({ ...config, knotMirrorUrl: "" }, repo, globalThis.fetch) + ).toThrow("No knot mirror is configured"); + expect(knotMirrorTarget(config, repo, globalThis.fetch).via).toBe("mirror"); + }); }); describe("resolveDefaultBranch", () => { diff --git a/web/src/lib/api/gitclient.ts b/web/src/lib/api/gitclient.ts index 82092f732..0425731e9 100644 --- a/web/src/lib/api/gitclient.ts +++ b/web/src/lib/api/gitclient.ts @@ -31,17 +31,26 @@ export interface GitTarget { via: "mirror" | "bobbin"; } +export const knotMirrorTarget = ( + config: GitServiceConfig, + repo: { repoDid: string }, + fetch: typeof globalThis.fetch +): GitTarget => { + if (!config.knotMirrorUrl) throw new Error("No knot mirror is configured"); + return { + ctx: createKnotMirrorClient(config.knotMirrorUrl, fetch), + repo: repo.repoDid as Did, + via: "mirror" + }; +}; + export const gitTarget = ( config: GitServiceConfig, repo: { uri: string; repoDid?: string }, fetch: typeof globalThis.fetch ): GitTarget => config.knotMirrorUrl && repo.repoDid - ? { - ctx: createKnotMirrorClient(config.knotMirrorUrl, fetch), - repo: repo.repoDid as Did, - via: "mirror" - } + ? knotMirrorTarget(config, { repoDid: repo.repoDid }, fetch) : { ctx: createBobbinClient({ serviceUrl: config.bobbinUrl, fetch }), repo: repo.uri as ResourceUri, diff --git a/web/src/lib/api/gitmirror.test.ts b/web/src/lib/api/gitmirror.test.ts index 0af23185b..7aca650f5 100644 --- a/web/src/lib/api/gitmirror.test.ts +++ b/web/src/lib/api/gitmirror.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "./client"; import { + commitAtRef, diffFileKind, diffFileName, diffFileStat, listCommits, + toMirrorCommitDetail, toMirrorCommitSummary, type MirrorCommit, type MirrorFileDiff @@ -51,6 +53,33 @@ describe("gitmirror.listCommits", () => { }); }); +describe("gitmirror.commitAtRef", () => { + it("recovers the exact hash when commit-time ordering returns an ancestor first", async () => { + const wanted = "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ commits: [commit("1".repeat(40))] }), { + headers: { "content-type": "application/json" } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ commits: [commit(wanted)] }), { + headers: { "content-type": "application/json" } + }) + ); + const ctx = createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); + + await expect(commitAtRef(ctx, "did:plc:repo" as never, wanted)).resolves.toMatchObject({ + oid: wanted + }); + + const retry = new URL(String(fetchMock.mock.calls[1][0])); + expect(retry.searchParams.getAll("ranges")).toEqual([`${wanted}^..${wanted}`]); + expect(retry.searchParams.get("limit")).toBe("100"); + }); +}); + describe("gitmirror.toMirrorCommitSummary", () => { it("splits the message and picks up the change id", () => { const summary = toMirrorCommitSummary( @@ -77,6 +106,32 @@ describe("gitmirror.toMirrorCommitSummary", () => { }); }); +describe("gitmirror.toMirrorCommitDetail", () => { + it("maps signatures and commit trailers for the commit header", () => { + const detail = toMirrorCommitDetail( + commit("9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", { + author: { + name: "alice", + email: "did:plc:alice", + when: "2025-09-22T10:40:35+09:00" + }, + message: "subject\n\nbody\n\nCo-authored-by: Bob " + }) + ); + + expect(detail).toMatchObject({ + hash: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", + subject: "subject", + body: "body\n\nCo-authored-by: Bob ", + authorDid: "did:plc:alice", + authorWhen: "2025-09-22T10:40:35+09:00", + committerName: "alice", + committerWhen: "2025-09-22T10:44:00+09:00", + coAuthors: [{ name: "Bob", email: "bob@example.com" }] + }); + }); +}); + const src = (path: string, extra: Partial = {}) => ({ path, oid: "oid", diff --git a/web/src/lib/api/gitmirror.ts b/web/src/lib/api/gitmirror.ts index 900245a68..45ea6d37a 100644 --- a/web/src/lib/api/gitmirror.ts +++ b/web/src/lib/api/gitmirror.ts @@ -1,5 +1,12 @@ import { jsonGet } from "./_request"; -import { splitMessage, type CommitSummary } from "./repo"; +import { + coAuthorsFrom, + didFromSignature, + FULL_HASH_RE, + splitMessage, + type CommitDetail, + type CommitSummary +} from "./repo"; import type { Did } from "@atcute/lexicons/syntax"; import type { BobbinContext, XrpcRequestInit } from "./client"; import type { Revspec } from "./pullRoute"; @@ -24,6 +31,21 @@ export const listCommits = ( init?: XrpcRequestInit ) => jsonGet(ctx, LIST_COMMITS_NSID, params, init); +export const commitAtRef = async ( + ctx: BobbinContext, + repo: Did, + ref: string, + init?: XrpcRequestInit +): Promise => { + const page = await listCommits(ctx, { repo, ranges: [ref], limit: 1 }, init); + const first = page.commits[0]; + if (!first || !FULL_HASH_RE.test(ref) || first.oid.toLowerCase() === ref.toLowerCase()) { + return first; + } + const exact = await listCommits(ctx, { repo, ranges: [`${ref}^..${ref}`], limit: 100 }, init); + return exact.commits.find((commit) => commit.oid.toLowerCase() === ref.toLowerCase()); +}; + const LOG_LIMIT = 100; // every commit a pull version brought, newest first @@ -92,6 +114,9 @@ export const diffFileStat = (file: MirrorFileDiff): { insertions: number; deleti return { insertions, deletions }; }; +export const mirrorChangeId = (commit: MirrorCommit): string | undefined => + commit.extraHeaders.find((header) => header.key === "change-id")?.value; + export const toMirrorCommitSummary = (commit: MirrorCommit): CommitSummary => { const [subject, body] = splitMessage(commit.message); return { @@ -104,7 +129,28 @@ export const toMirrorCommitSummary = (commit: MirrorCommit): CommitSummary => { when: commit.committer.when ?? commit.author.when, // the pull page links a commit row as `firstParent..commit` parent: commit.parents[0], - // jj stashes the change id in a commit header, same lookup the appview does - changeId: commit.extraHeaders.find((header) => header.key === "change-id")?.value + changeId: mirrorChangeId(commit) + }; +}; + +export const toMirrorCommitDetail = (commit: MirrorCommit): CommitDetail => { + const [subject, body] = splitMessage(commit.message); + const committerWhen = commit.committer.when ?? ""; + return { + hash: commit.oid, + shortHash: commit.oid.slice(0, 8), + subject, + body, + authorName: commit.author.name, + authorEmail: commit.author.email, + authorDid: didFromSignature(commit.author.email), + authorWhen: commit.author.when ?? "", + committerName: commit.committer.name, + committerEmail: commit.committer.email, + committerWhen, + coAuthors: coAuthorsFrom(commit.message, committerWhen).map(({ Name, Email }) => ({ + name: Name, + email: Email + })) }; }; diff --git a/web/src/lib/components/repo/CommitView.svelte b/web/src/lib/components/repo/CommitView.svelte index 68cfdfe86..c330e5f2c 100644 --- a/web/src/lib/components/repo/CommitView.svelte +++ b/web/src/lib/components/repo/CommitView.svelte @@ -1,10 +1,12 @@
@@ -70,6 +80,24 @@ {/if}
+ {#if downloadUrls} + + {/if}