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.5 kB 342 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 129const coAuthorPattern = /^Co-authored-by:\s*(.+?)\s*<([^>]+)>/gim; 130 131// trailers carry no date of their own, like types/commit.go every 132// co-author gets stamped with the committer's When 133export const coAuthorsFrom = (message: string, when: string): GitSignature[] => { 134 const seen = new Set<string>(); 135 const coAuthors: GitSignature[] = []; 136 for (const match of message.matchAll(coAuthorPattern)) { 137 const name = match[1].trim(); 138 const email = match[2].trim(); 139 if (seen.has(email)) continue; 140 seen.add(email); 141 coAuthors.push({ Name: name, Email: email, When: when }); 142 } 143 return coAuthors; 144}; 145 146export const toCommitSummary = (commit: LogCommit): CommitSummary => { 147 const [subject, body] = splitMessage(commit.message ?? ""); 148 const hash = commit.this ?? ""; 149 return { 150 hash, 151 shortHash: hash.slice(0, 8), 152 subject, 153 body, 154 authorName: commit.author?.Name ?? "", 155 authorEmail: commit.author?.Email ?? "", 156 when: commit.committer?.When ?? commit.author?.When ?? "", 157 changeId: commit.change_id 158 }; 159}; 160 161export interface CommitDetail { 162 hash: string; 163 shortHash: string; 164 subject: string; 165 body: string; 166 authorName: string; 167 authorEmail: string; 168 authorWhen: string; 169 committerName: string; 170 committerEmail: string; 171 committerWhen: string; 172 coAuthors: { name: string; email: string }[]; 173} 174 175// the diff endpoint's commit carries both signatures, unlike the log's 176// summary shape 177export const toCommitDetail = (commit: NiceCommit): CommitDetail => { 178 const [subject, body] = splitMessage(commit.message ?? ""); 179 const hash = commit.this ?? ""; 180 const committerWhen = commit.committer?.When ?? ""; 181 return { 182 hash, 183 shortHash: hash.slice(0, 8), 184 subject, 185 body, 186 authorName: commit.author?.Name ?? "", 187 authorEmail: commit.author?.Email ?? "", 188 authorWhen: commit.author?.When ?? "", 189 committerName: commit.committer?.Name ?? "", 190 committerEmail: commit.committer?.Email ?? "", 191 committerWhen, 192 coAuthors: coAuthorsFrom(commit.message ?? "", committerWhen).map(({ Name, Email }) => ({ 193 name: Name, 194 email: Email 195 })) 196 }; 197}; 198 199// the tree endpoint uses lexicon casing where the log ones pass through go field 200// names, so this cannot share `toCommitSummary` 201export const toTreeCommitSummary = (commit: { 202 hash: string; 203 message?: string; 204 when?: string; 205 author?: { name?: string; email?: string; when?: string }; 206}): CommitSummary => { 207 const [subject, body] = splitMessage(commit.message ?? ""); 208 return { 209 hash: commit.hash, 210 shortHash: commit.hash.slice(0, 8), 211 subject, 212 body, 213 authorName: commit.author?.name ?? "", 214 authorEmail: commit.author?.email ?? "", 215 when: commit.when ?? commit.author?.when ?? "" 216 }; 217}; 218 219export interface BranchSummary { 220 name: string; 221 hash: string; 222 when?: string; 223 isDefault: boolean; 224 // full commit message 225 message?: string; 226} 227 228export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({ 229 name: branch.reference.name, 230 hash: branch.reference.hash, 231 when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When, 232 isDefault: branch.is_default === true, 233 message: branch.commit?.Message 234}); 235 236export interface TagSummary { 237 name: string; 238 hash: string; 239 // an annotated tag has its own hash, this is the commit it points at 240 commitHash: string; 241 when?: string; 242 message?: string; 243 // annotated tags carry their tagger 244 taggerName?: string; 245} 246 247export const hexFromBytes = (bytes: number[]): string => 248 bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); 249 250export const toTagSummary = (tag: TagEntry): TagSummary => { 251 const target = tag.tag?.Target; 252 return { 253 name: tag.name, 254 hash: tag.hash, 255 commitHash: target?.length ? hexFromBytes(target) : tag.hash, 256 when: tag.tag?.Tagger?.When, 257 message: tag.message ?? tag.tag?.Message, 258 taggerName: tag.tag?.Tagger?.Name 259 }; 260}; 261 262export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule"; 263 264// modes come back octal and zero padded 265export const treeEntryKind = (mode: string): TreeEntryKind => { 266 switch (mode.replace(/^0+/, "").padStart(6, "0")) { 267 case "040000": 268 return "directory"; 269 case "120000": 270 return "symlink"; 271 case "160000": 272 return "submodule"; 273 default: 274 return "file"; 275 } 276}; 277 278export interface TreeEntrySummary { 279 name: string; 280 kind: TreeEntryKind; 281 size: number; 282 lastCommitHash?: string; 283 lastCommitWhen?: string; 284 lastCommitMessage?: string; 285} 286 287export const toTreeEntrySummary = (entry: Tree.TreeEntry): TreeEntrySummary => ({ 288 name: entry.name, 289 kind: treeEntryKind(entry.mode), 290 size: entry.size, 291 lastCommitHash: entry.last_commit?.hash, 292 lastCommitWhen: entry.last_commit?.when, 293 lastCommitMessage: entry.last_commit?.message 294}); 295 296export const tagsByCommitHash = ( 297 commits: CommitSummary[], 298 tags: TagSummary[] 299): Record<string, string[]> => { 300 const shown = new Set(commits.map((commit) => commit.hash)); 301 return tags.reduce<Record<string, string[]>>((acc, tag) => { 302 if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name); 303 return acc; 304 }, {}); 305}; 306 307export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] => 308 [...entries].sort((a, b) => { 309 const aDir = a.kind === "directory" || a.kind === "submodule"; 310 const bDir = b.kind === "directory" || b.kind === "submodule"; 311 if (aDir !== bDir) return aDir ? -1 : 1; 312 return a.name.localeCompare(b.name); 313 }); 314 315// the log cursor is a numeric offset encoded as a string 316export const logFor = ( 317 ctx: BobbinContext, 318 repo: string, 319 ref: string, 320 limit: number, 321 cursor?: string, 322 init?: XrpcRequestInit 323) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init); 324 325export const branchesFor = ( 326 ctx: BobbinContext, 327 repo: string, 328 limit: number, 329 cursor?: string, 330 init?: XrpcRequestInit 331) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init); 332 333export const tagsFor = ( 334 ctx: BobbinContext, 335 repo: string, 336 limit: number, 337 cursor?: string, 338 init?: XrpcRequestInit 339) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init); 340 341export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) => 342 knotTag<RepoTagResponse>(ctx, { repo, tag }, init);