internal/httpd/web.go
1915 lines · 60051 bytes
1package httpd
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "hash/fnv"
10 "io"
11 "log"
12 "math"
13 "os"
14 "path/filepath"
15
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "html/template"
19 "net/http"
20 "net/url"
21 "path"
22 "regexp"
23 "sort"
24 "strconv"
25 "strings"
26 "time"
27
28 "github.com/alecthomas/chroma/v2/formatters/html"
29 "github.com/alecthomas/chroma/v2/lexers"
30 "github.com/alecthomas/chroma/v2/styles"
31 "github.com/microcosm-cc/bluemonday"
32 "github.com/niklasfasching/go-org/org"
33 "github.com/yuin/goldmark"
34 highlighting "github.com/yuin/goldmark-highlighting/v2"
35 "github.com/yuin/goldmark/extension"
36 "github.com/yuin/goldmark/parser"
37
38 "gitbay.org/gitbay/internal/autolink"
39 "gitbay.org/gitbay/internal/control"
40 "gitbay.org/gitbay/internal/gitutil"
41 "gitbay.org/gitbay/internal/sig"
42 "gitbay.org/gitbay/internal/store"
43 "gitbay.org/gitbay/internal/web"
44)
45
46const maxRenderBytes = 1 << 20 // largest blob rendered inline
47
48func (s *Server) render(w http.ResponseWriter, page string, data any) {
49 var buf bytes.Buffer
50 if err := web.Render(&buf, page, data); err != nil {
51 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
52 return
53 }
54 w.Header().Set("Content-Type", "text/html; charset=utf-8")
55 buf.WriteTo(w)
56}
57
58// siteName is the instance's display name: the operator's [web] title,
59// or the site host when they have not set one.
60func (s *Server) siteName() string {
61 if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
62 return t
63 }
64 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
65 return strings.TrimSuffix(h, "/")
66}
67
68// stylesheetETag is the hash of what stylesheet serves, computed once:
69// a browser revalidates with If-None-Match and gets a 304 until a deploy
70// changes the bytes (#132).
71var stylesheetETag = func() string {
72 h := sha256.New()
73 h.Write(web.StyleCSS)
74 h.Write(chromaCSS)
75 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
76}()
77
78func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
79 w.Header().Set("ETag", stylesheetETag)
80 w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
81 if r.Header.Get("If-None-Match") == stylesheetETag {
82 w.WriteHeader(http.StatusNotModified)
83 return
84 }
85 w.Header().Set("Content-Type", "text/css; charset=utf-8")
86 w.Write(web.StyleCSS)
87 w.Write(chromaCSS)
88}
89
90func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
91 w.Header().Set("Content-Type", "image/svg+xml")
92 w.Write(web.FaviconSVG)
93}
94
95// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
96// so the CSP's default-src 'self' covers it — no font CDN.
97func (s *Server) font(w http.ResponseWriter, r *http.Request) {
98 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
99 if err != nil {
100 http.NotFound(w, r)
101 return
102 }
103 w.Header().Set("Content-Type", "font/woff2")
104 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
105 w.Write(data)
106}
107
108// notFound renders the designed 404 page with a 404 status. Falls back to
109// the stock plain-text response if the template fails.
110func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
111 var buf bytes.Buffer
112 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
113 http.NotFound(w, r)
114 return
115 }
116 w.Header().Set("Content-Type", "text/html; charset=utf-8")
117 w.WriteHeader(http.StatusNotFound)
118 buf.WriteTo(w)
119}
120
121// describedRepo pairs a repo with the listing metadata: description,
122// topics, license, and last-updated date.
123type describedRepo struct {
124 store.Repo
125 Desc string
126 Topics []string
127 License string
128 Updated string
129}
130
131// Archived flattens the settings flag so the reporow partial can read the
132// same field name from a describedRepo and from a profile's repo row.
133func (d describedRepo) Archived() bool { return d.Settings.Archived }
134
135func (s *Server) describeAll(repos []store.Repo) []describedRepo {
136 var out []describedRepo
137 for _, r := range repos {
138 dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
139 d := describedRepo{
140 Repo: r,
141 Desc: gitutil.ReadDescription(dir),
142 License: control.DetectLicense(dir, r.DefaultBranch),
143 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
144 }
145 d.Topics, _ = s.st.ListTopics(r.ID)
146 out = append(out, d)
147 }
148 return out
149}
150
151// index is the homepage: a dashboard for logged-in users, a landing page
152// for everyone else. The full public listing lives at /explore.
153func (s *Server) index(w http.ResponseWriter, r *http.Request) {
154 if s.cfg.Web.Mode == "accounts" {
155 if viewer := s.viewer(r); viewer.ID != 0 {
156 s.dashboard(w, r, viewer)
157 return
158 }
159 }
160 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
161 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
162 s.render(w, "landing.html", struct {
163 basePage
164 Host string
165 Accounts bool
166 Signup bool
167 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
168 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
169}
170
171func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
172 pinned, _ := s.st.PinnedRepos(viewer.ID)
173 var visible []store.Repo
174 for _, rp := range pinned {
175 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
176 if policy.CanRead(viewer, rp, grant) {
177 visible = append(visible, rp)
178 }
179 }
180 mrs, _ := s.st.DashboardMRs(viewer.ID)
181 issues, _ := s.st.DashboardIssues(viewer.ID)
182 reviews, _ := s.st.ReviewQueue(viewer.ID)
183 assigned, _ := s.st.AssignedIssues(viewer.ID)
184 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
185 s.render(w, "dashboard.html", struct {
186 basePage
187 Pinned []store.Repo
188 Reviews []store.DashboardItem
189 Assigned []store.DashboardItem
190 MRs []store.DashboardItem
191 Issues []store.DashboardItem
192 Feed []feedLine
193 }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
194}
195
196func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
197 repos, err := s.st.ListPublicRepos()
198 if err != nil {
199 http.Error(w, "internal error", http.StatusInternalServerError)
200 return
201 }
202 var viewer store.User
203 if s.cfg.Web.Mode == "accounts" {
204 viewer = s.viewer(r)
205 }
206 q := strings.TrimSpace(r.URL.Query().Get("q"))
207 s.render(w, "explore.html", struct {
208 basePage
209 Query string
210 Repos []describedRepo
211 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
212}
213
214// privacy renders the privacy page: what the gitbay software does with
215// data, plus this instance's operator-provided notes.
216func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
217 s.render(w, "privacy.html", struct {
218 basePage
219 Host string
220 Notice string
221 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
222}
223
224// filterRepos keeps repos matching the query by the same rule `repo
225// search` uses. An empty query keeps everything.
226func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
227 if q == "" {
228 return repos
229 }
230 var out []describedRepo
231 for _, d := range repos {
232 if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
233 out = append(out, d)
234 }
235 }
236 return out
237}
238
239// repoPage is the shared context for repo-scoped pages.
240type repoPage struct {
241 basePage
242 Desc string
243 Repo store.Repo
244 Ref string
245 CloneURL string
246 Dir string
247 Tab string // active tab in the repo header
248 Topics []string
249 Pinned bool // by the viewer
250 Watch string // the viewer's watch state: watching, muted, or ""
251 HasWiki bool
252 Host string
253 Mirrors []mirrorLine // repo admins only
254 CanAdmin bool // gates the settings tab
255 // OpenIssues and OpenMRs are the counts on the header tabs.
256 OpenIssues int
257 OpenMRs int
258 // RepoHome asks the layout for the full header — description, topics,
259 // website, mirrors. Every other page gets identity and tabs only, so a
260 // repo describes itself once rather than on all twelve of its pages.
261 RepoHome bool
262}
263
264// mirrorLine is the admin-only mirror status shown in the repo header.
265// It carries no credentials: the stored URL is credential-free.
266type mirrorLine struct {
267 Direction string
268 URL string
269 Target string // URL without the scheme, for display
270 Synced string
271 Error string
272}
273
274// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
275// readable "2026-08-25 03:39 UTC".
276func syncedAt(ts string) string {
277 if len(ts) < 16 {
278 return ts
279 }
280 return ts[:10] + " " + ts[11:16] + " UTC"
281}
282
283// repoFor resolves the repo for a web request; false means 404 was sent.
284// Anonymous visitors see public repos only; in accounts mode a logged-in
285// viewer additionally sees repos their grants allow. Private and missing
286// repos are indistinguishable either way.
287func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
288 var repo store.Repo
289 var viewer store.User
290 if s.cfg.Web.Mode == "accounts" {
291 viewer = s.viewer(r)
292 }
293 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
294 ok := err == nil
295 grant := ""
296 if ok {
297 if viewer.ID != 0 {
298 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
299 }
300 ok = policyCanRead(viewer, repo, grant)
301 }
302 if !ok {
303 s.notFound(w, r)
304 return repoPage{}, false
305 }
306 if ref == "" {
307 ref = repo.DefaultBranch
308 }
309 topics, _ := s.st.ListTopics(repo.ID)
310 pinned, watch := false, ""
311 if viewer.ID != 0 {
312 pinned = s.st.IsPinned(viewer.ID, repo.ID)
313 watch = s.st.RepoWatchState(repo.ID, viewer.ID)
314 }
315 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
316 var mirrors []mirrorLine
317 if canAdmin {
318 ms, _ := s.st.ListMirrors(repo.ID)
319 for _, m := range ms {
320 mirrors = append(mirrors, mirrorLine{
321 Direction: m.Direction,
322 URL: m.URL,
323 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
324 Synced: syncedAt(m.LastSync),
325 Error: m.LastError,
326 })
327 }
328 }
329 openIssues, openMRs := s.st.OpenCounts(repo.ID)
330 return repoPage{
331 basePage: s.baseFor(viewer),
332 CanAdmin: canAdmin,
333 Mirrors: mirrors,
334 Pinned: pinned,
335 Watch: watch,
336 HasWiki: s.hasWiki(repo),
337 Host: s.cfg.SiteHost(),
338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
339 Repo: repo,
340 Ref: ref,
341 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
342 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
343 Topics: topics,
344 OpenIssues: openIssues,
345 OpenMRs: openMRs,
346 }, true
347}
348
349type crumb struct {
350 Name string
351 URL string
352}
353
354// crumbs builds one crumb per path component. Every component but the
355// last is a directory and links to the tree; only the leaf is a page of
356// the given kind.
357func crumbs(p repoPage, kind, filePath string) []crumb {
358 var cs []crumb
359 parts := strings.Split(strings.Trim(filePath, "/"), "/")
360 acc := ""
361 for i, part := range parts {
362 if part == "" {
363 continue
364 }
365 acc = path.Join(acc, part)
366 k := "tree"
367 if i == len(parts)-1 {
368 k = kind
369 }
370 cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
371 }
372 return cs
373}
374
375// profileView is profile show's payload, shaped for the templates. The
376// repo rows carry the same names the reporow partial reads, so a profile
377// listing renders identically to explore's.
378// profileView is profile show's payload with the repository rows wrapped
379// so the reporow partial can reach them. The fields themselves are the
380// command's: a field it gains appears here without being re-declared.
381type profileView struct {
382 control.ProfileOut
383 Repos []profileRepoRow `json:"repos"`
384}
385
386// profileRepoRow is one repository row on a profile. The partial asks for
387// OwnerName, Name and Desc; the payload carries a path and a description.
388type profileRepoRow struct {
389 control.ProfileRepo
390}
391
392func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
393func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
394func (p profileRepoRow) Desc() string { return p.Description }
395
396// ownerPage renders /{owner} for users and orgs: the repositories the
397// viewer may see, org membership either direction. Owner names are not
398// secret (they are on every commit); repository visibility rules hold.
399func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
400 name := r.PathValue("owner")
401 var viewer store.User
402 if s.cfg.Web.Mode == "accounts" {
403 viewer = s.viewer(r)
404 }
405
406 // Everything on this page — membership, the repositories this viewer
407 // may see, the activity year — comes from profile show, so the page
408 // and the command cannot report different things.
409 var d profileView
410 code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
411 switch {
412 case code == protocol.ExitNotFound:
413 s.notFound(w, r)
414 return
415 case code != protocol.ExitOK:
416 log.Printf("profile %s: %s", name, msg)
417 http.Error(w, "internal error", http.StatusInternalServerError)
418 return
419 }
420
421 counts := make(map[string]int, len(d.Activity))
422 for _, day := range d.Activity {
423 counts[day.Date] = day.Count
424 }
425 weeks, activityTotal := activityGrid(counts)
426
427 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
428 profile := store.Profile{Description: d.Description, Website: d.Website,
429 About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
430 s.render(w, "owner.html", struct {
431 basePage
432 Owner string
433 Kind string
434 Profile store.Profile
435 AboutHTML template.HTML
436 Repos []profileRepoRow
437 Members []control.ProfileMember
438 Orgs []control.ProfileMember
439 Activity []activityWeek
440 ActivityTotal int
441 Teams []teamView
442 CanAdmin bool
443 Self bool
444 Notice string
445 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
446 d.Repos, d.Members, d.Orgs,
447 weeks, activityTotal, teams, canAdmin,
448 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
449 s.takeFlash(w, r)})
450}
451
452func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
453 p, ok := s.repoFor(w, r, "")
454 if !ok {
455 return
456 }
457 p.Tab = "files"
458 p.RepoHome = true
459 s.renderTree(w, r, p, "")
460}
461
462func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
463 p, ok := s.repoFor(w, r, r.PathValue("ref"))
464 if !ok {
465 return
466 }
467 p.Tab = "files"
468 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
469}
470
471// treePage is shared by the populated and empty-repository renders: two
472// anonymous structs drifted apart once already.
473type treePage struct {
474 repoPage
475 Crumbs []crumb
476 Prefix string
477 DirPath string
478 RefKind string
479 Entries []gitutil.TreeEntry
480 Branches []gitutil.Ref
481 ReadmeName string
482 ReadmeHTML template.HTML
483 LastCommits map[string]namedCommit
484 Tip namedCommit
485 Facts repoFacts
486}
487
488func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
489 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
490 // Empty repo: render the page with no entries rather than 404.
491 s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
492 return
493 }
494 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
495 if err != nil {
496 s.notFound(w, r)
497 return
498 }
499 // Directories first. git's tree order interleaves them with files, but
500 // a listing is scanned by shape before name. Stable, so each group
501 // keeps the ordering git gave it.
502 sort.SliceStable(entries, func(i, j int) bool {
503 return entries[i].Type == "tree" && entries[j].Type != "tree"
504 })
505 prefix := ""
506 if dirPath != "" {
507 prefix = dirPath + "/"
508 }
509
510 var readmeHTML template.HTML
511 readmeName := pickReadme(entries)
512 if readmeName != "" {
513 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
514 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
515 }
516 }
517
518 branches, _ := gitutil.Refs(p.Dir, "heads")
519 names := make([]string, 0, len(entries))
520 for _, e := range entries {
521 names = append(names, e.Name)
522 }
523 // The facts bar is about the repository, not this directory, so it is
524 // computed once at the root and left off subdirectory listings.
525 var facts repoFacts
526 if dirPath == "" {
527 facts = s.factsFor(p)
528 }
529 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
530 readmeName, readmeHTML,
531 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
532 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
533}
534
535func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
536 p, ok := s.repoFor(w, r, r.PathValue("ref"))
537 if !ok {
538 return
539 }
540 p.Tab = "files"
541 filePath := strings.Trim(r.PathValue("path"), "/")
542 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
543 if err != nil {
544 s.notFound(w, r)
545 return
546 }
547 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
548 _, image := imageTypes[strings.ToLower(path.Ext(filePath))]
549
550 var codeHTML template.HTML
551 if !binary && !image {
552 codeHTML = highlight(filePath, data)
553 }
554 // Markdown and org render like a README, with the source one click
555 // away; ?view=source shows the text instead.
556 renderable := false
557 switch path.Ext(strings.ToLower(filePath)) {
558 case ".md", ".markdown", ".org":
559 renderable = !binary
560 }
561 var renderedHTML template.HTML
562 rendered := renderable && r.URL.Query().Get("view") != "source"
563 if rendered {
564 renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
565 }
566 cs := crumbs(p, "blob", filePath)
567 base := ""
568 if len(cs) > 0 {
569 base = cs[len(cs)-1].Name
570 cs = cs[:len(cs)-1]
571 }
572 branches, _ := gitutil.Refs(p.Dir, "heads")
573 lines := 0
574 if !binary && !image && len(data) > 0 {
575 lines = bytes.Count(data, []byte("\n"))
576 if data[len(data)-1] != '\n' {
577 lines++
578 }
579 }
580 // The file listing leads with the last commit now, so the facts about
581 // the file itself are reported here instead.
582 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
583 s.render(w, "blob.html", struct {
584 repoPage
585 Crumbs []crumb
586 Base string
587 Path string
588 DirPath string
589 RefKind string
590 Binary bool
591 Image bool
592 Size int
593 Lines int
594 Exec bool
595 Symlink bool
596 Branches []gitutil.Ref
597 CodeHTML template.HTML
598 Renderable bool // markdown or org: the toggle is offered
599 Rendered bool // this response shows the rendering
600 RenderedHTML template.HTML
601 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
602 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
603}
604
605// releases lists tag-anchored releases with notes and assets.
606func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
607 p, ok := s.repoFor(w, r, "")
608 if !ok {
609 return
610 }
611 p.Tab = "releases"
612 rels, err := s.st.ListReleases(p.Repo.ID)
613 if err != nil {
614 http.Error(w, "internal error", http.StatusInternalServerError)
615 return
616 }
617 md := s.ugcFor(r, p.Repo)
618 type relView struct {
619 store.Release
620 NotesHTML template.HTML
621 }
622 var views []relView
623 for _, rel := range rels {
624 views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
625 }
626 // Tags without a release yet are what a create form can offer.
627 released := map[string]bool{}
628 for _, rel := range rels {
629 released[rel.Tag] = true
630 }
631 var freeTags []string
632 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
633 for _, tg := range tags {
634 if !released[tg.Name] {
635 freeTags = append(freeTags, tg.Name)
636 }
637 }
638 }
639 s.render(w, "releases.html", struct {
640 repoPage
641 Releases []relView
642 FreeTags []string
643 CanWrite bool
644 Notice string
645 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
646}
647
648// releaseAsset streams one uploaded asset. Tags containing '/' are not
649// reachable here (single path segment); SSH download always works.
650func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
651 p, ok := s.repoFor(w, r, "")
652 if !ok {
653 return
654 }
655 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
656 if err != nil {
657 s.notFound(w, r)
658 return
659 }
660 name := r.PathValue("name")
661 found := false
662 for _, a := range rel.Assets {
663 if a.Name == name {
664 found = true
665 }
666 }
667 if !found {
668 s.notFound(w, r)
669 return
670 }
671 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
672 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
673 if err != nil {
674 s.notFound(w, r)
675 return
676 }
677 defer f.Close()
678 w.Header().Set("Content-Type", "application/octet-stream")
679 w.Header().Set("X-Content-Type-Options", "nosniff")
680 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
681 if fi, err := f.Stat(); err == nil {
682 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
683 }
684 io.Copy(w, f)
685}
686
687// milestones lists a repo's milestones with progress.
688func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
689 p, ok := s.repoFor(w, r, "")
690 if !ok {
691 return
692 }
693 p.Tab = "issues"
694 state := r.URL.Query().Get("state")
695 if state != "closed" && state != "all" {
696 state = "open"
697 }
698 ms, err := s.st.ListMilestones(p.Repo.ID, state)
699 if err != nil {
700 http.Error(w, "internal error", http.StatusInternalServerError)
701 return
702 }
703 type msView struct {
704 store.Milestone
705 Percent int
706 }
707 var views []msView
708 for _, m := range ms {
709 v := msView{Milestone: m}
710 if total := m.OpenItems + m.ClosedItems; total > 0 {
711 v.Percent = m.ClosedItems * 100 / total
712 }
713 views = append(views, v)
714 }
715 s.render(w, "milestones.html", struct {
716 repoPage
717 State string
718 Milestones []msView
719 }{p, state, views})
720}
721
722// search runs a bounded literal git grep over the repo's default branch.
723func (s *Server) search(w http.ResponseWriter, r *http.Request) {
724 p, ok := s.repoFor(w, r, "")
725 if !ok {
726 return
727 }
728 p.Tab = "search"
729 q := strings.TrimSpace(r.URL.Query().Get("q"))
730 type matchView struct {
731 Path string
732 Line int
733 TextHTML template.HTML
734 }
735 var matches []matchView
736 var queryErr string
737 if q != "" {
738 if len(q) < 2 || len(q) > 200 {
739 queryErr = "query must be 2 to 200 characters"
740 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
741 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
742 if err != nil {
743 http.Error(w, "internal error", http.StatusInternalServerError)
744 return
745 }
746 for _, m := range raw {
747 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
748 }
749 }
750 }
751 s.render(w, "search.html", struct {
752 repoPage
753 Query string
754 QueryErr string
755 Matches []matchView
756 Capped bool
757 }{p, q, queryErr, matches, len(matches) == 200})
758}
759
760// markMatch escapes a matched line and wraps case-insensitive occurrences
761// of the query in <mark>.
762func markMatch(text, q string) template.HTML {
763 lower, lq := strings.ToLower(text), strings.ToLower(q)
764 var b strings.Builder
765 pos := 0
766 for {
767 i := strings.Index(lower[pos:], lq)
768 if i < 0 {
769 break
770 }
771 i += pos
772 b.WriteString(template.HTMLEscapeString(text[pos:i]))
773 b.WriteString("<mark>")
774 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
775 b.WriteString("</mark>")
776 pos = i + len(q)
777 }
778 b.WriteString(template.HTMLEscapeString(text[pos:]))
779 return template.HTML(b.String())
780}
781
782func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
783 p, ok := s.repoFor(w, r, r.PathValue("ref"))
784 if !ok {
785 return
786 }
787 p.Tab = "files"
788 filePath := strings.Trim(r.PathValue("path"), "/")
789
790 // Blame is a control command; the web renders what it returns rather
791 // than shelling out to git itself, so all three surfaces agree.
792 page := 1
793 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
794 page = n
795 }
796 from := (page-1)*control.BlameSpan + 1
797
798 var out struct {
799 From int `json:"from"`
800 To int `json:"to"`
801 TotalLines int `json:"total_lines"`
802 Hunks []struct {
803 SHA string `json:"sha"`
804 AuthorName string `json:"author_name"`
805 AuthorEmail string `json:"author_email"`
806 Date string `json:"date"`
807 Summary string `json:"summary"`
808 StartLine int `json:"start_line"`
809 Lines []string `json:"lines"`
810 } `json:"hunks"`
811 }
812 argv := []string{"repo", "blame", p.Repo.Path(), filePath,
813 "--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
814 var viewer store.User
815 if s.cfg.Web.Mode == "accounts" {
816 viewer = s.viewer(r)
817 }
818 msg, ok := s.runControlInto(viewer, argv, &out)
819
820 // A binary or empty file is a refusal, not a 404: the page still
821 // renders and says why there is nothing to attribute.
822 binary := false
823 if !ok {
824 if strings.Contains(msg, "is binary") {
825 binary = true
826 } else {
827 s.notFound(w, r)
828 return
829 }
830 }
831
832 type hunkView struct {
833 gitutil.BlameHunk
834 ShortSHA string
835 Date string
836 Sig sigView
837 Numbered []numberedLine
838 }
839 var hunks []hunkView
840 sigs := map[string]sigView{}
841 for _, h := range out.Hunks {
842 v, seen := sigs[h.SHA]
843 if !seen {
844 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
845 sigs[h.SHA] = v
846 }
847 date := h.Date
848 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
849 date = t.Format("2006-01-02")
850 }
851 hv := hunkView{
852 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
853 AuthorEmail: h.AuthorEmail, Summary: h.Summary,
854 StartLine: h.StartLine, Lines: h.Lines},
855 ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
856 }
857 for i, l := range h.Lines {
858 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
859 }
860 hunks = append(hunks, hv)
861 }
862
863 pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
864 if pages == 0 {
865 pages = 1
866 }
867 if page > pages {
868 page = pages
869 }
870
871 cs := crumbs(p, "blame", filePath)
872 base := ""
873 if len(cs) > 0 {
874 base = cs[len(cs)-1].Name
875 cs = cs[:len(cs)-1]
876 }
877 s.render(w, "blame.html", struct {
878 repoPage
879 Crumbs []crumb
880 Base string
881 Path string
882 Binary bool
883 Hunks []hunkView
884 Page, Pages int
885 }{p, cs, base, filePath, binary, hunks, page, pages})
886}
887
888type numberedLine struct {
889 N int
890 Text string
891}
892
893// chromaFormatter emits class-based markup (no inline colors), so the
894// stylesheet can swap palettes with the color scheme.
895var chromaFormatter = html.New(html.WithClasses(true),
896 html.WithLineNumbers(true), html.LineNumbersInTable(false),
897 html.WithLinkableLineNumbers(true, "L"))
898
899func highlight(filePath string, data []byte) template.HTML {
900 lexer := lexers.Match(filePath)
901 if lexer == nil {
902 lexer = lexers.Fallback
903 }
904 iterator, err := lexer.Tokenise(nil, string(data))
905 if err != nil {
906 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
907 }
908 var buf bytes.Buffer
909 if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
910 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
911 }
912 return template.HTML(buf.String())
913}
914
915// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
916// The light one cannot be left unscoped: the two palettes do not name the
917// same token set, and every token github-dark omits would keep its
918// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
919// Scoped, an unnamed token inherits the wrapper's colour instead, which is
920// readable in both. The site's --code-bg stays the background either way.
921// lightStyle and darkStyle are chosen on measured contrast against the
922// grounds code actually sits on here — page, code block, and the diff
923// tints. friendly, the chroma default, put 61 token/ground pairs under
924// 4.5:1; xcode puts one.
925const (
926 lightStyle = "xcode"
927 darkStyle = "github-dark"
928)
929
930var chromaCSS = func() []byte {
931 var buf bytes.Buffer
932 buf.WriteString("@media (prefers-color-scheme: light) {\n")
933 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
934 // xcode's NameAttribute is its one token under 4.5:1 against the diff
935 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
936 buf.WriteString(".chroma .na { color: #6f5a21 }\n")
937 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
938 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
939 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
940 // Line numbers take the site's own gutter colour in both schemes. Left
941 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
942 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
943 // latter is a formatter fallback, not a style entry, so no palette test
944 // can see it.
945 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
946 return buf.Bytes()
947}()
948
949func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
950 p, ok := s.repoFor(w, r, r.PathValue("ref"))
951 if !ok {
952 return
953 }
954 filePath := strings.Trim(r.PathValue("path"), "/")
955 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
956 if err != nil {
957 s.notFound(w, r)
958 return
959 }
960 // Serve inert: never let repo content execute in the forge's origin.
961 // Images get their real type so <img> works under nosniff; SVG script
962 // is dead on arrival because the instance CSP is script-src 'none'.
963 ct := "text/plain; charset=utf-8"
964 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
965 ct = t
966 }
967 w.Header().Set("Content-Type", ct)
968 w.Header().Set("X-Content-Type-Options", "nosniff")
969 w.Write(data)
970}
971
972// imageTypes are the formats raw serves with a real content type and blob
973// pages preview inline.
974var imageTypes = map[string]string{
975 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
976 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
977 ".svg": "image/svg+xml", ".ico": "image/x-icon",
978}
979
980// readmeRank orders competing README files: richer renderers win.
981var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
982
983// pickReadme returns the best README-ish blob in a tree listing: any file
984// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
985// we can render richly.
986func pickReadme(entries []gitutil.TreeEntry) string {
987 best, bestRank := "", 1<<30
988 for _, e := range entries {
989 if e.Type != "blob" {
990 continue
991 }
992 lower := strings.ToLower(e.Name)
993 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
994 continue
995 }
996 rank, ok := readmeRank[path.Ext(lower)]
997 if !ok {
998 rank = 10 // plaintext fallback
999 }
1000 if rank < bestRank {
1001 best, bestRank = e.Name, rank
1002 }
1003 }
1004 return best
1005}
1006
1007// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1008// task lists) on top of CommonMark, with class-based fence highlighting
1009// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1010// dropped.
1011// Headings carry ids so a README or wiki section can be linked to, the
1012// way org headings already are (#132).
1013var markdown = goldmark.New(
1014 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1015 goldmark.WithExtensions(extension.GFM,
1016 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1017
1018// fenceHighlight renders one code block with chroma classes, for org and
1019// anything else outside goldmark. Unknown languages fall back to plain.
1020func fenceHighlight(source, lang string) string {
1021 lexer := lexers.Get(lang)
1022 if lexer == nil {
1023 lexer = lexers.Fallback
1024 }
1025 iterator, err := lexer.Tokenise(nil, source)
1026 if err != nil {
1027 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1028 }
1029 var buf bytes.Buffer
1030 f := html.New(html.WithClasses(true))
1031 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1032 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1033 }
1034 return buf.String()
1035}
1036
1037// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1038// goldmark's default renderer drops raw HTML, so this is safe as-is.
1039func mdHTML(raw string) template.HTML {
1040 if strings.TrimSpace(raw) == "" {
1041 return ""
1042 }
1043 var buf bytes.Buffer
1044 if markdown.Convert([]byte(raw), &buf) != nil {
1045 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1046 }
1047 return template.HTML(buf.String())
1048}
1049
1050// aboutHTML renders a profile's about text. It has no filename to
1051// dispatch on, so the stored format picks the extension; anything other
1052// than org is markdown.
1053func aboutHTML(p store.Profile) template.HTML {
1054 if strings.TrimSpace(p.About) == "" {
1055 return ""
1056 }
1057 name := "about.md"
1058 if p.AboutFormat == "org" {
1059 name = "about.org"
1060 }
1061 return renderReadme(name, []byte(p.About))
1062}
1063
1064// webResolver answers autolink lookups for one viewer. Cross-repo
1065// references to repositories the viewer cannot read stay plain text, per
1066// the enumeration rule: a link would confirm the repo exists.
1067type webResolver struct {
1068 s *Server
1069 viewer store.User
1070}
1071
1072func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1073 repo, err := r.s.st.RepoByPath(owner + "/" + name)
1074 if err != nil {
1075 return ""
1076 }
1077 grant := ""
1078 if r.viewer.ID != 0 {
1079 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1080 }
1081 if !policy.CanRead(r.viewer, repo, grant) {
1082 return ""
1083 }
1084 if kind == '#' {
1085 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1086 return ""
1087 }
1088 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1089 }
1090 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1091 return ""
1092 }
1093 return autolink.MRURL(repo.OwnerName, repo.Name, n)
1094}
1095
1096func (r webResolver) UserURL(name string) string {
1097 if _, err := r.s.st.UserByUsername(name); err == nil {
1098 return "/" + name
1099 }
1100 if _, err := r.s.st.OrgByName(name); err == nil {
1101 return "/" + name
1102 }
1103 return ""
1104}
1105
1106// ugcRenderer renders one user-authored body in the format it was written in.
1107// The format travels with the body: it is recorded when the text is written, so
1108// changing a preference later cannot re-interpret prose that already exists.
1109type ugcRenderer func(raw, format string) template.HTML
1110
1111// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1112// so a body stored before formats existed — and any row whose column defaulted —
1113// renders exactly as it did before.
1114//
1115// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1116// about text take, so it inherits that function's include guard and sanitising
1117// rather than growing a second org renderer to keep in step.
1118func ugcHTML(raw, format string) template.HTML {
1119 if format == "org" {
1120 return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1121 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1122 })
1123 }
1124 return mdHTML(raw)
1125}
1126
1127// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1128// ugcHTML plus cross-reference and mention autolinking for this viewer.
1129func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1130 viewer := store.User{}
1131 if s.cfg.Web.Mode == "accounts" {
1132 viewer = s.viewer(r)
1133 }
1134 res := webResolver{s, viewer}
1135 return func(raw, format string) template.HTML {
1136 h := ugcHTML(raw, format)
1137 if h == "" {
1138 return h
1139 }
1140 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1141 }
1142}
1143
1144// renderedComment pairs a comment with its rendered body for templates.
1145type renderedComment struct {
1146 Author string
1147 CreatedAt string
1148 Kind string
1149 BodyHTML template.HTML
1150}
1151
1152func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1153 var out []renderedComment
1154 for _, c := range cs {
1155 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1156 }
1157 return out
1158}
1159
1160// ugcPolicy sanitizes rendered repo content before it enters the forge's
1161// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1162// output and repo-authored HTML are not. Chroma's highlighting classes
1163// must survive; the pattern admits only short token codes, not the site's
1164// own class names.
1165var ugcPolicy = func() *bluemonday.Policy {
1166 p := bluemonday.UGCPolicy()
1167 p.AllowAttrs("class").
1168 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1169 OnElements("span", "pre", "code", "div")
1170 return p
1171}()
1172
1173// renderReadme renders a README by extension: markdown, org-mode, and
1174// (sanitized) HTML richly; everything else as escaped plaintext.
1175// orgConfig is the go-org configuration for rendering untrusted org.
1176//
1177// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1178// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1179// wiki page, a profile — so both keywords are refused outright: the file is
1180// never opened and the keyword stays the inert text it is. There is no safe
1181// subset to allow instead. An absolute path skips go-org's relative-path join,
1182// a relative one resolves against the daemon's working directory, and a repo
1183// has no directory to scope to anyway because the content came from a git
1184// object rather than a checkout.
1185//
1186// The default logger writes parse warnings to stderr, which would let pushed
1187// content write to the server's log; discard them.
1188func orgConfig() *org.Configuration {
1189 c := org.New()
1190 c.ReadFile = func(string) ([]byte, error) {
1191 return nil, errOrgIncludeDisabled
1192 }
1193 c.Log = log.New(io.Discard, "", 0)
1194 return c
1195}
1196
1197var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1198
1199// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1200// of contents: a README or wiki page is a document and carries one, an issue
1201// comment is a remark and should not sprout one above two headings. `fallback`
1202// supplies the plaintext rendering used when the writer fails.
1203func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1204 c := orgConfig()
1205 if !contents {
1206 // DefaultSettings is a fresh map per org.New(), so this is local.
1207 c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1208 }
1209 doc := c.Parse(bytes.NewReader(raw), name)
1210 writer := org.NewHTMLWriter()
1211 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1212 if inline {
1213 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1214 }
1215 return fenceHighlight(source, lang)
1216 }
1217 out, err := doc.Write(writer)
1218 if err != nil {
1219 return fallback()
1220 }
1221 return template.HTML(ugcPolicy.Sanitize(out))
1222}
1223
1224// headingTag matches an opening or closing h1..h5 tag, so a rendered
1225// document's headings can move down one level.
1226var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1227
1228// demoteHeadings moves every heading in a rendered document down one
1229// level: the page it sits on already has its h1 (the repository, the
1230// file, the wiki page), so a README's own h1 would be a second top-level
1231// heading in the outline (#133). Ids and anchors are untouched.
1232func demoteHeadings(h template.HTML) template.HTML {
1233 return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1234 sub := headingTag.FindStringSubmatch(m)
1235 return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1236 }))
1237}
1238
1239func renderReadme(name string, raw []byte) template.HTML {
1240 plain := func() template.HTML {
1241 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1242 }
1243 if gitutil.IsBinary(raw) {
1244 return ""
1245 }
1246 switch path.Ext(strings.ToLower(name)) {
1247 case ".md", ".markdown":
1248 var buf bytes.Buffer
1249 if markdown.Convert(raw, &buf) != nil {
1250 return plain()
1251 }
1252 return demoteHeadings(template.HTML(buf.String()))
1253 case ".org":
1254 return demoteHeadings(renderOrg(name, raw, true, plain))
1255 case ".html", ".htm":
1256 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1257 default:
1258 return plain()
1259 }
1260}
1261
1262type diffThread struct {
1263 ID int64
1264 Resolved string
1265 Stale bool
1266 // Pending marks a thread in the viewer's own unsubmitted review. Only
1267 // they are shown it, and the page says so, since it looks exactly
1268 // like a posted one otherwise.
1269 Pending bool
1270 CanResolve bool
1271 Comments []renderedComment
1272}
1273
1274// reviewRights decides which thread controls a viewer sees. mr resolve
1275// admits the thread author, the MR author, or anyone with write, so the
1276// page needs all three to render the button truthfully.
1277type reviewRights struct {
1278 Viewer string
1279 MRAuthor string
1280 Write bool
1281}
1282
1283func (r reviewRights) canResolve(threadAuthor string) bool {
1284 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1285}
1286
1287// attachThreads injects review threads under their anchored diff lines;
1288// threads whose anchor no longer appears (stale after force-push, or on a
1289// context line outside the current diff) are returned separately.
1290func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1291 type anchor struct {
1292 path string
1293 side string
1294 line int64
1295 }
1296 // Diff-line comments have no stored format yet, so they stay markdown.
1297 // They are the one user-authored body left without the choice; see #51.
1298 threads := map[int64]*diffThread{}
1299 anchors := map[int64]anchor{}
1300 var order []int64
1301 for _, cm := range comments {
1302 if cm.ReplyTo == 0 {
1303 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1304 Pending: cm.Pending,
1305 CanResolve: rights.canResolve(cm.Author),
1306 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1307 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1308 order = append(order, cm.ID)
1309 } else if th, ok := threads[cm.ReplyTo]; ok {
1310 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1311 }
1312 }
1313 placed := map[int64]bool{}
1314 for f := range files {
1315 lines := files[f].Lines
1316 for i := range lines {
1317 for _, id := range order {
1318 if placed[id] || threads[id].Stale {
1319 continue
1320 }
1321 a := anchors[id]
1322 if lines[i].Path != a.path {
1323 continue
1324 }
1325 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1326 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1327 lines[i].Threads = append(lines[i].Threads, *threads[id])
1328 files[f].Threads++
1329 files[f].Open = true
1330 placed[id] = true
1331 }
1332 }
1333 }
1334 }
1335 var unplaced []diffThread
1336 for _, id := range order {
1337 if !placed[id] {
1338 unplaced = append(unplaced, *threads[id])
1339 }
1340 }
1341 return files, unplaced
1342}
1343
1344// markCompose opens the new-thread form under one diff line. There is no
1345// JavaScript, so "comment on this line" is a plain GET carrying the
1346// anchor and the page renders the form where the reader asked for it.
1347func markCompose(files []diffFile, q url.Values) {
1348 path := q.Get("cpath")
1349 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1350 if path == "" || line < 1 {
1351 return
1352 }
1353 old := q.Get("cside") == "old"
1354 for f := range files {
1355 for i := range files[f].Lines {
1356 ln := &files[f].Lines[i]
1357 if ln.Path != path {
1358 continue
1359 }
1360 if (old && ln.Class == "del" && ln.OldLine == line) ||
1361 (!old && ln.Class != "del" && ln.NewLine == line) {
1362 ln.Compose = true
1363 files[f].Open = true
1364 return
1365 }
1366 }
1367 }
1368}
1369
1370type sigView struct {
1371 State string
1372 Signer string
1373 Fingerprint string
1374}
1375
1376func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1377 raw, err := gitutil.ReadCommit(dir, sha)
1378 if err != nil {
1379 return sigView{State: "unsigned"}, nil
1380 }
1381 parsed, err := sig.ParseCommit(raw)
1382 if err != nil {
1383 return sigView{State: "unsigned"}, nil
1384 }
1385 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1386 if err != nil {
1387 return sigView{State: "unsigned"}, parsed
1388 }
1389 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1390 if res.SignerUserID != 0 {
1391 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1392 v.Signer = u.Username
1393 }
1394 }
1395 return v, parsed
1396}
1397
1398func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1399 ref := r.PathValue("ref")
1400 p, ok := s.repoFor(w, r, ref)
1401 if !ok {
1402 return
1403 }
1404 p.Tab = "log"
1405 const pageSize = 50
1406 // ?path= filters to commits touching one file or directory.
1407 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1408 if filePath == "." {
1409 filePath = ""
1410 }
1411 var shas []string
1412 var err error
1413 if filePath != "" {
1414 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1415 } else {
1416 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1417 }
1418 if err != nil {
1419 s.notFound(w, r)
1420 return
1421 }
1422 next := ""
1423 if len(shas) > pageSize {
1424 next = shas[pageSize]
1425 shas = shas[:pageSize]
1426 }
1427 type row struct {
1428 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1429 Sig sigView
1430 Check string // combined status, "" when none ran
1431 }
1432 names := s.authorNames()
1433 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1434 var rows []row
1435 for _, sha := range shas {
1436 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1437 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1438 if parsed != nil {
1439 rw.Subject = parsed.Subject
1440 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1441 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1442 rw.AuthorEmail = parsed.AuthorEmail
1443 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1444 }
1445 rows = append(rows, rw)
1446 }
1447 s.render(w, "log.html", struct {
1448 repoPage
1449 Commits []row
1450 NextSHA string
1451 FilePath string
1452 }{p, rows, next, filePath})
1453}
1454
1455func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1456 p, ok := s.repoFor(w, r, "")
1457 if !ok {
1458 return
1459 }
1460 p.Tab = "log"
1461 sha := r.PathValue("sha")
1462 full, err := gitutil.ResolveRef(p.Dir, sha)
1463 if err != nil {
1464 s.notFound(w, r)
1465 return
1466 }
1467 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1468 if parsed == nil {
1469 s.notFound(w, r)
1470 return
1471 }
1472 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1473 files := parseDiff(patch)
1474 committerEmail := ""
1475 if parsed.CommitterEmail != parsed.AuthorEmail {
1476 committerEmail = parsed.CommitterEmail
1477 }
1478 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1479 commitNames := s.authorNames()
1480 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1481 msg := ""
1482 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1483 msg = string(parsed.Payload[i+2:])
1484 }
1485 s.render(w, "commit.html", struct {
1486 repoPage
1487 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1488 Parents []string
1489 Sig sigView
1490 Checks []store.CommitStatus
1491 DiffFiles []diffFile
1492 DiffTruncated bool
1493 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1494 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1495 gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1496}
1497
1498// labelPalette provides default label chip colors: mid-tone hues that stay
1499// legible on light and dark backgrounds.
1500var labelPalette = []string{
1501 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1502 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1503}
1504
1505var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1506
1507// clampChip keeps a user-set label colour legible as text on both
1508// grounds. Contrast is defined on relative luminance, so that is what is
1509// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1510// and against the dark ground alike, and where the palette's own colours
1511// sit. The hue is kept; the channels are scaled in linear light (#120).
1512func clampChip(hex string) string {
1513 lin := func(c int64) float64 {
1514 v := float64(c) / 255
1515 if v <= 0.04045 {
1516 return v / 12.92
1517 }
1518 return math.Pow((v+0.055)/1.055, 2.4)
1519 }
1520 r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1521 y := 0.2126*r + 0.7152*g + 0.0722*b
1522 const lo, hi = 0.12, 0.28
1523 if y >= lo && y <= hi {
1524 return strings.ToLower(hex)
1525 }
1526 target := hi
1527 if y < lo {
1528 target = lo
1529 }
1530 if y == 0 {
1531 r, g, b = target, target, target
1532 } else {
1533 k := target / y
1534 r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1535 }
1536 enc := func(v float64) int {
1537 if v <= 0.0031308 {
1538 v *= 12.92
1539 } else {
1540 v = 1.055*math.Pow(v, 1/2.4) - 0.055
1541 }
1542 return int(math.Round(v * 255))
1543 }
1544 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1545}
1546
1547func hexByte(s string) int64 {
1548 n, _ := strconv.ParseInt(s, 16, 32)
1549 return n
1550}
1551
1552// labelColors returns a complete label-name -> chip color map for a repo:
1553// the stored labels.color when it is a valid hex color, otherwise a
1554// stable default picked from the palette by name hash.
1555func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1556 stored, _ := s.st.LabelColors(repoID)
1557 out := make(map[string]template.CSS, len(stored))
1558 for name, color := range stored {
1559 if !hexColorPat.MatchString(color) {
1560 h := fnv.New32a()
1561 h.Write([]byte(name))
1562 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1563 }
1564 out[name] = template.CSS("--chip:" + clampChip(color))
1565 }
1566 return out
1567}
1568
1569// listPage is how many issues or merge requests a list page shows before
1570// it offers the older ones (#118). Keyset paging on the number, the same
1571// cursor the commands use, so every filter carries across pages.
1572const listPage = 50
1573
1574// olderLink is the current URL with before=<number> set.
1575func olderLink(r *http.Request, before int64) string {
1576 q := r.URL.Query()
1577 q.Set("before", strconv.FormatInt(before, 10))
1578 return "?" + q.Encode()
1579}
1580
1581func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1582 p, ok := s.repoFor(w, r, "")
1583 if !ok {
1584 return
1585 }
1586 p.Tab = "issues"
1587 state := r.URL.Query().Get("state")
1588 if state != "closed" && state != "all" {
1589 state = "open"
1590 }
1591 // The same filters the CLI's issue list takes, as query parameters;
1592 // label chips and author links point here.
1593 qv := r.URL.Query()
1594 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1595 Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1596 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1597 f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1598 issues, err := s.st.QueryIssues(p.Repo.ID, f)
1599 if err != nil {
1600 http.Error(w, "internal error", http.StatusInternalServerError)
1601 return
1602 }
1603 older := ""
1604 if len(issues) > listPage {
1605 issues = issues[:listPage]
1606 older = olderLink(r, issues[len(issues)-1].Number)
1607 }
1608 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1609 for i := range issues {
1610 issues[i].Labels = labels[issues[i].ID]
1611 }
1612 }
1613 s.render(w, "issues.html", struct {
1614 repoPage
1615 State string
1616 Label string
1617 Query string
1618 Filters []listFilter
1619 Issues []store.Issue
1620 LabelColors map[string]template.CSS
1621 Older string
1622 }{p, state, f.Label, f.Search,
1623 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1624 issues, s.labelColors(p.Repo.ID), older})
1625}
1626
1627func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1628 p, ok := s.repoFor(w, r, "")
1629 if !ok {
1630 return
1631 }
1632 p.Tab = "issues"
1633 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1634 if err != nil {
1635 s.notFound(w, r)
1636 return
1637 }
1638 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1639 if err != nil {
1640 s.notFound(w, r)
1641 return
1642 }
1643 comments, err := s.st.ListIssueComments(iss.ID)
1644 if err != nil {
1645 http.Error(w, "internal error", http.StatusInternalServerError)
1646 return
1647 }
1648 md := s.ugcFor(r, p.Repo)
1649 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1650 s.render(w, "issue.html", struct {
1651 repoPage
1652 Issue store.Issue
1653 BodyHTML template.HTML
1654 Comments []renderedComment
1655 CanEdit bool
1656 CanWrite bool
1657 Milestones []store.Milestone
1658 Notice string
1659 LabelColors map[string]template.CSS
1660 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1661 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1662 milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)})
1663}
1664
1665// canEditItem: the author or anyone with write access may edit.
1666// canWriteRepo reports whether the browser session may push to the repo,
1667// which is what gates the review and merge controls.
1668func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1669 if s.cfg.Web.Mode != "accounts" {
1670 return false
1671 }
1672 u := s.viewer(r)
1673 if u.ID == 0 {
1674 return false
1675 }
1676 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1677 return policy.CanWrite(u, repo, grant)
1678}
1679
1680func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1681 if s.cfg.Web.Mode != "accounts" {
1682 return false
1683 }
1684 u := s.viewer(r)
1685 if u.ID == 0 {
1686 return false
1687 }
1688 if u.Username == author {
1689 return true
1690 }
1691 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1692 return policy.CanWrite(u, repo, grant)
1693}
1694
1695func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1696 p, ok := s.repoFor(w, r, "")
1697 if !ok {
1698 return
1699 }
1700 p.Tab = "merge requests"
1701 state := r.URL.Query().Get("state")
1702 if state == "" {
1703 state = "open"
1704 }
1705 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1706 if !valid[state] {
1707 state = "open"
1708 }
1709 qv := r.URL.Query()
1710 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1711 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1712 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1713 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1714 if err != nil {
1715 http.Error(w, "internal error", http.StatusInternalServerError)
1716 return
1717 }
1718 older := ""
1719 if len(mrs) > listPage {
1720 mrs = mrs[:listPage]
1721 older = olderLink(r, mrs[len(mrs)-1].Number)
1722 }
1723 s.render(w, "mrs.html", struct {
1724 repoPage
1725 State string
1726 Query string
1727 Filters []listFilter
1728 MRs []store.MR
1729 Older string
1730 }{p, state, mf.Search,
1731 activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs, older})
1732}
1733
1734func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1735 p, ok := s.repoFor(w, r, "")
1736 if !ok {
1737 return
1738 }
1739 p.Tab = "merge requests"
1740 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1741 if err != nil {
1742 s.notFound(w, r)
1743 return
1744 }
1745 m, err := s.st.MRByNumber(p.Repo.ID, n)
1746 if err != nil {
1747 s.notFound(w, r)
1748 return
1749 }
1750 comments, _ := s.st.ListMRComments(m.ID)
1751 reviews, _ := s.st.ListMRReviews(m.ID)
1752 // The same rule the merge gates apply, so the page cannot show an
1753 // approval the gate ignores (#147).
1754 reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
1755 reviewRows := make([]reviewRow, 0, len(reviews))
1756 for _, r := range reviews {
1757 reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
1758 }
1759 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1760 // The viewer sees their own unsubmitted review comments and nobody
1761 // else's.
1762 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1763
1764 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1765 var files []diffFile
1766 base := m.MergedBase
1767 if base == "" {
1768 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1769 base = b
1770 }
1771 }
1772 var diffTruncated bool
1773 if base != "" {
1774 if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1775 files, diffTruncated = parseDiff(patch), truncated
1776 }
1777 }
1778 md := s.ugcFor(r, p.Repo)
1779 canWrite := s.canWriteRepo(r, p.Repo)
1780 var detachedThreads []diffThread
1781 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1782 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1783 if p.Viewer != "" {
1784 markCompose(files, r.URL.Query())
1785 }
1786 stat := statOf(files)
1787 // The commits this MR carries: base..head, the same range as the diff.
1788 type commitRow struct {
1789 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1790 Sig sigView
1791 }
1792 mrNames := s.authorNames()
1793 var commits []commitRow
1794 commitsTotal := 0
1795 if base != "" {
1796 const maxMRCommits = 100
1797 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1798 commitsTotal = len(shas)
1799 if len(shas) > maxMRCommits {
1800 shas = shas[:maxMRCommits]
1801 }
1802 for _, sha := range shas {
1803 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1804 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1805 if parsed != nil {
1806 cr.Subject = parsed.Subject
1807 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1808 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1809 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1810 }
1811 commits = append(commits, cr)
1812 }
1813 }
1814 // The diff is the reason most people open a merge request, so it gets
1815 // its own view rather than a fold at the foot of the conversation.
1816 // A query parameter keeps this working without JavaScript.
1817 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1818 // The revisions this merge request has had. A stale review is the
1819 // moment someone wants to know what moved, so the link to the
1820 // range-diff belongs next to it.
1821 revisions, _ := s.st.MRHeads(m.ID)
1822 branches, _ := gitutil.Refs(p.Dir, "heads")
1823 view := r.URL.Query().Get("view")
1824 if view != "commits" && view != "diff" {
1825 view = "conversation"
1826 }
1827 // The stack around an open merge request, for the header.
1828 var stackedOn *store.MR
1829 var stacked []store.MR
1830 if m.State == "open" {
1831 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1832 stackedOn = &parent
1833 }
1834 if m.SourceRepoID == p.Repo.ID {
1835 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1836 }
1837 }
1838 s.render(w, "mr.html", struct {
1839 repoPage
1840 MR store.MR
1841 View string
1842 BodyHTML template.HTML
1843 Checks []store.Check
1844 Combined string
1845 Comments []renderedComment
1846 Reviews []reviewRow
1847 DiffFiles []diffFile
1848 DiffTruncated bool
1849 Stat diffStat
1850 Commits []commitRow
1851 CommitsTotal int
1852 Branches []gitutil.Ref
1853 CanEdit bool
1854 CanWrite bool
1855 Unresolved int
1856 Revisions []store.MRHead
1857 Notice string
1858 DetachedThreads []diffThread
1859 StackedOn *store.MR
1860 Stacked []store.MR
1861 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1862 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1863 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
1864}
1865
1866func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1867 p, ok := s.repoFor(w, r, "")
1868 if !ok {
1869 return
1870 }
1871 p.Tab = "refs"
1872 branches, _ := gitutil.Refs(p.Dir, "heads")
1873 tags, _ := gitutil.Refs(p.Dir, "tags")
1874 s.render(w, "refs.html", struct {
1875 repoPage
1876 Branches, Tags []gitutil.Ref
1877 }{p, branches, tags})
1878}
1879
1880func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1881 p, ok := s.repoFor(w, r, "")
1882 if !ok {
1883 return
1884 }
1885 file := r.PathValue("file")
1886 ref, ok := strings.CutSuffix(file, ".tar.gz")
1887 if !ok {
1888 s.notFound(w, r)
1889 return
1890 }
1891 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1892 s.notFound(w, r)
1893 return
1894 }
1895 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1896 w.Header().Set("Content-Type", "application/gzip")
1897 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1898 gitutil.Archive(p.Dir, ref, prefix, w)
1899}
1900
1901func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1902 return policy.CanAdmin(u, repo, grant)
1903}
1904
1905func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1906 return policy.CanRead(u, repo, grant)
1907}
1908
1909// reviewRow is a review with whether the merge gates count it, which
1910// depends on the reviewer's access and so is not a property of the
1911// review row itself.
1912type reviewRow struct {
1913 store.MRReview
1914 Counts bool
1915}