| 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 |