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