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 / repo.ts
9.6 kB 345 lines
1import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client"; 2import type { NiceCommit } from "./diff"; 3import { getRepoByName, type RecordView, type RepoRecord } from "./records"; 4import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot"; 5import { httpStatusFor } from "./load"; 6import { rkeyFromUri } from "./uri"; 7import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; 8 9// log, branches and tags are `*/*` in the lexicons, so these shapes are copied 10// from core/types by hand. anything go-git touches keeps its go field names and 11// writes hashes as byte arrays, so the hex comes from a sibling field 12 13export interface GitSignature { 14 Name: string; 15 Email: string; 16 When: string; 17} 18 19// go-git's IsHash accepts uppercase hex too (hex.DecodeString), both sha1 20// and sha256 match case-insensitively 21export const FULL_HASH_RE = /^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/; 22 23export const parseRawCommit = (spec: string): { ref: string; format: "patch" | "diff" } | null => { 24 const dot = spec.lastIndexOf("."); 25 if (dot === -1) return null; 26 const format = spec.slice(dot + 1); 27 if (format !== "patch" && format !== "diff") return null; 28 const ref = spec.slice(0, dot); 29 return FULL_HASH_RE.test(ref) ? { ref, format } : null; 30}; 31 32export interface GitCommit { 33 Author?: GitSignature; 34 Committer?: GitSignature; 35 Message?: string; 36} 37 38// `this` and `parent` are the hex hashes, `hash` is the byte array 39export interface LogCommit { 40 this?: string; 41 parent?: string; 42 author?: GitSignature; 43 committer?: GitSignature; 44 message?: string; 45 tree?: string; 46 change_id?: string; 47} 48 49export interface LogResponse { 50 commits?: LogCommit[]; 51 ref?: string; 52 total?: number; 53 page?: number; 54} 55 56export interface GitReference { 57 name: string; 58 hash: string; 59} 60 61export interface BranchEntry { 62 reference: GitReference; 63 commit?: GitCommit; 64 is_default?: boolean; 65} 66 67export interface BranchesResponse { 68 branches?: BranchEntry[]; 69 total?: number; 70} 71 72export interface TagEntry { 73 name: string; 74 hash: string; 75 message?: string; 76 tag?: { 77 Tagger?: GitSignature; 78 Message?: string; 79 // the commit an annotated tag points at, bytes like every go-git hash 80 Target?: number[]; 81 }; 82} 83 84export interface TagsResponse { 85 tags?: TagEntry[]; 86 total?: number; 87} 88 89export interface RepoTagResponse { 90 tag?: TagEntry; 91} 92 93// newer repos get tid rkeys and keep their display name in the record 94export const repoNameOf = (view: RecordView<RepoRecord>): string => 95 view.value.name ?? rkeyFromUri(view.uri); 96 97// a repo bobbin has never indexed is a miss, not an error 98export const resolveRepoByName = async ( 99 ctx: BobbinContext, 100 ownerDid: string, 101 name: string, 102 init?: XrpcRequestInit 103): Promise<RecordView<RepoRecord> | null> => { 104 try { 105 return await getRepoByName(ctx, ownerDid, name, init); 106 } catch (cause) { 107 if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; 108 throw cause; 109 } 110}; 111 112export interface CommitSummary { 113 hash: string; 114 shortHash: string; 115 subject: string; 116 body: string; 117 authorName: string; 118 authorEmail: string; 119 when: string; 120 changeId?: string; 121} 122 123export const splitMessage = (message: string): [string, string] => { 124 const separator = message.indexOf("\n\n"); 125 if (separator === -1) return [message.trim(), ""]; 126 return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()]; 127}; 128 129/** the subject is the first paragraph, not the first line */ 130export const subjectOf = (message: string): string => splitMessage(message)[0]; 131 132const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim; 133 134// trailers carry no date of their own, like types/commit.go every 135// co-author gets stamped with the committer's When 136export const coAuthorsFrom = (message: string, when: string): GitSignature[] => { 137 const seen = new Set<string>(); 138 const coAuthors: GitSignature[] = []; 139 for (const match of message.matchAll(coAuthorPattern)) { 140 const name = match[1].trim(); 141 const email = match[2].trim(); 142 if (seen.has(email)) continue; 143 seen.add(email); 144 coAuthors.push({ Name: name, Email: email, When: when }); 145 } 146 return coAuthors; 147}; 148 149export const toCommitSummary = (commit: LogCommit): CommitSummary => { 150 const [subject, body] = splitMessage(commit.message ?? ""); 151 const hash = commit.this ?? ""; 152 return { 153 hash, 154 shortHash: hash.slice(0, 8), 155 subject, 156 body, 157 authorName: commit.author?.Name ?? "", 158 authorEmail: commit.author?.Email ?? "", 159 when: commit.committer?.When ?? commit.author?.When ?? "", 160 changeId: commit.change_id 161 }; 162}; 163 164export interface CommitDetail { 165 hash: string; 166 shortHash: string; 167 subject: string; 168 body: string; 169 authorName: string; 170 authorEmail: string; 171 authorWhen: string; 172 committerName: string; 173 committerEmail: string; 174 committerWhen: string; 175 coAuthors: { name: string; email: string }[]; 176} 177 178// the diff endpoint's commit carries both signatures, unlike the log's 179// summary shape 180export const toCommitDetail = (commit: NiceCommit): CommitDetail => { 181 const [subject, body] = splitMessage(commit.message ?? ""); 182 const hash = commit.this ?? ""; 183 const committerWhen = commit.committer?.When ?? ""; 184 return { 185 hash, 186 shortHash: hash.slice(0, 8), 187 subject, 188 body, 189 authorName: commit.author?.Name ?? "", 190 authorEmail: commit.author?.Email ?? "", 191 authorWhen: commit.author?.When ?? "", 192 committerName: commit.committer?.Name ?? "", 193 committerEmail: commit.committer?.Email ?? "", 194 committerWhen, 195 coAuthors: coAuthorsFrom(commit.message ?? "", committerWhen).map(({ Name, Email }) => ({ 196 name: Name, 197 email: Email 198 })) 199 }; 200}; 201 202// the tree endpoint uses lexicon casing where the log ones pass through go field 203// names, so this cannot share `toCommitSummary` 204export const toTreeCommitSummary = (commit: { 205 hash: string; 206 message?: string; 207 when?: string; 208 author?: { name?: string; email?: string; when?: string }; 209}): CommitSummary => { 210 const [subject, body] = splitMessage(commit.message ?? ""); 211 return { 212 hash: commit.hash, 213 shortHash: commit.hash.slice(0, 8), 214 subject, 215 body, 216 authorName: commit.author?.name ?? "", 217 authorEmail: commit.author?.email ?? "", 218 when: commit.when ?? commit.author?.when ?? "" 219 }; 220}; 221 222export interface BranchSummary { 223 name: string; 224 hash: string; 225 when?: string; 226 isDefault: boolean; 227 // full commit message 228 message?: string; 229} 230 231export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({ 232 name: branch.reference.name, 233 hash: branch.reference.hash, 234 when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When, 235 isDefault: branch.is_default === true, 236 message: branch.commit?.Message 237}); 238 239export interface TagSummary { 240 name: string; 241 hash: string; 242 // an annotated tag has its own hash, this is the commit it points at 243 commitHash: string; 244 when?: string; 245 message?: string; 246 // annotated tags carry their tagger 247 taggerName?: string; 248} 249 250export const hexFromBytes = (bytes: number[]): string => 251 bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); 252 253export const toTagSummary = (tag: TagEntry): TagSummary => { 254 const target = tag.tag?.Target; 255 return { 256 name: tag.name, 257 hash: tag.hash, 258 commitHash: target?.length ? hexFromBytes(target) : tag.hash, 259 when: tag.tag?.Tagger?.When, 260 message: tag.message ?? tag.tag?.Message, 261 taggerName: tag.tag?.Tagger?.Name 262 }; 263}; 264 265export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule"; 266 267// modes come back octal and zero padded 268export const treeEntryKind = (mode: string): TreeEntryKind => { 269 switch (mode.replace(/^0+/, "").padStart(6, "0")) { 270 case "040000": 271 return "directory"; 272 case "120000": 273 return "symlink"; 274 case "160000": 275 return "submodule"; 276 default: 277 return "file"; 278 } 279}; 280 281export interface TreeEntrySummary { 282 name: string; 283 kind: TreeEntryKind; 284 size: number; 285 lastCommitHash?: string; 286 lastCommitWhen?: string; 287 lastCommitMessage?: string; 288} 289 290export const toTreeEntrySummary = (entry: Tree.TreeEntry): TreeEntrySummary => ({ 291 name: entry.name, 292 kind: treeEntryKind(entry.mode), 293 size: entry.size, 294 lastCommitHash: entry.last_commit?.hash, 295 lastCommitWhen: entry.last_commit?.when, 296 lastCommitMessage: entry.last_commit?.message 297}); 298 299export const tagsByCommitHash = ( 300 commits: CommitSummary[], 301 tags: TagSummary[] 302): Record<string, string[]> => { 303 const shown = new Set(commits.map((commit) => commit.hash)); 304 return tags.reduce<Record<string, string[]>>((acc, tag) => { 305 if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name); 306 return acc; 307 }, {}); 308}; 309 310export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] => 311 [...entries].sort((a, b) => { 312 const aDir = a.kind === "directory" || a.kind === "submodule"; 313 const bDir = b.kind === "directory" || b.kind === "submodule"; 314 if (aDir !== bDir) return aDir ? -1 : 1; 315 return a.name.localeCompare(b.name); 316 }); 317 318// the log cursor is a numeric offset encoded as a string 319export const logFor = ( 320 ctx: BobbinContext, 321 repo: string, 322 ref: string, 323 limit: number, 324 cursor?: string, 325 init?: XrpcRequestInit 326) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init); 327 328export const branchesFor = ( 329 ctx: BobbinContext, 330 repo: string, 331 limit: number, 332 cursor?: string, 333 init?: XrpcRequestInit 334) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init); 335 336export const tagsFor = ( 337 ctx: BobbinContext, 338 repo: string, 339 limit: number, 340 cursor?: string, 341 init?: XrpcRequestInit 342) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init); 343 344export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) => 345 knotTag<RepoTagResponse>(ctx, { repo, tag }, init);