Skip to content
File

Blob: src/worker/routes/ui/commits.ts

typescript321 lines
1import type { CommitDiffResult, CommitFilePatchResult } from "@/shared/git/types";
2import {
3 listCommitsFirstParentRange,
4 listMergeSideFirstParent,
5 readCommitInfo,
6 listCommitChangedFiles,
7 readCommitFilePatch,
8} from "@/worker/git";
9import { isValidPath, formatWhen, OID_RE } from "@/shared/web";
10import { handleError } from "@/client/server/error";
11import { buildCacheKeyFrom, cacheOrLoadJSONForRequestWithTTL } from "@/worker/cache";
12import { badRequest, isRequestPrivate, loadUiRepoActivity, resolveUiRepoAccess } from "./helpers";
13import { isValidRef } from "@/shared/web";
14import type { AppContext } from "../hono";
15import { renderUiDocumentResponse } from "../uiResponse";
16 
17export async function handleCommits(c: AppContext<"/:owner/:repo/commits">) {
18 const env = c.env;
19 const owner = c.req.param("owner");
20 const repo = c.req.param("repo");
21 const access = await resolveUiRepoAccess(c, owner, repo);
22 if (access.kind === "response") return access.response;
23 const { route, cacheCtx } = access;
24 const repoId = route.doName;
25 const isPrivate = isRequestPrivate(cacheCtx);
26 const u = new URL(c.req.url);
27 const ref = u.searchParams.get("ref") || "main";
28 if (!isValidRef(ref)) {
29 return badRequest(env, "Invalid ref", "Ref format not allowed", {
30 owner,
31 repo,
32 refEnc: encodeURIComponent(ref),
33 });
34 }
35 const pageRaw = u.searchParams.get("page") || "";
36 const perRaw = Number(u.searchParams.get("per_page") || "25");
37 const perPage = Number.isFinite(perRaw) ? Math.max(5, Math.min(100, Math.floor(perRaw))) : 25;
38 try {
39 let page = Number(pageRaw);
40 if (!Number.isFinite(page) || page < 0) page = 0;
41 const offset = page * perPage;
42 
43 const loader = async () => {
44 const commits = await listCommitsFirstParentRange(
45 env,
46 repoId,
47 ref,
48 offset,
49 perPage,
50 cacheCtx
51 );
52 return commits.map((c) => ({
53 oid: c.oid,
54 shortOid: c.oid.slice(0, 7),
55 firstLine: (c.message || "").split(/\r?\n/, 1)[0],
56 authorName: c.author?.name || "",
57 when: c.author ? formatWhen(c.author.when, c.author.tz) : "",
58 isMerge: Array.isArray(c.parents) && c.parents.length > 1,
59 }));
60 };
61 const cacheKey = buildCacheKeyFrom(c.req.raw, "/_cache/commits", {
62 repo: repoId,
63 ref,
64 per_page: String(perPage),
65 page: String(page),
66 offset: String(offset),
67 });
68 const commitsView = await cacheOrLoadJSONForRequestWithTTL<
69 Array<{
70 oid: string;
71 shortOid: string;
72 firstLine: string;
73 authorName: string;
74 when: string;
75 }>
76 >(cacheCtx, cacheKey, loader, () => {
77 const isOid = OID_RE.test(ref);
78 const isTag = ref.startsWith("refs/tags/");
79 // Branch commits: 300s; Tags/OIDs (immutable): 3600s
80 return isOid || isTag ? 3600 : 300;
81 });
82 const list = commitsView || [];
83 const last = list[list.length - 1]?.oid || "";
84 const refEnc = encodeURIComponent(ref);
85 const pager = {
86 perPageLinks: [10, 25, 50].map((n) => ({
87 text: String(n),
88 href: `/${owner}/${repo}/commits?ref=${refEnc}&page=${page}&per_page=${n}`,
89 })),
90 newerHref:
91 page > 0
92 ? `/${owner}/${repo}/commits?ref=${refEnc}&page=${page - 1}&per_page=${perPage}`
93 : undefined,
94 olderHref:
95 last && list.length === perPage
96 ? `/${owner}/${repo}/commits?ref=${refEnc}&page=${page + 1}&per_page=${perPage}`
97 : undefined,
98 };
99 const progress = await loadUiRepoActivity(env, access);
100 return renderUiDocumentResponse(
101 env,
102 "commits",
103 {
104 title: `Commits on ${ref} · ${owner}/${repo}`,
105 owner,
106 repo,
107 ref,
108 refEnc,
109 commits: list,
110 pager,
111 progress,
112 },
113 {
114 cacheControl: isPrivate ? "no-store" : undefined,
115 failureBody: "Failed to render view",
116 viewer: access.viewer,
117 }
118 );
119 } catch (e) {
120 return handleError(env, e, `Error · ${owner}/${repo}`, {
121 owner,
122 repo,
123 refEnc: encodeURIComponent(ref),
124 });
125 }
126}
127 
128export async function handleCommitFragments(c: AppContext<"/:owner/:repo/commits/fragments/:oid">) {
129 const env = c.env;
130 const owner = c.req.param("owner");
131 const repo = c.req.param("repo");
132 const oid = c.req.param("oid");
133 const access = await resolveUiRepoAccess(c, owner, repo, { responseShape: "json" });
134 if (access.kind === "response") return access.response;
135 const { route, cacheCtx } = access;
136 if (!OID_RE.test(oid)) {
137 return badRequest(env, "Invalid OID", "OID must be 40 hex", {
138 owner,
139 repo,
140 refEnc: encodeURIComponent(oid),
141 });
142 }
143 const u = new URL(c.req.url);
144 const limitRaw = Number(u.searchParams.get("limit") || "20");
145 const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, Math.floor(limitRaw))) : 20;
146 const repoId = route.doName;
147 try {
148 const side = await listMergeSideFirstParent(
149 env,
150 repoId,
151 oid,
152 limit,
153 {
154 scanLimit: Math.min(400, limit * 5),
155 timeBudgetMs: 5000,
156 mainlineProbe: 50,
157 },
158 cacheCtx
159 );
160 const commits = (side || []).map((c) => ({
161 oid: c.oid,
162 shortOid: c.oid.slice(0, 7),
163 firstLine: (c.message || "").split(/\r?\n/, 1)[0],
164 authorName: c.author?.name || "",
165 when: c.author ? formatWhen(c.author.when, c.author.tz) : "",
166 }));
167 return new Response(JSON.stringify({ owner, repo, commits, compact: true, mergeOf: oid }), {
168 headers: {
169 "Content-Type": "application/json; charset=utf-8",
170 "Cache-Control": "no-store, no-cache, must-revalidate",
171 "X-Page-Renderer": "react-fragment-json",
172 },
173 });
174 } catch (e) {
175 return handleError(env, e, `Error · ${owner}/${repo}`, {
176 owner,
177 repo,
178 refEnc: encodeURIComponent(oid),
179 });
180 }
181}
182 
183export async function handleCommitDiff(c: AppContext<"/:owner/:repo/commit/:oid/diff">) {
184 const env = c.env;
185 const owner = c.req.param("owner");
186 const repo = c.req.param("repo");
187 const oid = c.req.param("oid");
188 const access = await resolveUiRepoAccess(c, owner, repo, { responseShape: "json" });
189 if (access.kind === "response") return access.response;
190 const { route, cacheCtx } = access;
191 if (!OID_RE.test(oid)) {
192 return badRequest(env, "Invalid commit OID", "Commit id must be 40-hex", {
193 owner,
194 repo,
195 refEnc: encodeURIComponent(oid),
196 });
197 }
198 const url = new URL(c.req.url);
199 const path = url.searchParams.get("path") || "";
200 if (!path || !isValidPath(path)) {
201 return badRequest(env, "Invalid path", "Path contains invalid characters or is too long", {
202 owner,
203 repo,
204 refEnc: encodeURIComponent(oid),
205 path,
206 });
207 }
208 const repoId = route.doName;
209 try {
210 const loader = async () => await readCommitFilePatch(env, repoId, oid, path, cacheCtx);
211 const patchCacheKey = buildCacheKeyFrom(c.req.raw, "/_cache/commit-patch", {
212 repo: repoId,
213 oid,
214 path,
215 v: "1",
216 });
217 const patch = await cacheOrLoadJSONForRequestWithTTL<CommitFilePatchResult>(
218 cacheCtx,
219 patchCacheKey,
220 loader,
221 () => 86400
222 );
223 return new Response(JSON.stringify(patch), {
224 headers: {
225 "Content-Type": "application/json; charset=utf-8",
226 "Cache-Control": "no-store, no-cache, must-revalidate",
227 "X-Page-Renderer": "react-fragment-json",
228 },
229 });
230 } catch (e) {
231 return handleError(env, e, `Error · ${owner}/${repo}`, {
232 owner,
233 repo,
234 refEnc: encodeURIComponent(oid),
235 path,
236 });
237 }
238}
239 
240export async function handleCommit(c: AppContext<"/:owner/:repo/commit/:oid">) {
241 const env = c.env;
242 const owner = c.req.param("owner");
243 const repo = c.req.param("repo");
244 const oid = c.req.param("oid");
245 const access = await resolveUiRepoAccess(c, owner, repo);
246 if (access.kind === "response") return access.response;
247 const { route, cacheCtx } = access;
248 const isPrivate = isRequestPrivate(cacheCtx);
249 if (!OID_RE.test(oid)) {
250 return badRequest(env, "Invalid commit OID", "Commit id must be 40-hex", {
251 owner,
252 repo,
253 refEnc: encodeURIComponent(oid),
254 });
255 }
256 const repoId = route.doName;
257 try {
258 const commit = await readCommitInfo(env, repoId, oid, cacheCtx);
259 const diffLoader = async () =>
260 await listCommitChangedFiles(env, repoId, oid, cacheCtx, {
261 timeBudgetMs: 5000,
262 });
263 const diffCacheKey = buildCacheKeyFrom(c.req.raw, "/_cache/commit-diff", {
264 repo: repoId,
265 oid,
266 v: "1",
267 });
268 const diff = await cacheOrLoadJSONForRequestWithTTL<CommitDiffResult>(
269 cacheCtx,
270 diffCacheKey,
271 diffLoader,
272 () => 86400
273 );
274 const when = commit.author ? formatWhen(commit.author.when, commit.author.tz) : "";
275 const parents = (commit.parents || []).map((p) => ({ oid: p, short: p.slice(0, 7) }));
276 const progress = await loadUiRepoActivity(env, access);
277 return renderUiDocumentResponse(
278 env,
279 "commit",
280 {
281 title: `${commit.oid.slice(0, 7)} · ${owner}/${repo}`,
282 owner,
283 repo,
284 commitOid: commit.oid,
285 refEnc: encodeURIComponent(commit.oid),
286 progress,
287 commitShort: commit.oid.slice(0, 7),
288 authorName: commit.author?.name || "",
289 authorEmail: commit.author?.email || "",
290 when,
291 parents,
292 treeShort: (commit.tree || "").slice(0, 7),
293 message: commit.message || "",
294 diffBaseRefEnc: diff?.baseCommitOid ? encodeURIComponent(diff.baseCommitOid) : "",
295 diffCompareMode: diff?.compareMode || "root",
296 diffEntries: diff?.entries || [],
297 diffSummary: {
298 added: diff?.added || 0,
299 modified: diff?.modified || 0,
300 deleted: diff?.deleted || 0,
301 total: diff?.total || 0,
302 },
303 diffTruncated: diff?.truncated || false,
304 diffTruncateReason: diff?.truncateReason || "",
305 },
306 {
307 cacheControl: isPrivate ? "no-store" : undefined,
308 failureBody: "Failed to render view",
309 viewer: access.viewer,
310 }
311 );
312 } catch (e) {
313 return handleError(env, e, `Error · ${owner}/${repo}`, {
314 owner,
315 repo,
316 refEnc: encodeURIComponent(oid),
317 path: "",
318 });
319 }
320}