This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / api / repoIndex.ts
6.6 kB 209 lines
1import { error } from "@sveltejs/kit"; 2import { ClientResponseError } from "$lib/api/client"; 3import { 4 branches as gitBranches, 5 gitTarget, 6 languages as gitLanguages, 7 log as gitLog, 8 tags as gitTags, 9 tree as gitTree 10} from "$lib/api/gitclient"; 11import { parallel } from "$lib/api/load"; 12import { 13 sortTreeEntries, 14 tagsByCommitHash, 15 toBranchSummary, 16 toCommitSummary, 17 toTagSummary, 18 toTreeCommitSummary, 19 toTreeEntrySummary 20} from "$lib/api/repo"; 21import { renderDocument } from "$lib/markup"; 22import type { LanguageSlice, RepoInfo } from "$lib/components/repo/types"; 23// `/tree/{ref}` is this same page at another ref, so they share a load 24 25const COMMIT_LIMIT = 10; 26const BRANCH_LIMIT = 5; 27const TAG_LIMIT = 5; 28// a knot only ever lists 100 refs, so any total we get is really a minimum 29export const REF_LIMIT = 100; 30 31export interface RepoParent { 32 publicConfig: { bobbinUrl: string; knotMirrorUrl: string; camoEnabled: boolean }; 33 repo: RepoInfo; 34} 35 36export interface RepoLoadEvent { 37 fetch: typeof globalThis.fetch; 38 url: URL; 39} 40 41const orNull = <T>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null); 42 43interface Attempt<T> { 44 value: T | null; 45 error: unknown | null; 46} 47 48const attempt = <T>(promise: Promise<T>): Promise<Attempt<T>> => 49 promise.then( 50 (value) => ({ value, error: null }), 51 (error) => ({ value: null, error }) 52 ); 53 54const isUnsupported = (cause: unknown): boolean => 55 cause instanceof ClientResponseError && cause.status === 404; 56 57export type RepoAvailability = "ok" | "empty" | "needs-upgrade" | "unreachable"; 58 59export const classifyRepoAvailability = ( 60 attempts: readonly { value: unknown | null; error: unknown | null }[] 61): Exclude<RepoAvailability, "empty"> => { 62 if (attempts.every((result) => result.value === null && isUnsupported(result.error))) { 63 return "needs-upgrade"; 64 } 65 return attempts.every((result) => result.value === null) ? "unreachable" : "ok"; 66}; 67 68const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { 69 const sized = languages.filter((language) => language.size > 0); 70 const total = sized.reduce((sum, language) => sum + language.size, 0); 71 if (total === 0) return []; 72 73 const slices = sized.map((language) => { 74 const share = (language.size / total) * 100; 75 return { name: language.name, share, percentage: Math.floor(share) }; 76 }); 77 78 const short = 100 - slices.reduce((sum, slice) => sum + slice.percentage, 0); 79 [...slices] 80 .sort((a, b) => (b.share % 1) - (a.share % 1) || b.share - a.share) 81 .slice(0, Math.max(0, short)) 82 .forEach((slice) => { 83 slice.percentage += 1; 84 }); 85 86 return slices.sort((a, b) => b.share - a.share); 87}; 88 89const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ 90 branches: branches.map((branch) => branch.name), 91 tags: tags.map((tag) => tag.name), 92 capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT 93}); 94 95export const renderReadme = ( 96 readme: { filename: string; contents: string } | null, 97 parent: RepoParent, 98 event: RepoLoadEvent, 99 ref: string, 100 dir?: string 101) => 102 readme 103 ? renderDocument(readme.filename, readme.contents, { 104 repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 105 ref, 106 dir, 107 host: event.url.host, 108 camo: parent.publicConfig.camoEnabled 109 }) 110 : Promise.resolve(null); 111 112// the knot sends a readme with empty fields when a directory has none 113const readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => { 114 const readme = tree?.readme; 115 return readme?.filename ? readme : null; 116}; 117 118export interface RepoIndexOptions { 119 requireRef?: boolean; 120} 121 122export const loadRepoIndex = async ( 123 event: RepoLoadEvent, 124 parent: RepoParent, 125 ref: string, 126 // a ref from the url has to resolve or a typo looks like a repo with no 127 // files. the default branch renders whatever the knot managed to answer 128 { requireRef = false }: RepoIndexOptions = {} 129) => { 130 const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 131 132 // each list falls back on its own, so half a page still renders 133 const results = await parallel({ 134 tree: attempt(gitTree(git, { ref })), 135 log: attempt(gitLog(git, { ref, limit: COMMIT_LIMIT })), 136 branches: attempt(gitBranches(git, REF_LIMIT)), 137 tags: attempt(gitTags(git, REF_LIMIT)), 138 languages: attempt(gitLanguages(git, ref)) 139 }); 140 141 const branches = (results.branches.value?.branches ?? []).map(toBranchSummary); 142 const tags = (results.tags.value?.tags ?? []).map(toTagSummary); 143 const commits = (results.log.value?.commits ?? []).map(toCommitSummary); 144 const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary)); 145 146 const languages = toLanguageSlices(results.languages.value?.languages ?? []); 147 148 const readme = readmeOf(results.tree.value); 149 const readmeHtml = await renderReadme(readme, parent, event, ref); 150 151 const contentAttempts = [results.tree, results.log, results.branches]; 152 const knot = classifyRepoAvailability(contentAttempts); 153 const availability: RepoAvailability = 154 knot === "ok" && files.length === 0 && branches.length === 0 ? "empty" : knot; 155 156 // there are refs but not this one, so it is not a real ref. an empty repo has 157 // no refs at all and still gets a page 158 if (requireRef && results.tree.value === null && branches.length > 0) { 159 error(404, `${ref} does not exist in this repository`); 160 } 161 162 return { 163 ref, 164 availability, 165 files, 166 readme, 167 readmeHtml, 168 commits, 169 tagsByCommit: tagsByCommitHash(commits, tags), 170 totalCommits: results.log.value?.total ?? commits.length, 171 branches: branches.slice(0, BRANCH_LIMIT), 172 totalBranches: branches.length, 173 tags: tags.slice(0, TAG_LIMIT), 174 totalTags: tags.length, 175 // the switcher needs every ref, not just the visible slice 176 refs: refNames(branches, tags), 177 languages 178 }; 179}; 180 181export const loadRepoTree = async ( 182 event: RepoLoadEvent, 183 parent: RepoParent, 184 ref: string, 185 path: string 186) => { 187 const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 188 189 // the tree is the whole page here, so a miss is just a 404 190 const tree = await orNull(gitTree(git, { ref, path })); 191 const files = sortTreeEntries((tree?.files ?? []).map(toTreeEntrySummary)); 192 // git cannot store an empty directory. so nothing here means the path is a 193 // file, or was never there 194 if (tree === null || files.length === 0) { 195 error(404, `${path} does not exist at ${ref}`); 196 } 197 198 const readme = readmeOf(tree); 199 const readmeHtml = await renderReadme(readme, parent, event, ref, path); 200 201 return { 202 ref, 203 path, 204 files, 205 readme, 206 readmeHtml, 207 lastCommit: tree.lastCommit ? toTreeCommitSummary(tree.lastCommit) : null 208 }; 209};