import { error } from "@sveltejs/kit";
import type { Did } from "@atcute/lexicons/syntax";
import type { GitRepoInfo } from "#lib/components/repo/types.ts";
import { createBobbinClient } from "#lib/api/client.ts";
import { branches as listBranches, gitTarget, type GitServiceConfig } from "#lib/api/gitclient.ts";
import { getMergeBase, listCommits, toMirrorCommitSummary } from "#lib/api/knotmirror.ts";
import { listRepos } from "#lib/api/records.ts";
import {
resolveRepoKnot,
branchSummaries,
type BranchSummary,
type CommitSummary
} from "#lib/api/repo.ts";
import { didFromUri } from "#lib/api/uri.ts";
import type { Fetch } from "#lib/fetch.ts";
export type PullSource = "patch" | "branch" | "fork";
const SOURCES: PullSource[] = ["patch", "branch", "fork"];
export const parseSource = (raw: string | null | undefined): PullSource | null => {
const value = raw?.toLowerCase() as PullSource | undefined;
return value && SOURCES.includes(value) ? value : null;
};
const branchTime = (branch: BranchSummary): number => (branch.when ? Date.parse(branch.when) : NaN);
/** newest commit first; branches with no commit sort last, order otherwise kept */
export const sortBranchesByRecency = (branches: readonly BranchSummary[]): BranchSummary[] =>
branches
.map((branch, index) => ({ branch, index, time: branchTime(branch) }))
.sort((a, b) => {
const aMissing = Number.isNaN(a.time);
const bMissing = Number.isNaN(b.time);
if (aMissing || bMissing)
return aMissing === bMissing ? a.index - b.index : aMissing ? 1 : -1;
return b.time - a.time || a.index - b.index;
})
.map((entry) => entry.branch);
/** you can't open a PR from the default branch, so it never appears as a source */
export const sourceBranchChoices = (branches: readonly BranchSummary[]): BranchSummary[] =>
sortBranchesByRecency(branches.filter((branch) => !branch.isDefault));
const named = (branches: readonly BranchSummary[], name: string): boolean =>
branches.some((branch) => branch.name === name);
export const defaultTargetBranch = (
branches: readonly BranchSummary[],
current: string
): string => {
if (current && named(branches, current)) return current;
return branches.find((branch) => branch.isDefault)?.name ?? "";
};
export const defaultSourceBranch = (
source: PullSource,
current: string,
branchChoices: readonly BranchSummary[],
forkBranches: readonly BranchSummary[]
): string => {
if (source === "patch") return current;
const candidates = source === "fork" ? forkBranches : branchChoices;
if (current && named(candidates, current)) return current;
return candidates[0]?.name ?? "";
};
export interface ComposeSelection {
source: PullSource;
sourceBranch: string;
targetBranch: string;
/** repo did of the chosen fork */
fork: string;
}
export const composeQuery = (selection: ComposeSelection): string => {
const query = new URLSearchParams();
if (selection.source !== "branch") query.set("source", selection.source);
if (selection.sourceBranch) query.set("sourceBranch", selection.sourceBranch);
if (selection.targetBranch) query.set("targetBranch", selection.targetBranch);
if (selection.source === "fork" && selection.fork) query.set("fork", selection.fork);
return query.toString();
};
const BRANCH_LIMIT = 100;
const COMMIT_LIMIT = 100;
// one of the viewer's forks, as the fork picker needs it
export interface ForkOption {
uri: string;
repoDid: string;
owner: string;
name: string;
knot: string;
}
export interface ComposeData {
commits: CommitSummary[];
commitsError: string;
mergeBase: string;
sourceHead: string;
// `owner/repo` of the source repo, for the browse-at-commit links
sourceRepoPath: string;
branches: BranchSummary[];
sourceBranches: BranchSummary[];
forkBranches: BranchSummary[];
forks: ForkOption[];
source: PullSource;
sourceBranch: string;
targetBranch: string;
fork: string;
patch: string;
sourceRepoDid: string;
sourceKnot: string;
showDetails: boolean;
prefillError: string;
}
// the route load calls this for the active account, the component calls it
// again client-side when the pick diverges since only the fork lane depends
// on the identity
export const loadCompose = async (args: {
config: GitServiceConfig;
repo: GitRepoInfo | Promise<GitRepoInfo>;
viewer: { did: string; handle: string } | null;
params: URLSearchParams;
fetch: Fetch;
}): Promise<ComposeData> => {
const { config, repo: repoInput, viewer, params, fetch: fetchFn } = args;
const repo = await repoInput;
const repoDid = repo.repoDid;
if (!repoDid) throw error(404, "This repository has not been indexed yet");
const ctx = createBobbinClient({ serviceUrl: config.bobbinUrl, fetch: fetchFn });
const source: PullSource = parseSource(params.get("source")) ?? "branch";
const [branchList, forkList] = await Promise.all([
listBranches(gitTarget(config, repo, fetchFn), BRANCH_LIMIT)
.then(branchSummaries)
.catch(() => [] as BranchSummary[]),
viewer
? listRepos(ctx, viewer.did, { limit: 100 })
.then((page) =>
Promise.all(
page.items
.filter((item) => {
const source = item.value.source;
return !!source && "did" in source && source.did === repoDid;
})
.map(async (item): Promise<ForkOption> => ({
uri: item.uri,
repoDid: didFromUri(item.uri),
owner: viewer.handle,
name: item.value.declaration.name,
knot: (await resolveRepoKnot(ctx, didFromUri(item.uri))) ?? ""
}))
)
)
.catch(() => [] as ForkOption[])
: Promise.resolve([] as ForkOption[])
]);
let fork = params.get("fork") ?? "";
if (source === "fork" && !fork && forkList.length === 1) fork = forkList[0].repoDid;
const forkRepo = forkList.find((entry) => entry.repoDid === fork);
let prefillError = "";
let forkBranches: BranchSummary[] = [];
if (source === "fork" && forkRepo) {
try {
const page = await listBranches(
gitTarget(config, { uri: forkRepo.uri, repoDid: forkRepo.repoDid }, fetchFn),
BRANCH_LIMIT
);
forkBranches = sortBranchesByRecency(branchSummaries(page));
} catch (cause) {
prefillError = cause instanceof Error ? cause.message : String(cause);
}
}
const sourceBranches = sourceBranchChoices(branchList);
const targetBranch = defaultTargetBranch(branchList, params.get("targetBranch") ?? "");
const sourceBranch = defaultSourceBranch(
source,
params.get("sourceBranch") ?? "",
sourceBranches,
forkBranches
);
const sourceRepoDid = fork || repoDid;
const hasComparison = Boolean(sourceRepoDid && sourceBranch && targetBranch);
const targetHead = branchList.find((branch) => branch.name === targetBranch)?.hash ?? "";
const sourceHead =
(source === "fork" ? forkBranches : sourceBranches).find(
(branch) => branch.name === sourceBranch
)?.hash ?? "";
const repos = sourceRepoDid === repoDid ? [repoDid] : [sourceRepoDid, repoDid];
let commits: CommitSummary[] = [];
let commitsError = "";
let mergeBase = "";
if (hasComparison && source !== "patch" && targetHead && sourceHead) {
try {
const [page, base] = await Promise.all([
listCommits(ctx, {
repos: repos as Did[],
ranges: [`${targetHead}..${sourceHead}`],
limit: COMMIT_LIMIT,
includeStats: true
}),
getMergeBase(ctx, { repos, base: targetHead, head: sourceHead })
]);
commits = page.commits.map(toMirrorCommitSummary);
mergeBase = base.commit;
} catch (cause) {
commitsError = cause instanceof Error ? cause.message : String(cause);
}
}
return {
commits,
commitsError,
mergeBase,
sourceHead,
sourceRepoPath: forkRepo
? `${forkRepo.owner}/${forkRepo.name}`
: `${repo.ownerHandle}/${repo.name}`,
branches: branchList,
sourceBranches,
forkBranches,
forks: forkList,
source,
sourceBranch,
targetBranch,
fork,
patch: params.get("patch") ?? "",
sourceRepoDid,
sourceKnot: forkRepo?.knot ?? repo.knot,
showDetails: hasComparison,
prefillError
};
};