// the fixture pull request the pull page stories share: ten commits over three
// versions, a two-file diff, and a fetch mock standing in for bobbin, the knot
// mirror and gitmirror, so a story can reach a rendered diff instead of an error.
import type { CommentView } from "$lib/components/comment/comments";
import type { CommitSummary } from "$lib/api/repo";
import type { MirrorFileDiff } from "$lib/api/gitmirror";
import type { Revspec } from "$lib/api/pullRoute";
export const bobbinUrl = "https://bobbin.example.test";
export const knotMirrorUrl = "https://mirror.example.test";
export const repoDid = "did:plc:core";
export const sourceRepoDid = "did:plc:alice-core";
export const pullRkey = "3mreview";
export const targetBranch = "sv-fe";
export const sourceBranch = "feature/notifs-ui";
export const pullAuthor = { did: "did:plc:anirudh", handle: "anirudh.fi" };
export const pullCollaborator = { did: "did:plc:oppi", handle: "oppi.tangled.sh" };
export const pullUri = `at://${pullAuthor.did}/sh.tangled.repo.pull/${pullRkey}`;
// the merge base every version was cut from, and the target branch tip
export const base = "ffffffffffffffffffffffffffffffffffffffff";
export const targetHead = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
const subjects = [
"mark notifications read on visit",
"move mention parsing",
"fan out mention notifications",
"remove unread toggle",
"fix notification links",
"load notifications from deliberi",
"ingest pull state collections",
"update hydrant filters",
"add subscription lookups",
"watch notification preferences"
];
const oid = (index: number) => index.toString(16).repeat(40).slice(0, 40);
// newest first, the order the pull page's commit rows expect
export const pullCommits: CommitSummary[] = subjects.map((subject, index) => ({
hash: oid(index),
shortHash: oid(index).slice(0, 8),
subject,
body: "",
authorName: index % 2 === 0 ? "anirudh" : "oppi",
authorEmail: index % 2 === 0 ? "anirudh@example.test" : "oppi@example.test",
when: `2026-08-${String(18 - index).padStart(2, "0")}T12:00:00Z`,
// the row below it, and the merge base under the oldest one
parent: index === subjects.length - 1 ? base : oid(index + 1),
changeId: `I${oid(index).slice(0, 12)}`,
commentCount: index === 0 ? 1 : index === 3 ? 1 : 0
}));
// each resubmit brought one more commit, so the heads walk the list backwards
export const pullVersions: (Revspec & { createdAt: string })[] = [
{ base, head: oid(2), createdAt: "2026-08-16T09:00:00Z" },
{ base, head: oid(1), createdAt: "2026-08-17T15:30:00Z" },
{ base, head: oid(0), createdAt: "2026-08-18T12:00:00Z" }
];
export const pullHead = pullVersions[pullVersions.length - 1]!.head;
const commentUri = (rkey: string, did: string) => `at://${did}/sh.tangled.feed.comment/${rkey}`;
const pinned = (oid: string) => ({ repo: sourceRepoDid, commit: oid }) as CommentView["embed"];
// two loose comments and two pinned to a commit, spread across the versions so
// the discussion shows its version headers and the range filter has something
// to hide
export const pullComments: CommentView[] = [
{
uri: commentUri("3mcone", pullCollaborator.did),
rkey: "3mcone",
authorDid: pullCollaborator.did,
authorHandle: pullCollaborator.handle,
createdAt: "2026-08-16T18:00:00Z",
body: "The fan-out looks right, but can the mention parser move first? It reads like two changes.",
bodyHtml:
"
The fan-out looks right, but can the mention parser move first? It reads like two changes.
"
},
{
uri: commentUri("3mctwo", pullAuthor.did),
rkey: "3mctwo",
authorDid: pullAuthor.did,
authorHandle: pullAuthor.handle,
createdAt: "2026-08-17T09:10:00Z",
body: "Split out, and the unread toggle is gone in v1.",
bodyHtml: "Split out, and the unread toggle is gone in v1.
"
},
{
uri: commentUri("3mcthree", pullCollaborator.did),
rkey: "3mcthree",
authorDid: pullCollaborator.did,
authorHandle: pullCollaborator.handle,
createdAt: "2026-08-17T19:45:00Z",
body: "This one drops the toggle without touching its preference key.",
bodyHtml: "This one drops the toggle without touching its preference key.
",
embed: pinned(oid(3))
},
{
uri: commentUri("3mcfour", pullCollaborator.did),
rkey: "3mcfour",
authorDid: pullCollaborator.did,
authorHandle: pullCollaborator.handle,
createdAt: "2026-08-18T14:20:00Z",
body: "Marking on visit is the part I wanted. Ship it.",
bodyHtml: "Marking on visit is the part I wanted. Ship it.
",
embed: pinned(oid(0))
}
];
// one fixture file: its two snapshots, and the commit row that changed it, so a
// narrowed commit range answers with the files that range really touches
interface FixtureFile {
path: string;
oldPath?: string;
row: number;
old?: string;
next?: string;
binary?: boolean;
}
const files: FixtureFile[] = [
{
path: "src/lib/notifications.svelte.ts",
row: 0,
old: `import { getContext, setContext } from "svelte";
export const NOTIFICATIONS_KEY = Symbol("notifications");
export const createNotifications = () => {
let unread = $state(0);
return {
get unread() {
return unread;
},
set unread(value: number) {
unread = value;
}
};
};
`,
next: `import { getContext, setContext } from "svelte";
export const NOTIFICATIONS_KEY = Symbol("notifications");
export const createNotifications = () => {
let unread = $state(0);
const visited = new SvelteSet();
return {
get unread() {
return unread;
},
markEntityVisited: async (uri: string) => {
if (visited.has(uri)) return;
visited.add(uri);
unread = Math.max(unread - 1, 0);
}
};
};
`
},
{
path: "src/lib/components/notifications/NotificationList.svelte",
row: 2,
next: `
{#each notifications as notification (notification.uri)}
{/each}
`
}
];
const rowOf = (ref: string): number => {
const row = pullCommits.findIndex((commit) => commit.hash === ref);
// the merge base and anything unknown sit under the oldest commit
return row === -1 ? pullCommits.length : row;
};
// rows are newest first, so a file reads as changed at every ref at or above the
// row that changed it
const textAt = (file: FixtureFile, ref: string): string =>
(rowOf(ref) <= file.row ? file.next : file.old) ?? "";
const zeroOid = "0".repeat(40);
const blobOid = (path: string, side: "lhs" | "rhs") =>
`${side === "lhs" ? "a" : "b"}${path.length.toString(16)}`
.padEnd(40, "1a2b3c4d5e6f7809")
.slice(0, 40);
const src = (file: FixtureFile, side: "lhs" | "rhs", text: string | undefined) => ({
path: text === undefined ? "" : side === "lhs" ? (file.oldPath ?? file.path) : file.path,
oid: text === undefined ? zeroOid : blobOid(file.path, side),
size: file.binary ? 12_384 : new TextEncoder().encode(text ?? "").length,
isBinary: !!file.binary,
isSubmodule: false
});
// the payload carries aligned line numbers only -- pierre diffs the two texts
// itself -- so one hunk over the changed span is all a card needs to size itself
const hunkOf = (oldText: string, newText: string) => {
const lhs = oldText === "" ? [] : oldText.split("\n");
const rhs = newText === "" ? [] : newText.split("\n");
let start = 0;
while (start < lhs.length && start < rhs.length && lhs[start] === rhs[start]) start++;
let tail = 0;
while (
tail < lhs.length - start &&
tail < rhs.length - start &&
lhs[lhs.length - 1 - tail] === rhs[rhs.length - 1 - tail]
)
tail++;
const lhsSpan = lhs.length - tail - start;
const rhsSpan = rhs.length - tail - start;
const lines = Array.from({ length: Math.max(lhsSpan, rhsSpan) }, (_, index) => ({
lhs: index < lhsSpan ? start + index : undefined,
rhs: index < rhsSpan ? start + index : undefined
}));
return {
lines,
novelLhs: lines.filter((line) => line.lhs !== undefined).map((line) => line.lhs as number),
novelRhs: lines.filter((line) => line.rhs !== undefined).map((line) => line.rhs as number)
};
};
const fileDiff = (file: FixtureFile, version: Revspec): MirrorFileDiff => {
const oldText = file.old === undefined ? undefined : textAt(file, version.base);
const newText = file.next === undefined ? undefined : textAt(file, version.head);
return {
lhsSrc: src(file, "lhs", oldText),
rhsSrc: src(file, "rhs", newText),
hasSyntacticChanges: !file.binary,
hunks: file.binary ? [] : [hunkOf(oldText ?? "", newText ?? "")]
};
};
// a range is [head row, base row), the same half-open span the commit list uses
const inRange = (file: FixtureFile, version: Revspec) =>
file.row >= rowOf(version.head) && file.row < rowOf(version.base);
export type DiffOutcome = "ok" | "empty" | "error" | "pending";
export type SubscriptionState = "none" | "active" | "error";
export interface PullFetchOptions {
/** the source branch tip; a value past the newest head arms Resubmit */
sourceHead?: string;
conflicted?: boolean;
subscription?: SubscriptionState;
diff?: DiffOutcome;
}
const json = (body: unknown) => Response.json(body);
const notFound = () => new Response(null, { status: 404 });
const failed = (message: string) =>
Response.json({ error: "InternalError", message }, { status: 500 });
const mirrorCommit = (commit: CommitSummary) => ({
oid: commit.hash,
message: commit.subject,
author: { name: commit.authorName, email: commit.authorEmail, when: commit.when },
committer: { name: commit.authorName, email: commit.authorEmail, when: commit.when },
parents: commit.parent ? [commit.parent] : [],
extraHeaders: commit.changeId ? [{ key: "change-id", value: commit.changeId }] : []
});
/**
* Answers every read the pull page makes: the subscription lookup, the branch
* and merge-base resolutions, the merge check, the diff payloads and the blob
* reads behind each file card. Install it before the page mounts -- the page
* captures `globalThis.fetch` when it builds its clients.
*/
export const mockPullFetch = (options: PullFetchOptions = {}): typeof fetch =>
(async (input: RequestInfo | URL) => {
const url = new URL(String(input instanceof Request ? input.url : input));
const nsid = url.pathname.replace(/^\/xrpc\//, "");
const param = (name: string) => url.searchParams.get(name) ?? "";
switch (nsid) {
case "org.tangled.feed.subscription.getForActor":
switch (options.subscription ?? "none") {
case "active":
return json({
uri: `at://${param("actor")}/org.tangled.feed.subscription/3msub`
});
case "error":
return failed("bobbin is down");
default:
return notFound();
}
case "org.tangled.temp.git.getBranch":
return json({
reference: { name: param("name") },
hash:
param("name") === sourceBranch
? (options.sourceHead ?? pullHead)
: targetHead
});
case "org.tangled.temp.git.getMergeBase":
return json({ commit: base });
case "sh.tangled.git.temp2.mergeCheck":
return json({
isConflicted: !!options.conflicted,
// two files, so a conflict list exercises both the file the diff
// opens on and one the reader has to be carried to
conflicts: options.conflicted
? [
{
filename: "src/lib/notifications.svelte.ts",
reason: "both modified"
},
{
filename:
"src/lib/components/notifications/NotificationList.svelte",
reason: "both added"
}
]
: []
});
case "sh.tangled.git.temp2.listCommits":
return json({ commits: pullCommits.map(mirrorCommit) });
case "sh.tangled.git.temp2.getDiff":
case "sh.tangled.git.temp2.getInterdiff": {
if (options.diff === "pending") return new Promise(() => {});
if (options.diff === "error") return failed("gitmirror is unreachable");
if (options.diff === "empty") return json({ diffs: [] });
// an interdiff's spans don't map onto one commit range, so it
// answers with the whole set
const version =
nsid === "sh.tangled.git.temp2.getDiff"
? { base: param("baseCommit"), head: param("headCommit") }
: { base, head: pullHead };
return json({
diffs: files
.filter((file) => inRange(file, version))
.map((file) => fileDiff(file, version))
});
}
case "org.tangled.temp.git.getEntry": {
const file = files.find(
(entry) => entry.path === param("path") || entry.oldPath === param("path")
);
if (!file) return notFound();
return json({
name: param("path").split("/").pop(),
mode: "100644",
size: file.binary
? 12_384
: new TextEncoder().encode(textAt(file, param("ref"))).length
});
}
case "sh.tangled.git.temp.getBlob": {
const file = files.find(
(entry) => entry.path === param("path") || entry.oldPath === param("path")
);
if (!file) return notFound();
return new Response(textAt(file, param("ref")));
}
}
return notFound();
}) as typeof fetch;