1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "html"
8 "io"
9 "net/http"
10 "net/url"
11 "strconv"
12 "strings"
13
14 "github.com/a-h/templ"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20// The merge request pages (ADR 0017). They read through MergeRequestService
21// and BrowseService as the visitor of the request: anonymous sees the merge
22// requests of public repositories, the signed-in owner those of every
23// repository, and only the owner gets the forms (mr_post.go handles them).
24//
25// Routes:
26//
27// GET /<repo>/merge-requests the list, open ones first
28// GET /<repo>/merge-requests/<n> the request with its diff
29// GET /<repo>/merge-requests/<n>/since the diff since the last read
30// GET /<repo>/compare/<branch> a branch against the default branch
31//
32// Reading marks nothing as read. MarkRead stores state, and a GET must stay
33// safe: a prefetcher, a link preview or a reload of the owner's tab would move
34// the read position, and the "since your last read" diff would lose what the
35// owner never saw. So the owner marks a head read with a POST button
36// ("[mark read]", /<n>/read), which carries the head the page showed; the
37// button only shows when that head is not the one already read. The since page
38// is the owner's alone (the position is the owner's), and answers 404 to
39// anyone else.
40//
41// Every page here is rendered through page(), so for the owner it is
42// "private, no-store" with "Vary: Cookie", and for a visitor "no-cache": the
43// forms and the CLI lines exist only in the owner's copy.
44
45const (
46 // mrPerPage is how many decided merge requests a page of the list shows,
47 // at least; pages of the use case are read whole.
48 mrPerPage = 50
49 // maxMRPages bounds the pages of the use case one list page reads.
50 maxMRPages = 20
51 // maxOpenPages bounds the pages read for the open merge requests, which
52 // the first list page shows in full.
53 maxOpenPages = 5
54 // maxComparePages bounds how many pages of history the compare page reads
55 // to find where the branch left the target.
56 maxComparePages = 5
57 // railBranches is how many branches the list offers to compare.
58 railBranches = 15
59)
60
61// MRRow is one merge request in the list.
62type MRRow struct {
63 Number string
64 Href string
65 Title string
66 Branches string
67 Author string
68 State string
69 When string
70 WhenAt string
71 Cursor bool
72}
73
74// BranchLink is a branch the list offers to open a merge request for.
75type BranchLink struct{ Name, Href string }
76
77// MRListPage is the list of the merge requests of a repository.
78type MRListPage struct {
79 Open, Done []MRRow
80 // OpenNote says the open list was cut.
81 OpenNote string
82 // Older and Newest are the paging links of the decided requests.
83 Older, Newest string
84 Branches []BranchLink
85 Default string
86 CLI Clone
87 // Live is true when the page announces changes.
88 Live bool
89}
90
91// CommentView is a comment of a merge request, ready to show.
92type CommentView struct {
93 ID string
94 Author string
95 When string
96 WhenAt string
97 Abs string
98 Parts []TextPart
99 Resolved bool
100 // Outdated is true when the head moved since the comment was written, so
101 // its line may have moved too.
102 Outdated bool
103 // Where names the line of a comment that is not under a line of the diff
104 // shown ("path:12 (new)").
105 Where string
106 // Resolve is the form that resolves or unresolves it; nil when the reader
107 // cannot.
108 Resolve *ResolveForm
109}
110
111// ResolveForm is the POST that changes the resolved state of a comment.
112type ResolveForm struct {
113 Action string
114 // Value is the state the form sets.
115 Value bool
116 Label string
117}
118
119// CommentForm is the POST that adds a comment.
120type CommentForm struct {
121 ID string
122 Action string
123 Title string
124 // Path, Line and Side designate the line; empty for a general comment.
125 Path, Line, Side string
126 Cancel string
127 Focus bool
128}
129
130// MRCommit is a commit of the request.
131type MRCommit struct {
132 Short, Href, Subject, Author, When, WhenAt string
133 // New is true for a commit pushed after the head the reviewer last read.
134 New bool
135}
136
137// MergeBox is what the owner does with an open request.
138type MergeBox struct {
139 MergeAction string
140 CloseAction string
141 // ReadyAction takes a draft out of draft; empty when it is not one: a
142 // draft shows it in place of the merge form.
143 ReadyAction string
144 ReadAction string
145 Head string
146 Modes []ModeOption
147 // HeadGone is true when the source branch no longer exists: there is
148 // nothing to merge.
149 HeadGone bool
150 CLI Clone
151 // CanMarkRead is true when the head differs from the one last read.
152 CanMarkRead bool
153 // CanDeleteBranch offers to delete the source after the merge: it is not
154 // the default branch. The server checks again, with the open requests.
155 CanDeleteBranch bool
156}
157
158// ModeOption is a merge mode the repository allows.
159type ModeOption struct{ Value, Label string }
160
161// SinceNav switches between the whole diff and the diff since the last read.
162type SinceNav struct {
163 AllHref, SinceHref string
164 Files int
165 ReadShort string
166 HeadShort string
167 Current bool
168}
169
170// MRPage is a merge request with its diff and comments.
171type MRPage struct {
172 Number string
173 Title string
174 State string
175 Open bool
176 Signed bool
177 Source, Target string
178 SourceHref string
179 TargetHref string
180 Author string
181 Opened Person
182 Updated string
183 UpdatedAt string
184 Desc []TextPart
185 Head string
186 HeadShort string
187 MergedShort string
188 MergedHref string
189 MergeMode string
190
191 Since *SinceNav
192 SinceNote string
193
194 NFiles, Adds, Dels int
195 Files []DiffFile
196 DiffView
197 Note string
198 Invisible bool
199
200 // General are the comments that are not on a line, Loose those on a line
201 // that the diff shown does not have.
202 General, Loose []CommentView
203 NComments int
204 NUnresolved int
205 GeneralForm *CommentForm
206
207 Commits []MRCommit
208 CommitsNote string
209 Modes []string
210 ChecksBlock bool
211 // Checks are the CI checks of the head of the request.
212 Checks ChecksSection
213
214 Box *MergeBox
215 // Live is true when the page announces changes.
216 Live bool
217}
218
219// CompareCommit is a commit of a branch that the target lacks.
220type CompareCommit = CommitRow
221
222// ComparePage is a branch compared with the default branch.
223type ComparePage struct {
224 Source, Target string
225 SourceHref string
226 TargetHref string
227 Ahead []CommitRow
228 AheadText string
229 CLI Clone
230 // Note says what the page could not tell.
231 Note string
232 // The diff is the one of the single commit the branch is ahead by.
233 NFiles, Adds, Dels int
234 Files []DiffFile
235 DiffView
236 Invisible bool
237 HasDiff bool
238 // Existing is the open merge request for this pair, when there is one.
239 Existing *Link
240 // Create is the form to open one; nil when the reader cannot or there is
241 // one already.
242 Create *CreateForm
243}
244
245// CreateForm is the POST that opens a merge request.
246type CreateForm struct {
247 Action, Source, Target, Title string
248}
249
250// mrBase is the URL of the merge request pages of a repository.
251func mrBase(repo string) string { return repoHref(repo, "merge-requests") }
252
253func mrHref(repo string, n int64, sub ...string) string {
254 return repoHref(repo, append([]string{"merge-requests", strconv.FormatInt(n, 10)}, sub...)...)
255}
256
257// mrNumber reads the number of the path: a positive decimal number, written
258// without sign or leading zeros, else the page is not found.
259func mrNumber(r *http.Request) (int64, error) {
260 s := r.PathValue("n")
261 n, err := strconv.ParseInt(s, 10, 64)
262 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
263 return 0, errNotFound
264 }
265 return n, nil
266}
267
268// mrStateName is the state a page shows: "draft" for an open request whose
269// title marks it so.
270func mrStateName(m domain.MergeRequest) string {
271 if m.IsOpen() && m.IsDraft() {
272 return "draft"
273 }
274 return stateName(m.State)
275}
276
277func stateName(s domain.MRState) string {
278 switch s {
279 case domain.MRStateOpen:
280 return "open"
281 case domain.MRStateMerged:
282 return "merged"
283 case domain.MRStateClosed:
284 return "closed"
285 }
286 return "unknown"
287}
288
289func modeName(m domain.MergeMode) string {
290 switch m {
291 case domain.MergeModeFastForward:
292 return "fast-forward"
293 case domain.MergeModeSquash:
294 return "squash"
295 }
296 return ""
297}
298
299// mrRow converts a merge request of a listing.
300func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
301 row := MRRow{
302 Number: "#" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),
303 Title: visibleText(m.Title.String()), State: mrStateName(m),
304 Branches: visibleText(m.Source) + " → " + visibleText(m.Target),
305 Author: s.who(m.Author),
306 }
307 ts := m.UpdatedAt
308 if ts.IsZero() {
309 ts = m.CreatedAt
310 }
311 row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
312 return row
313}
314
315// mrList lists the merge requests: the open ones, then the merged and closed
316// ones, a page at a time. The decided ones are paged with the page token of
317// the use case in "page"; the open ones show on the first page only.
318func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
319 d, err := s.loadRepo(ctx, r.PathValue("repo"))
320 if err != nil {
321 return nil, 0, err
322 }
323 token := r.URL.Query().Get("page")
324 p := MRListPage{Default: visibleText(d.repo.DefaultBranch)}
325 p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
326 base := mrBase(d.name())
327
328 if token == "" {
329 tok := ""
330 for i := 0; i < maxOpenPages; i++ {
331 res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
332 State: domain.MRStateOpen, PageSize: pageSize, PageToken: tok,
333 })
334 if err != nil {
335 return nil, 0, err
336 }
337 for _, m := range res.MergeRequests {
338 p.Open = append(p.Open, s.mrRow(d.name(), m))
339 }
340 if tok = res.NextPageToken; tok == "" {
341 break
342 }
343 }
344 if tok != "" {
345 p.OpenNote = "more open merge requests than are listed here."
346 }
347 }
348
349 tok := token
350 for i := 0; i < maxMRPages; i++ {
351 res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
352 PageSize: pageSize, PageToken: tok,
353 })
354 if err != nil {
355 return nil, 0, err
356 }
357 for _, m := range res.MergeRequests {
358 if m.State != domain.MRStateOpen {
359 p.Done = append(p.Done, s.mrRow(d.name(), m))
360 }
361 }
362 tok = res.NextPageToken
363 if tok == "" || len(p.Done) >= mrPerPage {
364 break
365 }
366 }
367 if tok != "" {
368 p.Older = base + "?page=" + url.QueryEscape(tok)
369 }
370 if token != "" {
371 p.Newest = base
372 }
373 switch {
374 case len(p.Open) > 0:
375 p.Open[0].Cursor = true
376 case len(p.Done) > 0:
377 p.Done[0].Cursor = true
378 }
379 for _, b := range d.branches {
380 if b.ShortName == d.repo.DefaultBranch || len(p.Branches) >= railBranches {
381 continue
382 }
383 p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.ShortName), Href: repoHref(d.name(), "compare", b.ShortName)})
384 }
385
386 l := s.repoLayout(ctx, d, "merge-requests")
387 l.Title = "merge requests · " + d.name() + " · " + s.instance
388 l.Crumbs[0].Current = false
389 l.Crumbs = append(l.Crumbs, Crumb{Label: "merge requests", Current: true})
390 if token == "" {
391 p.Live = true
392 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
393 }
394 l.HasRail = true
395 return MRList(l, p), http.StatusOK, nil
396}
397
398// mrShow shows a merge request: the whole diff against the target.
399func (s *Server) mrShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
400 return s.mrPage(ctx, r, false)
401}
402
403// mrSince shows the diff since the head the reviewer last marked read.
404func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, int, error) {
405 return s.mrPage(ctx, r, true)
406}
407
408//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
409func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
410 n, err := mrNumber(r)
411 if err != nil {
412 return nil, 0, err
413 }
414 if since && !SignedIn(ctx) {
415 // The read position is the owner's: there is nothing to show others.
416 return nil, 0, errNotFound
417 }
418 d, err := s.loadRepo(ctx, r.PathValue("repo"))
419 if err != nil {
420 return nil, 0, err
421 }
422 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since)
423 if err != nil {
424 return nil, 0, err
425 }
426 m := res.MergeRequest
427 now := s.cfg.Now()
428 signed := SignedIn(ctx)
429 open := m.State == domain.MRStateOpen
430 head := m.HeadID.String()
431 self := mrHref(d.name(), n)
432 if since {
433 self = mrHref(d.name(), n, "since")
434 }
435
436 p := MRPage{
437 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
438 State: mrStateName(m), Open: open, Signed: signed,
439 Source: visibleText(m.Source), Target: visibleText(m.Target),
440 Author: s.who(m.Author),
441 Desc: plainParts(m.Description.String()),
442 Head: head, HeadShort: short(head),
443 NFiles: res.Stats.FilesChanged,
444 Adds: res.Stats.Additions,
445 Dels: res.Stats.Deletions,
446 Files: diffFiles(res.Diff.Files),
447 }
448 p.Opened.Name = p.Author
449 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
450 if !m.CreatedAt.IsZero() {
451 p.Opened.Abs = absolute(m.CreatedAt)
452 }
453 p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
454 for _, b := range d.branches {
455 switch b.ShortName {
456 case m.Source:
457 p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
458 case m.Target:
459 p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
460 }
461 }
462 if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
463 p.MergedShort = short(m.MergedCommitID.String())
464 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
465 p.MergeMode = modeName(m.MergeMode)
466 }
467 for _, mode := range d.repo.Settings.AllowedMergeModes {
468 if name := modeName(mode); name != "" {
469 p.Modes = append(p.Modes, name)
470 }
471 }
472 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
473 if open && head != "" {
474 p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
475 } else if head != "" {
476 p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
477 p.Checks.Note = ""
478 }
479 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
480
481 // The commits of the request, newest first.
482 for _, c := range res.Commits {
483 row := commitRow(d.name(), c, now)
484 p.Commits = append(p.Commits, MRCommit{
485 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
486 })
487 }
488 lastRead := res.LastReadID.String()
489 if signed && lastRead != "" {
490 // The commits are newest first: those before the last read head are new.
491 // A head that is not among them (the branch was rewritten) marks none.
492 for i, c := range res.Commits {
493 if c.ID.String() != lastRead {
494 continue
495 }
496 for j := 0; j < i; j++ {
497 p.Commits[j].New = true
498 }
499 }
500 }
501 if res.CommitsTruncated {
502 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
503 }
504
505 // Since the last read: the navigation, and what the page says about it.
506 if !signed {
507 lastRead = "" // the read position is the owner's
508 }
509 if signed && lastRead != "" {
510 p.Since = &SinceNav{
511 AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
512 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
513 }
514 }
515 if since {
516 switch {
517 case lastRead == "":
518 p.SinceNote = "nothing was marked read yet: this is the whole change."
519 case res.LastReadLost:
520 p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
521 case len(p.Files) == 0:
522 p.SinceNote = "nothing changed since " + short(lastRead) + "."
523 default:
524 p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
525 }
526 }
527 if p.Since != nil {
528 p.Since.Files = len(p.Files)
529 }
530
531 // Comments: under their line, else in the list below the diff. The forms
532 // are the owner's, on the whole diff of an open request.
533 canWrite := signed && open && !since
534 var on *lineRef
535 if canWrite {
536 on = parseOn(r.URL.Query().Get("on"))
537 }
538 s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
539 lines, invisible := diffLines(p.Files)
540 p.Invisible = invisible
541 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
542 if signed && open {
543 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
544 if m.IsDraft() {
545 p.Box.ReadyAction = mrHref(d.name(), n, "ready")
546 }
547 }
548 if canWrite {
549 p.GeneralForm = &CommentForm{
550 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
551 }
552 }
553
554 l := s.repoLayout(ctx, d, "merge-requests")
555 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
556 if head != "" {
557 pinRef(&l, target{rev: head, name: m.Source})
558 if !d.hasBranch(m.Source) {
559 pinRef(&l, target{rev: head, name: short(head), detached: true})
560 }
561 }
562 l.Crumbs[0].Current = false
563 l.Crumbs = append(l.Crumbs,
564 Crumb{Label: "merge requests", Href: mrBase(d.name())},
565 Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
566 )
567 if since {
568 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
569 }
570 p.Live = true
571 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))
572 l.HasRail = true
573 return MergeRequest(l, p), http.StatusOK, nil
574}
575
576// hasBranch tells whether the repository has the branch.
577func (d *repoData) hasBranch(name string) bool {
578 for _, b := range d.branches {
579 if b.ShortName == name {
580 return true
581 }
582 }
583 return false
584}
585
586// mergeBox is what the owner can do with an open request.
587func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
588 b := &MergeBox{
589 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
590 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
591 CanMarkRead: head != "" && head != lastRead,
592 CanDeleteBranch: source != d.repo.DefaultBranch,
593 }
594 ff, sq := false, false
595 for _, mode := range d.repo.Settings.AllowedMergeModes {
596 switch mode {
597 case domain.MergeModeFastForward:
598 ff = true
599 b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
600 case domain.MergeModeSquash:
601 sq = true
602 b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
603 }
604 }
605 if head != "" {
606 cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
607 b.CLI = Clone{ID: "cli-merge"}
608 if ff {
609 b.CLI.Lines = append(b.CLI.Lines, cmd)
610 }
611 if sq {
612 b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
613 }
614 }
615 return b
616}
617
618// shellWord quotes s for a shell unless it is made of safe characters, which
619// a repository name always is.
620func shellWord(s string) string {
621 for _, c := range []byte(s) {
622 if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
623 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
624 }
625 }
626 return s
627}
628
629// lineRef designates a line of the diff in the query of the comment form:
630// "?on=new:12:path/to/file".
631type lineRef struct {
632 side string
633 line int
634 path string
635}
636
637func parseOn(s string) *lineRef {
638 side, rest, ok := strings.Cut(s, ":")
639 if !ok || (side != "new" && side != "old") {
640 return nil
641 }
642 num, path, ok := strings.Cut(rest, ":")
643 line, err := strconv.Atoi(num)
644 if !ok || err != nil || line < 1 || path == "" {
645 return nil
646 }
647 return &lineRef{side: side, line: line, path: path}
648}
649
650// placeComments puts every comment under its line when the diff shown has it,
651// else in the list below the diff. The comments name lines of the diff against
652// the target, so a page that shows another diff (since the last read) lists
653// them all below, with their line. A reader who can write gets the line numbers
654// as links to the form of their line; on designates the line whose form shows.
655//
656//hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
657func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
658 now := s.cfg.Now()
659 self := mrHref(repo, n)
660 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
661
662 type lineKey struct {
663 path string
664 side domain.DiffSide
665 line int
666 }
667 index := map[lineKey]*DiffLine{}
668 fileIDs := map[string]string{}
669 for fi := range p.Files {
670 f := &p.Files[fi]
671 fileIDs[f.Key] = f.ID
672 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
673 for hi := range f.Hunks {
674 for li := range f.Hunks[hi].Lines {
675 ln := &f.Hunks[hi].Lines[li]
676 if ln.oldN > 0 && ln.Kind != "add" {
677 index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
678 if linkable {
679 ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
680 }
681 }
682 if ln.newN > 0 && ln.Kind != "del" {
683 index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
684 if linkable {
685 ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
686 }
687 }
688 }
689 }
690 }
691 find := func(path string, side domain.DiffSide, line int) *DiffLine {
692 if !inline {
693 return nil
694 }
695 return index[lineKey{path, side, line}]
696 }
697
698 for _, c := range comments {
699 cid := strconv.FormatInt(c.ID, 10)
700 v := CommentView{
701 ID: cid, Author: s.who(c.Author),
702 Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
703 Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
704 }
705 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
706 if !c.CreatedAt.IsZero() {
707 v.Abs = absolute(c.CreatedAt)
708 }
709 p.NComments++
710 if !c.Resolved {
711 p.NUnresolved++
712 }
713 general := c.Path == ""
714 var ln *DiffLine
715 if !general {
716 ln = find(c.Path, c.Side, c.Line)
717 if ln == nil {
718 side := "new"
719 if c.Side == domain.SideOld {
720 side = "old"
721 }
722 v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
723 }
724 }
725 if canWrite {
726 v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
727 if c.Resolved {
728 v.Resolve.Label = "Unresolve"
729 }
730 }
731 switch {
732 case ln != nil:
733 ln.Notes = append(ln.Notes, v)
734 case general:
735 p.General = append(p.General, v)
736 default:
737 p.Loose = append(p.Loose, v)
738 }
739 }
740
741 if on != nil {
742 side := domain.SideNew
743 if on.side == "old" {
744 side = domain.SideOld
745 }
746 if ln := find(on.path, side, on.line); ln != nil {
747 ln.Form = &CommentForm{
748 ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
749 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
750 Cancel: self + "#" + fileIDs[on.path], Focus: true,
751 }
752 }
753 }
754}
755
756// userText renders the parts of a plain text: the text escaped, the http(s)
757// addresses as links. Its white space is kept by the style of the container.
758func userText(parts []TextPart) templ.Component {
759 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
760 var b strings.Builder
761 for _, part := range parts {
762 if part.Href != "" {
763 b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
764 } else {
765 b.WriteString(html.EscapeString(part.Text))
766 }
767 }
768 _, err := io.WriteString(w, b.String())
769 return err
770 })
771}