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
7.1 kB 265 lines
1import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client"; 2import { getRepoByName, type RecordView, type RepoRecord } from "./records"; 3import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot"; 4import { httpStatusFor } from "./load"; 5import { rkeyFromUri } from "./uri"; 6import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; 7 8// log, branches and tags are `*/*` in the lexicons, so these shapes are copied 9// from core/types by hand. anything go-git touches keeps its go field names and 10// writes hashes as byte arrays, so the hex comes from a sibling field 11 12export interface GitSignature { 13 Name: string; 14 Email: string; 15 When: string; 16} 17 18export interface GitCommit { 19 Author?: GitSignature; 20 Committer?: GitSignature; 21 Message?: string; 22} 23 24// `this` and `parent` are the hex hashes, `hash` is the byte array 25export interface LogCommit { 26 this?: string; 27 parent?: string; 28 author?: GitSignature; 29 committer?: GitSignature; 30 message?: string; 31 tree?: string; 32 change_id?: string; 33} 34 35export interface LogResponse { 36 commits?: LogCommit[]; 37 ref?: string; 38 total?: number; 39 page?: number; 40} 41 42export interface GitReference { 43 name: string; 44 hash: string; 45} 46 47export interface BranchEntry { 48 reference: GitReference; 49 commit?: GitCommit; 50 is_default?: boolean; 51} 52 53export interface BranchesResponse { 54 branches?: BranchEntry[]; 55 total?: number; 56} 57 58export interface TagEntry { 59 name: string; 60 hash: string; 61 message?: string; 62 tag?: { 63 Tagger?: GitSignature; 64 Message?: string; 65 // the commit an annotated tag points at, bytes like every go-git hash 66 Target?: number[]; 67 }; 68} 69 70export interface TagsResponse { 71 tags?: TagEntry[]; 72 total?: number; 73} 74 75export interface RepoTagResponse { 76 tag?: TagEntry; 77} 78 79// newer repos get tid rkeys and keep their display name in the record 80export const repoNameOf = (view: RecordView<RepoRecord>): string => 81 view.value.name ?? rkeyFromUri(view.uri); 82 83// a repo bobbin has never indexed is a miss, not an error 84export const resolveRepoByName = async ( 85 ctx: BobbinContext, 86 ownerDid: string, 87 name: string, 88 init?: XrpcRequestInit 89): Promise<RecordView<RepoRecord> | null> => { 90 try { 91 return await getRepoByName(ctx, ownerDid, name, init); 92 } catch (cause) { 93 if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; 94 throw cause; 95 } 96}; 97 98export interface CommitSummary { 99 hash: string; 100 shortHash: string; 101 subject: string; 102 body: string; 103 authorName: string; 104 authorEmail: string; 105 when: string; 106 changeId?: string; 107} 108 109const splitMessage = (message: string): [string, string] => { 110 const separator = message.indexOf("\n\n"); 111 if (separator === -1) return [message.trim(), ""]; 112 return [message.slice(0, separator).trim(), message.slice(separator + 2).trim()]; 113}; 114 115/** the subject is the first paragraph, not the first line */ 116export const subjectOf = (message: string): string => splitMessage(message)[0]; 117 118export const toCommitSummary = (commit: LogCommit): CommitSummary => { 119 const [subject, body] = splitMessage(commit.message ?? ""); 120 const hash = commit.this ?? ""; 121 return { 122 hash, 123 shortHash: hash.slice(0, 8), 124 subject, 125 body, 126 authorName: commit.author?.Name ?? "", 127 authorEmail: commit.author?.Email ?? "", 128 when: commit.committer?.When ?? commit.author?.When ?? "", 129 changeId: commit.change_id 130 }; 131}; 132 133// the tree endpoint uses lexicon casing where the log ones pass through go field 134// names, so this cannot share `toCommitSummary` 135export const toTreeCommitSummary = (commit: Tree.LastCommit): CommitSummary => { 136 const [subject, body] = splitMessage(commit.message ?? ""); 137 return { 138 hash: commit.hash, 139 shortHash: commit.hash.slice(0, 8), 140 subject, 141 body, 142 authorName: commit.author?.name ?? "", 143 authorEmail: commit.author?.email ?? "", 144 when: commit.when ?? commit.author?.when ?? "" 145 }; 146}; 147 148export interface BranchSummary { 149 name: string; 150 hash: string; 151 when?: string; 152 isDefault: boolean; 153} 154 155export const toBranchSummary = (branch: BranchEntry): BranchSummary => ({ 156 name: branch.reference.name, 157 hash: branch.reference.hash, 158 when: branch.commit?.Committer?.When ?? branch.commit?.Author?.When, 159 isDefault: branch.is_default === true 160}); 161 162export interface TagSummary { 163 name: string; 164 hash: string; 165 // an annotated tag has its own hash, this is the commit it points at 166 commitHash: string; 167 when?: string; 168 message?: string; 169} 170 171export const hexFromBytes = (bytes: number[]): string => 172 bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); 173 174export const toTagSummary = (tag: TagEntry): TagSummary => { 175 const target = tag.tag?.Target; 176 return { 177 name: tag.name, 178 hash: tag.hash, 179 commitHash: target?.length ? hexFromBytes(target) : tag.hash, 180 when: tag.tag?.Tagger?.When, 181 message: tag.message ?? tag.tag?.Message 182 }; 183}; 184 185export type TreeEntryKind = "file" | "directory" | "symlink" | "submodule"; 186 187// modes come back octal and zero padded 188export const treeEntryKind = (mode: string): TreeEntryKind => { 189 switch (mode.replace(/^0+/, "").padStart(6, "0")) { 190 case "040000": 191 return "directory"; 192 case "120000": 193 return "symlink"; 194 case "160000": 195 return "submodule"; 196 default: 197 return "file"; 198 } 199}; 200 201export interface TreeEntrySummary { 202 name: string; 203 kind: TreeEntryKind; 204 size: number; 205 lastCommitHash?: string; 206 lastCommitWhen?: string; 207 lastCommitMessage?: string; 208} 209 210export const toTreeEntrySummary = (entry: Tree.TreeEntry): TreeEntrySummary => ({ 211 name: entry.name, 212 kind: treeEntryKind(entry.mode), 213 size: entry.size, 214 lastCommitHash: entry.last_commit?.hash, 215 lastCommitWhen: entry.last_commit?.when, 216 lastCommitMessage: entry.last_commit?.message 217}); 218 219export const tagsByCommitHash = ( 220 commits: CommitSummary[], 221 tags: TagSummary[] 222): Record<string, string[]> => { 223 const shown = new Set(commits.map((commit) => commit.hash)); 224 return tags.reduce<Record<string, string[]>>((acc, tag) => { 225 if (shown.has(tag.commitHash)) (acc[tag.commitHash] ??= []).push(tag.name); 226 return acc; 227 }, {}); 228}; 229 230export const sortTreeEntries = (entries: TreeEntrySummary[]): TreeEntrySummary[] => 231 [...entries].sort((a, b) => { 232 const aDir = a.kind === "directory" || a.kind === "submodule"; 233 const bDir = b.kind === "directory" || b.kind === "submodule"; 234 if (aDir !== bDir) return aDir ? -1 : 1; 235 return a.name.localeCompare(b.name); 236 }); 237 238// the log cursor is a numeric offset encoded as a string 239export const logFor = ( 240 ctx: BobbinContext, 241 repo: string, 242 ref: string, 243 limit: number, 244 cursor?: string, 245 init?: XrpcRequestInit 246) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init); 247 248export const branchesFor = ( 249 ctx: BobbinContext, 250 repo: string, 251 limit: number, 252 cursor?: string, 253 init?: XrpcRequestInit 254) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init); 255 256export const tagsFor = ( 257 ctx: BobbinContext, 258 repo: string, 259 limit: number, 260 cursor?: string, 261 init?: XrpcRequestInit 262) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init); 263 264export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) => 265 knotTag<RepoTagResponse>(ctx, { repo, tag }, init);