Go · 21682 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "errors"
7 "html/template"
8 "net/http"
9 "net/url"
10 "regexp"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/go-chi/chi/v5"
17
18 "github.com/tenseleyFlow/shithub/internal/auth/policy"
19 diffparse "github.com/tenseleyFlow/shithub/internal/repos/diff/parse"
20 diffrender "github.com/tenseleyFlow/shithub/internal/repos/diff/render"
21 diffsource "github.com/tenseleyFlow/shithub/internal/repos/diff/source"
22 "github.com/tenseleyFlow/shithub/internal/repos/git"
23 "github.com/tenseleyFlow/shithub/internal/repos/identity"
24 "github.com/tenseleyFlow/shithub/internal/web/middleware"
25 )
26
27 // MountHistory registers the S18 routes:
28 //
29 // GET /{owner}/{repo}/commits/{ref}/* — list, with ?path= filter
30 // GET /{owner}/{repo}/commits/{ref}.atom — Atom feed
31 // GET /{owner}/{repo}/commit/{sha} — single commit
32 // GET /{owner}/{repo}/blame/{ref}/{path...} — blame
33 //
34 // Mount BEFORE /tree/{ref}/* so the more-specific paths win chi's
35 // match-by-registration-order.
36 func (h *Handlers) MountHistory(r chi.Router) {
37 // Atom is a literal-suffix match; chi can't combine wildcard with a
38 // trailing literal, so we keep `{ref}.atom` for atom-only and use
39 // `commits/*` for the HTML list (which resolves ref-with-slash).
40 r.Get("/{owner}/{repo}/commits/{ref}.atom", h.commitsAtom)
41 r.Get("/{owner}/{repo}/commits/*", h.commitsList)
42 r.Get("/{owner}/{repo}/commit/{sha}", h.commitView)
43 r.Get("/{owner}/{repo}/blame/*", h.blameView)
44 }
45
46 // commitsList renders the paginated commit history for a ref, with an
47 // optional `path` filter (set when the user clicks "history" on a blob
48 // or follows the deferred-from-S17 tree column).
49 func (h *Handlers) commitsList(w http.ResponseWriter, r *http.Request) {
50 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
51 if !ok {
52 return
53 }
54 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
55 if err != nil {
56 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
57 return
58 }
59 refs, _ := git.ListRefs(r.Context(), gitDir)
60 allNames := make([]string, 0, len(refs.Branches)+len(refs.Tags))
61 for _, b := range refs.Branches {
62 allNames = append(allNames, b.Name)
63 }
64 for _, t := range refs.Tags {
65 allNames = append(allNames, t.Name)
66 }
67 rest := strings.Trim(chi.URLParam(r, "*"), "/")
68 ref := row.DefaultBranch
69 if rest != "" {
70 segs := strings.Split(rest, "/")
71 if matched, _, ok := git.ResolveRef(allNames, segs); ok {
72 ref = matched
73 } else if len(segs[0]) == 40 && isHex(segs[0]) {
74 ref = segs[0]
75 } else {
76 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
77 return
78 }
79 }
80
81 q := r.URL.Query()
82 const perPage = 30
83 page, _ := strconv.Atoi(q.Get("page"))
84 if page < 1 {
85 page = 1
86 }
87 pathFilter := strings.TrimSpace(q.Get("path"))
88 authorFilter := strings.TrimSpace(q.Get("author"))
89 sinceRaw := strings.TrimSpace(q.Get("since"))
90 untilRaw := strings.TrimSpace(q.Get("until"))
91 since := parseDateParam(sinceRaw)
92 until := parseUntilDateParam(untilRaw)
93
94 commits, err := git.Log(r.Context(), gitDir, git.LogOptions{
95 Ref: ref,
96 MaxCount: perPage,
97 Skip: (page - 1) * perPage,
98 Path: pathFilter,
99 Author: authorFilter,
100 Since: since,
101 Until: until,
102 Follow: pathFilter != "",
103 })
104 if err != nil {
105 h.d.Logger.WarnContext(r.Context(), "commits: Log", "error", err)
106 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
107 return
108 }
109
110 resolver := identity.New(h.d.Pool)
111 rows := make([]commitRow, 0, len(commits))
112 for _, c := range commits {
113 rows = append(rows, newCommitRow(c, resolver.Resolve(r.Context(), c.AuthorEmail)))
114 }
115
116 filterValues := commitFilterValues(pathFilter, authorFilter, sinceRaw, untilRaw)
117 olderHref := ""
118 if len(commits) == perPage {
119 v := cloneURLValues(filterValues)
120 v.Set("page", strconv.Itoa(page+1))
121 olderHref = commitsHref(owner.Username, row.Name, ref, v)
122 }
123 newerHref := ""
124 if page > 1 {
125 v := cloneURLValues(filterValues)
126 v.Set("page", strconv.Itoa(page-1))
127 newerHref = commitsHref(owner.Username, row.Name, ref, v)
128 }
129 pathClearHref := ""
130 if pathFilter != "" {
131 v := cloneURLValues(filterValues)
132 v.Del("path")
133 pathClearHref = commitsHref(owner.Username, row.Name, ref, v)
134 }
135 allAuthorsValues := cloneURLValues(filterValues)
136 allAuthorsValues.Del("author")
137 selectedDate := until
138 if selectedDate.IsZero() {
139 selectedDate = since
140 }
141
142 h.d.Render.RenderPage(w, r, "repo/commits", map[string]any{
143 "Title": "Commits · " + row.Name,
144 "CSRFToken": middleware.CSRFTokenForRequest(r),
145 "Owner": owner.Username,
146 "Repo": row,
147 "RepoActions": h.repoActions(r, row.ID),
148 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
149 "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
150 "ActiveSubnav": "code",
151 "Ref": ref,
152 "PathFilter": pathFilter,
153 "PathClearHref": pathClearHref,
154 "Author": authorFilter,
155 "AuthorLabel": commitAuthorSummary(rows, authorFilter),
156 "AllAuthorsHref": commitsHref(owner.Username, row.Name, ref, allAuthorsValues),
157 "AuthorFilters": commitAuthorFilters(owner.Username, row.Name, ref, filterValues, rows, authorFilter),
158 "Since": sinceRaw,
159 "Until": untilRaw,
160 "DateLabel": commitDateSummary(sinceRaw, untilRaw),
161 "Calendar": commitCalendar(owner.Username, row.Name, ref, filterValues, selectedDate, q.Get("calendar_month"), rows, time.Now()),
162 "CommitGroups": groupCommitRows(rows),
163 "RefMenu": commitRefMenu(owner.Username, row.Name, ref, row.DefaultBranch, refs),
164 "Page": page,
165 "NewerHref": newerHref,
166 "OlderHref": olderHref,
167 "HasActiveFilters": pathFilter != "" || authorFilter != "" || sinceRaw != "" || untilRaw != "",
168 })
169 }
170
171 // commitView renders the single-commit page: subject + body, parents,
172 // committer, file-changed table. Per-file diff bodies are S19 — for
173 // now the file rows show stats only.
174 func (h *Handlers) commitView(w http.ResponseWriter, r *http.Request) {
175 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
176 if !ok {
177 return
178 }
179 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
180 if err != nil {
181 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
182 return
183 }
184 sha := chi.URLParam(r, "sha")
185 if !validateSHA(sha) {
186 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
187 return
188 }
189 detail, err := git.GetCommit(r.Context(), gitDir, sha)
190 if err != nil {
191 if errors.Is(err, git.ErrCommitNotFound) {
192 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
193 return
194 }
195 h.d.Logger.WarnContext(r.Context(), "commit: GetCommit", "error", err)
196 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
197 return
198 }
199 resolver := identity.New(h.d.Pool)
200 author := resolver.Resolve(r.Context(), detail.AuthorEmail)
201 committer := resolver.Resolve(r.Context(), detail.CommitterEmail)
202
203 // S19 diff render: source the patch from the SHA and inline-render.
204 mode := diffrender.ModeUnified
205 if r.URL.Query().Get("diff") == "split" {
206 mode = diffrender.ModeSplit
207 }
208 hideWS := r.URL.Query().Get("w") == "1"
209 patch, perr := diffsource.FromCommit(r.Context(), gitDir, detail.OID, diffsource.Options{
210 IgnoreWhitespace: hideWS, FindRenames: true,
211 })
212 var diffHTML template.HTML
213 if perr != nil {
214 h.d.Logger.WarnContext(r.Context(), "commit: diff source", "error", perr)
215 } else {
216 parsed, perr2 := diffparse.ParseBytes(patch)
217 if perr2 != nil {
218 h.d.Logger.WarnContext(r.Context(), "commit: diff parse", "error", perr2)
219 } else {
220 diffHTML = template.HTML(diffrender.Diff(parsed, diffrender.Options{Mode: mode})) //nolint:gosec // escapes inside
221 }
222 }
223
224 h.d.Render.RenderPage(w, r, "repo/commit", map[string]any{
225 "Title": detail.Subject + " · " + row.Name,
226 "CSRFToken": middleware.CSRFTokenForRequest(r),
227 "Owner": owner.Username,
228 "Repo": row,
229 "Detail": detail,
230 "Author": author,
231 "Committer": committer,
232 "BodyHTML": template.HTML(linkifyCommitBody(detail.Body)), //nolint:gosec // escaped inside
233 "DiffHTML": diffHTML,
234 "DiffMode": string(mode),
235 "HideWS": hideWS,
236 })
237 }
238
239 // blameView renders blame for a file. Reuses codeContext so the
240 // branch dropdown + breadcrumbs match the tree/blob pages.
241 func (h *Handlers) blameView(w http.ResponseWriter, r *http.Request) {
242 cc, ok := h.loadCodeContext(w, r)
243 if !ok {
244 return
245 }
246 chunks, err := git.Blame(r.Context(), cc.gitDir, git.BlameOptions{
247 Ref: cc.ref,
248 Path: cc.subpath,
249 })
250 tooLarge := errors.Is(err, git.ErrBlameTooLarge)
251 notBlob := errors.Is(err, git.ErrBlameOnBinary)
252 if err != nil && !tooLarge && !notBlob {
253 h.d.Logger.WarnContext(r.Context(), "blame: Blame", "error", err)
254 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
255 return
256 }
257
258 resolver := identity.New(h.d.Pool)
259 chunkRows := make([]blameChunkRow, 0, len(chunks))
260 for _, c := range chunks {
261 chunkRows = append(chunkRows, blameChunkRow{
262 Chunk: c,
263 Author: resolver.Resolve(r.Context(), c.AuthorEmail),
264 })
265 }
266
267 h.d.Render.RenderPage(w, r, "repo/blame", map[string]any{
268 "Title": "Blame · " + cc.subpath,
269 "CSRFToken": middleware.CSRFTokenForRequest(r),
270 "Owner": cc.owner,
271 "Repo": cc.row,
272 "Ref": cc.ref,
273 "Path": cc.subpath,
274 "Crumbs": breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
275 "Chunks": chunkRows,
276 "TooLarge": tooLarge,
277 "NotBlob": notBlob,
278 })
279 }
280
281 // commitsAtom serves the lightweight Atom feed of recent commits.
282 func (h *Handlers) commitsAtom(w http.ResponseWriter, r *http.Request) {
283 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
284 if !ok {
285 return
286 }
287 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
288 if err != nil {
289 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
290 return
291 }
292 ref := chi.URLParam(r, "ref")
293 commits, err := git.Log(r.Context(), gitDir, git.LogOptions{
294 Ref: ref, MaxCount: 50,
295 })
296 if err != nil {
297 h.d.Logger.WarnContext(r.Context(), "atom: Log", "error", err)
298 http.Error(w, "internal error", http.StatusInternalServerError)
299 return
300 }
301 w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
302 writeAtom(w, owner.Username, row.Name, ref, commits)
303 }
304
305 // commitRow / blameChunkRow attach the resolved identity to the bare
306 // git data so templates can render avatars and profile links without
307 // re-running the resolver.
308 type commitRow struct {
309 Commit git.Commit
310 Author identity.Resolved
311 AuthorLabel string
312 AuthorHref string
313 }
314
315 func newCommitRow(c git.Commit, author identity.Resolved) commitRow {
316 return commitRow{
317 Commit: c,
318 Author: author,
319 AuthorLabel: commitAuthorLabel(c, author),
320 AuthorHref: commitAuthorHref(author),
321 }
322 }
323
324 type commitGroup struct {
325 Title string
326 Rows []commitRow
327 }
328
329 type commitAuthorFilter struct {
330 Label string
331 Query string
332 Href string
333 Active bool
334 User bool
335 AvatarURL string
336 IdenticonSeed string
337 }
338
339 type commitRefOption struct {
340 Name string
341 Href string
342 Active bool
343 IsDefault bool
344 }
345
346 type commitRefMenuView struct {
347 Branches []commitRefOption
348 Tags []commitRefOption
349 }
350
351 type commitCalendarView struct {
352 MonthLabel string
353 YearLabel string
354 PrevMonthHref string
355 NextMonthHref string
356 ClearHref string
357 TodayHref string
358 Weeks [][]commitCalendarDay
359 }
360
361 type commitCalendarDay struct {
362 Label string
363 Href string
364 InMonth bool
365 IsSelected bool
366 IsToday bool
367 }
368
369 type blameChunkRow struct {
370 Chunk git.BlameChunk
371 Author identity.Resolved
372 }
373
374 func commitAuthorLabel(c git.Commit, author identity.Resolved) string {
375 if author.User {
376 if author.Username != "" {
377 return author.Username
378 }
379 return author.DisplayName
380 }
381 if strings.TrimSpace(c.AuthorName) != "" {
382 return strings.TrimSpace(c.AuthorName)
383 }
384 return strings.TrimSpace(c.AuthorEmail)
385 }
386
387 func commitAuthorHref(author identity.Resolved) string {
388 if !author.User || author.Username == "" {
389 return ""
390 }
391 return "/" + pathEscapeSegments(author.Username)
392 }
393
394 func commitAuthorQuery(row commitRow) string {
395 if row.Author.User && row.Author.Username != "" {
396 return row.Author.Username
397 }
398 if email := strings.TrimSpace(row.Commit.AuthorEmail); email != "" {
399 return email
400 }
401 return strings.TrimSpace(row.Commit.AuthorName)
402 }
403
404 func groupCommitRows(rows []commitRow) []commitGroup {
405 groups := make([]commitGroup, 0)
406 last := ""
407 for _, row := range rows {
408 title := row.Commit.AuthorWhen.Local().Format("January 2, 2006")
409 if title != last {
410 groups = append(groups, commitGroup{Title: title})
411 last = title
412 }
413 groups[len(groups)-1].Rows = append(groups[len(groups)-1].Rows, row)
414 }
415 return groups
416 }
417
418 func commitAuthorSummary(rows []commitRow, active string) string {
419 active = strings.TrimSpace(active)
420 if active == "" {
421 return "All users"
422 }
423 for _, row := range rows {
424 if strings.EqualFold(commitAuthorQuery(row), active) || strings.EqualFold(row.AuthorLabel, active) {
425 return row.AuthorLabel
426 }
427 }
428 return active
429 }
430
431 func commitAuthorFilters(owner, repoName, ref string, base url.Values, rows []commitRow, active string) []commitAuthorFilter {
432 type candidate struct {
433 filter commitAuthorFilter
434 key string
435 }
436 seen := make(map[string]struct{})
437 candidates := make([]candidate, 0, len(rows))
438 for _, row := range rows {
439 query := commitAuthorQuery(row)
440 if query == "" {
441 continue
442 }
443 key := strings.ToLower(query)
444 if _, ok := seen[key]; ok {
445 continue
446 }
447 seen[key] = struct{}{}
448 values := cloneURLValues(base)
449 values.Set("author", query)
450 values.Del("page")
451 values.Del("calendar_month")
452 candidates = append(candidates, candidate{
453 key: key,
454 filter: commitAuthorFilter{
455 Label: row.AuthorLabel,
456 Query: query,
457 Href: commitsHref(owner, repoName, ref, values),
458 Active: strings.EqualFold(active, query) || strings.EqualFold(active, row.AuthorLabel),
459 User: row.Author.User,
460 AvatarURL: row.Author.AvatarURL,
461 IdenticonSeed: row.Author.IdenticonSeed,
462 },
463 })
464 }
465 if active != "" {
466 key := strings.ToLower(active)
467 if _, ok := seen[key]; !ok {
468 values := cloneURLValues(base)
469 values.Set("author", active)
470 values.Del("page")
471 values.Del("calendar_month")
472 candidates = append(candidates, candidate{
473 key: key,
474 filter: commitAuthorFilter{
475 Label: active,
476 Query: active,
477 Href: commitsHref(owner, repoName, ref, values),
478 Active: true,
479 },
480 })
481 }
482 }
483 sort.SliceStable(candidates, func(i, j int) bool {
484 if candidates[i].filter.Active != candidates[j].filter.Active {
485 return candidates[i].filter.Active
486 }
487 return strings.ToLower(candidates[i].filter.Label) < strings.ToLower(candidates[j].filter.Label)
488 })
489 out := make([]commitAuthorFilter, 0, len(candidates))
490 for _, c := range candidates {
491 out = append(out, c.filter)
492 }
493 return out
494 }
495
496 func commitDateSummary(sinceRaw, untilRaw string) string {
497 sinceRaw = strings.TrimSpace(sinceRaw)
498 untilRaw = strings.TrimSpace(untilRaw)
499 switch {
500 case sinceRaw == "" && untilRaw == "":
501 return "All time"
502 case sinceRaw != "" && untilRaw != "":
503 return formatCommitFilterDate(sinceRaw) + " - " + formatCommitFilterDate(untilRaw)
504 case sinceRaw != "":
505 return "Since " + formatCommitFilterDate(sinceRaw)
506 default:
507 return "Until " + formatCommitFilterDate(untilRaw)
508 }
509 }
510
511 func formatCommitFilterDate(raw string) string {
512 t, err := time.Parse("2006-01-02", raw)
513 if err != nil {
514 return raw
515 }
516 return t.Format("Jan 2, 2006")
517 }
518
519 func commitRefMenu(owner, repoName, current, defaultBranch string, refs git.RefListing) commitRefMenuView {
520 out := commitRefMenuView{
521 Branches: make([]commitRefOption, 0, len(refs.Branches)),
522 Tags: make([]commitRefOption, 0, len(refs.Tags)),
523 }
524 for _, ref := range refs.Branches {
525 out.Branches = append(out.Branches, commitRefOption{
526 Name: ref.Name,
527 Href: commitsHref(owner, repoName, ref.Name, nil),
528 Active: ref.Name == current,
529 IsDefault: ref.Name == defaultBranch,
530 })
531 }
532 for _, ref := range refs.Tags {
533 out.Tags = append(out.Tags, commitRefOption{
534 Name: ref.Name,
535 Href: commitsHref(owner, repoName, ref.Name, nil),
536 Active: ref.Name == current,
537 })
538 }
539 return out
540 }
541
542 func commitCalendar(owner, repoName, ref string, base url.Values, selected time.Time, monthParam string, rows []commitRow, now time.Time) commitCalendarView {
543 if now.IsZero() {
544 now = time.Now()
545 }
546 anchor := selected
547 if anchor.IsZero() && len(rows) > 0 {
548 anchor = rows[0].Commit.AuthorWhen
549 }
550 if anchor.IsZero() {
551 anchor = now
552 }
553 if t, err := time.Parse("2006-01", strings.TrimSpace(monthParam)); err == nil {
554 anchor = t
555 }
556 loc := anchor.Location()
557 if loc == nil {
558 loc = time.Local
559 }
560 monthStart := time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, loc)
561 gridStart := monthStart.AddDate(0, 0, -int(monthStart.Weekday()))
562 weeks := make([][]commitCalendarDay, 6)
563 for week := 0; week < 6; week++ {
564 weeks[week] = make([]commitCalendarDay, 7)
565 for day := 0; day < 7; day++ {
566 d := gridStart.AddDate(0, 0, week*7+day)
567 values := cloneURLValues(base)
568 values.Set("until", d.Format("2006-01-02"))
569 values.Del("page")
570 values.Del("calendar_month")
571 weeks[week][day] = commitCalendarDay{
572 Label: strconv.Itoa(d.Day()),
573 Href: commitsHref(owner, repoName, ref, values),
574 InMonth: d.Month() == monthStart.Month(),
575 IsSelected: sameCalendarDate(d, selected),
576 IsToday: sameCalendarDate(d, now),
577 }
578 }
579 }
580
581 prevValues := cloneURLValues(base)
582 prevValues.Set("calendar_month", monthStart.AddDate(0, -1, 0).Format("2006-01"))
583 prevValues.Del("page")
584 nextValues := cloneURLValues(base)
585 nextValues.Set("calendar_month", monthStart.AddDate(0, 1, 0).Format("2006-01"))
586 nextValues.Del("page")
587 clearValues := cloneURLValues(base)
588 clearValues.Del("since")
589 clearValues.Del("until")
590 clearValues.Del("calendar_month")
591 clearValues.Del("page")
592 todayValues := cloneURLValues(base)
593 todayValues.Set("until", now.Format("2006-01-02"))
594 todayValues.Del("calendar_month")
595 todayValues.Del("page")
596
597 return commitCalendarView{
598 MonthLabel: monthStart.Format("January"),
599 YearLabel: monthStart.Format("2006"),
600 PrevMonthHref: commitsHref(owner, repoName, ref, prevValues),
601 NextMonthHref: commitsHref(owner, repoName, ref, nextValues),
602 ClearHref: commitsHref(owner, repoName, ref, clearValues),
603 TodayHref: commitsHref(owner, repoName, ref, todayValues),
604 Weeks: weeks,
605 }
606 }
607
608 func sameCalendarDate(a, b time.Time) bool {
609 if a.IsZero() || b.IsZero() {
610 return false
611 }
612 bb := b.In(a.Location())
613 return a.Year() == bb.Year() && a.Month() == bb.Month() && a.Day() == bb.Day()
614 }
615
616 func commitFilterValues(pathFilter, authorFilter, sinceRaw, untilRaw string) url.Values {
617 values := url.Values{}
618 if pathFilter != "" {
619 values.Set("path", pathFilter)
620 }
621 if authorFilter != "" {
622 values.Set("author", authorFilter)
623 }
624 if sinceRaw != "" {
625 values.Set("since", sinceRaw)
626 }
627 if untilRaw != "" {
628 values.Set("until", untilRaw)
629 }
630 return values
631 }
632
633 func commitsHref(owner, repoName, ref string, values url.Values) string {
634 path := "/" + url.PathEscape(owner) + "/" + url.PathEscape(repoName) + "/commits/" + pathEscapeSegments(ref)
635 if len(values) == 0 {
636 return path
637 }
638 encoded := values.Encode()
639 if encoded == "" {
640 return path
641 }
642 return path + "?" + encoded
643 }
644
645 func cloneURLValues(values url.Values) url.Values {
646 out := make(url.Values, len(values))
647 for k, vv := range values {
648 out[k] = append([]string(nil), vv...)
649 }
650 return out
651 }
652
653 func pathEscapeSegments(s string) string {
654 parts := strings.Split(s, "/")
655 for i, part := range parts {
656 parts[i] = url.PathEscape(part)
657 }
658 return strings.Join(parts, "/")
659 }
660
661 // validateSHA accepts 7..40 hex chars. Git resolves short SHAs when
662 // unambiguous; we cap at 40 (full).
663 func validateSHA(s string) bool {
664 if len(s) < 7 || len(s) > 40 {
665 return false
666 }
667 return isHex(s)
668 }
669
670 // parseDateParam takes a YYYY-MM-DD param and returns a UTC time. Any
671 // parse error returns the zero time, which the Log helper treats as
672 // "no filter."
673 func parseDateParam(s string) time.Time {
674 if s == "" {
675 return time.Time{}
676 }
677 t, err := time.Parse("2006-01-02", s)
678 if err != nil {
679 return time.Time{}
680 }
681 return t
682 }
683
684 func parseUntilDateParam(s string) time.Time {
685 t := parseDateParam(s)
686 if t.IsZero() {
687 return t
688 }
689 return t.Add(24*time.Hour - time.Second)
690 }
691
692 // linkifyCommitBody produces escaped + linkified HTML from a commit
693 // message body. Two transformations:
694 // 1. URL detection (http/https) → `<a href="...">URL</a>`
695 // 2. Issue refs (`#NNN` and `owner/repo#NNN`) → `<span data-ref="...">…</span>`
696 // so the S21 issue layer can post-render-link them without
697 // re-rendering the page.
698 //
699 // The output is HTML-escaped at every entry point — the only raw HTML
700 // is the wrapper tags this function emits.
701 func linkifyCommitBody(body string) string {
702 if body == "" {
703 return ""
704 }
705 escaped := template.HTMLEscapeString(body)
706 escaped = issueRefRE.ReplaceAllStringFunc(escaped, func(m string) string {
707 return `<span data-ref="` + template.HTMLEscapeString(m) + `">` + m + `</span>`
708 })
709 escaped = urlRE.ReplaceAllStringFunc(escaped, func(m string) string {
710 return `<a href="` + m + `" rel="nofollow noopener">` + m + `</a>`
711 })
712 // Preserve newlines as <br> for plaintext-style rendering.
713 return strings.ReplaceAll(escaped, "\n", "<br>")
714 }
715
716 var (
717 issueRefRE = regexp.MustCompile(`(?:[a-z0-9][a-z0-9-]*\/[a-z0-9._-]+)?#\d+`)
718 urlRE = regexp.MustCompile(`https?:\/\/[^\s<>"']+`)
719 )
720