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 "connectrpc.com/connect"
15 "github.com/a-h/templ"
16
17 pb "bnema.dev/ermite-api/gen/ermite"
18)
19
20// The merge request pages (ADR 0017). They read through MergeRequestService
21// and GitService 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 API are read whole.
48 mrPerPage = 50
49 // maxMRPages bounds the API pages 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}
151
152// ModeOption is a merge mode the repository allows.
153type ModeOption struct{ Value, Label string }
154
155// SinceNav switches between the whole diff and the diff since the last read.
156type SinceNav struct {
157 AllHref, SinceHref string
158 Files int
159 ReadShort string
160 HeadShort string
161 Current bool
162}
163
164// MRPage is a merge request with its diff and comments.
165type MRPage struct {
166 Number string
167 Title string
168 State string
169 Open bool
170 Signed bool
171 Source, Target string
172 SourceHref string
173 TargetHref string
174 Author string
175 Opened Person
176 Updated string
177 UpdatedAt string
178 Desc []TextPart
179 Head string
180 HeadShort string
181 MergedShort string
182 MergedHref string
183 MergeMode string
184
185 Since *SinceNav
186 SinceNote string
187
188 NFiles, Adds, Dels int
189 Files []DiffFile
190 DiffView
191 Note string
192 Invisible bool
193
194 // General are the comments that are not on a line, Loose those on a line
195 // that the diff shown does not have.
196 General, Loose []CommentView
197 NComments int
198 NUnresolved int
199 GeneralForm *CommentForm
200
201 Commits []MRCommit
202 CommitsNote string
203 Modes []string
204 ChecksBlock bool
205
206 Box *MergeBox
207 // Live is true when the page announces changes.
208 Live bool
209}
210
211// CompareCommit is a commit of a branch that the target lacks.
212type CompareCommit = CommitRow
213
214// ComparePage is a branch compared with the default branch.
215type ComparePage struct {
216 Source, Target string
217 SourceHref string
218 TargetHref string
219 Ahead []CommitRow
220 AheadText string
221 CLI Clone
222 // Note says what the page could not tell.
223 Note string
224 // The diff is the one of the single commit the branch is ahead by.
225 NFiles, Adds, Dels int
226 Files []DiffFile
227 DiffView
228 Invisible bool
229 HasDiff bool
230 // Existing is the open merge request for this pair, when there is one.
231 Existing *Link
232 // Create is the form to open one; nil when the reader cannot or there is
233 // one already.
234 Create *CreateForm
235}
236
237// CreateForm is the POST that opens a merge request.
238type CreateForm struct {
239 Action, Source, Target, Title string
240}
241
242// mrBase is the URL of the merge request pages of a repository.
243func mrBase(repo string) string { return repoHref(repo, "merge-requests") }
244
245func mrHref(repo string, n int64, sub ...string) string {
246 return repoHref(repo, append([]string{"merge-requests", strconv.FormatInt(n, 10)}, sub...)...)
247}
248
249// mrNumber reads the number of the path: a positive decimal number, written
250// without sign or leading zeros, else the page is not found.
251func mrNumber(r *http.Request) (int64, error) {
252 s := r.PathValue("n")
253 n, err := strconv.ParseInt(s, 10, 64)
254 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
255 return 0, errNotFound
256 }
257 return n, nil
258}
259
260func stateName(s pb.MergeRequestState) string {
261 switch s {
262 case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
263 return "open"
264 case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
265 return "merged"
266 case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
267 return "closed"
268 }
269 return "unknown"
270}
271
272func modeName(m pb.MergeMode) string {
273 switch m {
274 case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
275 return "fast-forward"
276 case pb.MergeMode_MERGE_MODE_SQUASH:
277 return "squash"
278 }
279 return ""
280}
281
282// mrRow converts a merge request of a listing.
283func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow {
284 row := MRRow{
285 Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()),
286 Title: visibleText(m.GetTitle()), State: stateName(m.GetState()),
287 Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()),
288 Author: visibleText(m.GetAuthor()),
289 }
290 ts := m.GetUpdatedAt()
291 if !ts.IsValid() {
292 ts = m.GetCreatedAt()
293 }
294 row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
295 return row
296}
297
298// mrList lists the merge requests: the open ones, then the merged and closed
299// ones, a page at a time. The decided ones are paged with the page token of
300// the API in "page"; the open ones show on the first page only.
301func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
302 d, err := s.loadRepo(ctx, r.PathValue("repo"))
303 if err != nil {
304 return nil, 0, err
305 }
306 token := r.URL.Query().Get("page")
307 p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())}
308 p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
309 base := mrBase(d.name())
310
311 if token == "" {
312 tok := ""
313 for i := 0; i < maxOpenPages; i++ {
314 res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
315 Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok,
316 }))
317 if err != nil {
318 return nil, 0, err
319 }
320 for _, m := range res.Msg.GetMergeRequests() {
321 p.Open = append(p.Open, s.mrRow(d.name(), m))
322 }
323 if tok = res.Msg.GetNextPageToken(); tok == "" {
324 break
325 }
326 }
327 if tok != "" {
328 p.OpenNote = "more open merge requests than are listed here."
329 }
330 }
331
332 tok := token
333 for i := 0; i < maxMRPages; i++ {
334 res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
335 Repo: d.name(), PageSize: pageSize, PageToken: tok,
336 }))
337 if err != nil {
338 return nil, 0, err
339 }
340 for _, m := range res.Msg.GetMergeRequests() {
341 if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
342 p.Done = append(p.Done, s.mrRow(d.name(), m))
343 }
344 }
345 tok = res.Msg.GetNextPageToken()
346 if tok == "" || len(p.Done) >= mrPerPage {
347 break
348 }
349 }
350 if tok != "" {
351 p.Older = base + "?page=" + url.QueryEscape(tok)
352 }
353 if token != "" {
354 p.Newest = base
355 }
356 switch {
357 case len(p.Open) > 0:
358 p.Open[0].Cursor = true
359 case len(p.Done) > 0:
360 p.Done[0].Cursor = true
361 }
362 for _, b := range d.branches {
363 if b.GetShortName() == d.repo.GetDefaultBranch() || len(p.Branches) >= railBranches {
364 continue
365 }
366 p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.GetShortName()), Href: repoHref(d.name(), "compare", b.GetShortName())})
367 }
368
369 l := s.repoLayout(ctx, d, "merge-requests")
370 l.Title = "merge requests · " + d.name() + " · " + s.instance
371 l.Crumbs[0].Current = false
372 l.Crumbs = append(l.Crumbs, Crumb{Label: "merge requests", Current: true})
373 if token == "" {
374 p.Live = true
375 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
376 }
377 rows := len(p.Open) + len(p.Done)
378 l.Status = Status{
379 Mode: "merge requests", Context: itoa(len(p.Open)) + " open · " + itoa(len(p.Done)) + " decided",
380 Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: rows,
381 }
382 l.HasRail, l.RailSum = true, "states · open one"
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
396func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
397 n, err := mrNumber(r)
398 if err != nil {
399 return nil, 0, err
400 }
401 if since && !SignedIn(ctx) {
402 // The read position is the owner's: there is nothing to show others.
403 return nil, 0, errNotFound
404 }
405 d, err := s.loadRepo(ctx, r.PathValue("repo"))
406 if err != nil {
407 return nil, 0, err
408 }
409 res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{
410 Repo: d.name(), Number: n, SinceLastRead: since,
411 }))
412 if err != nil {
413 return nil, 0, err
414 }
415 m := res.Msg.GetMergeRequest()
416 if m == nil {
417 return nil, 0, errNotFound
418 }
419 now := s.cfg.Now()
420 signed := SignedIn(ctx)
421 open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
422 head := m.GetHeadId()
423 self := mrHref(d.name(), n)
424 if since {
425 self = mrHref(d.name(), n, "since")
426 }
427
428 p := MRPage{
429 Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()),
430 State: stateName(m.GetState()), Open: open, Signed: signed,
431 Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()),
432 Author: visibleText(m.GetAuthor()),
433 Desc: plainParts(m.GetDescription()),
434 Head: head, HeadShort: short(head),
435 NFiles: int(res.Msg.GetStats().GetFilesChanged()),
436 Adds: int(res.Msg.GetStats().GetAdditions()),
437 Dels: int(res.Msg.GetStats().GetDeletions()),
438 Files: diffFiles(res.Msg.GetDiff().GetFiles()),
439 }
440 p.Opened.Name = p.Author
441 p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt())
442 if t := m.GetCreatedAt(); t.IsValid() {
443 p.Opened.Abs = absolute(t.AsTime())
444 }
445 p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt())
446 for _, b := range d.branches {
447 switch b.GetShortName() {
448 case m.GetSourceBranch():
449 p.SourceHref = repoHref(d.name(), "tree", b.GetShortName())
450 case m.GetTargetBranch():
451 p.TargetHref = repoHref(d.name(), "tree", b.GetShortName())
452 }
453 }
454 if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" {
455 p.MergedShort = short(m.GetMergedCommitId())
456 p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId())
457 p.MergeMode = modeName(m.GetMergeMode())
458 }
459 if st := d.repo.GetSettings(); st != nil {
460 for _, mode := range st.GetAllowedMergeModes() {
461 if name := modeName(mode); name != "" {
462 p.Modes = append(p.Modes, name)
463 }
464 }
465 p.ChecksBlock = st.GetFailingChecksBlock()
466 }
467 p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
468
469 // The commits of the request, newest first.
470 for _, c := range res.Msg.GetCommits() {
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.Msg.GetLastReadId()
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.Msg.GetCommits() {
481 if c.GetId() != lastRead {
482 continue
483 }
484 for j := 0; j < i; j++ {
485 p.Commits[j].New = true
486 }
487 }
488 }
489 if res.Msg.GetCommitsTruncated() {
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.Msg.GetLastReadLost():
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.Msg.GetComments(), 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, 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.GetSourceBranch()})
543 if !d.hasBranch(m.GetSourceBranch()) {
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.GetSourceBranch()))
557 l.Status = Status{
558 Mode: "merge request", Context: p.Number + " · " + p.Source + " → " + p.Target + " · " + p.State,
559 Hints: "n next note · d split · u unified · x collapse all · ? help",
560 }
561 l.HasRail, l.RailSum = true, "merge · checks · commits · files"
562 return MergeRequest(l, p), http.StatusOK, nil
563}
564
565// hasBranch tells whether the repository has the branch.
566func (d *repoData) hasBranch(name string) bool {
567 for _, b := range d.branches {
568 if b.GetShortName() == name {
569 return true
570 }
571 }
572 return false
573}
574
575// mergeBox is what the owner can do with an open request.
576func (s *Server) mergeBox(d *repoData, n int64, head, lastRead string) *MergeBox {
577 b := &MergeBox{
578 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
579 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
580 CanMarkRead: head != "" && head != lastRead,
581 }
582 ff, sq := false, false
583 if st := d.repo.GetSettings(); st != nil {
584 for _, mode := range st.GetAllowedMergeModes() {
585 switch mode {
586 case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
587 ff = true
588 b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
589 case pb.MergeMode_MERGE_MODE_SQUASH:
590 sq = true
591 b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
592 }
593 }
594 }
595 if head != "" {
596 cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
597 b.CLI = Clone{ID: "cli-merge"}
598 if ff {
599 b.CLI.Lines = append(b.CLI.Lines, cmd)
600 }
601 if sq {
602 b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
603 }
604 }
605 return b
606}
607
608// shellWord quotes s for a shell unless it is made of safe characters, which
609// a repository name always is.
610func shellWord(s string) string {
611 for _, c := range []byte(s) {
612 if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
613 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
614 }
615 }
616 return s
617}
618
619// lineRef designates a line of the diff in the query of the comment form:
620// "?on=new:12:path/to/file".
621type lineRef struct {
622 side string
623 line int
624 path string
625}
626
627func parseOn(s string) *lineRef {
628 side, rest, ok := strings.Cut(s, ":")
629 if !ok || (side != "new" && side != "old") {
630 return nil
631 }
632 num, path, ok := strings.Cut(rest, ":")
633 line, err := strconv.Atoi(num)
634 if !ok || err != nil || line < 1 || path == "" {
635 return nil
636 }
637 return &lineRef{side: side, line: line, path: path}
638}
639
640// placeComments puts every comment under its line when the diff shown has it,
641// else in the list below the diff. The comments name lines of the diff against
642// the target, so a page that shows another diff (since the last read) lists
643// them all below, with their line. A reader who can write gets the line numbers
644// as links to the form of their line; on designates the line whose form shows.
645func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) {
646 now := s.cfg.Now()
647 self := mrHref(repo, n)
648 resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
649
650 type lineKey struct {
651 path string
652 side pb.DiffSide
653 line int
654 }
655 index := map[lineKey]*DiffLine{}
656 fileIDs := map[string]string{}
657 for fi := range p.Files {
658 f := &p.Files[fi]
659 fileIDs[f.Key] = f.ID
660 linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
661 for hi := range f.Hunks {
662 for li := range f.Hunks[hi].Lines {
663 ln := &f.Hunks[hi].Lines[li]
664 if ln.oldN > 0 && ln.Kind != "add" {
665 index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln
666 if linkable {
667 ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
668 }
669 }
670 if ln.newN > 0 && ln.Kind != "del" {
671 index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln
672 if linkable {
673 ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
674 }
675 }
676 }
677 }
678 }
679 find := func(path string, side pb.DiffSide, line int) *DiffLine {
680 if !inline {
681 return nil
682 }
683 return index[lineKey{path, side, line}]
684 }
685
686 for _, c := range comments {
687 v := CommentView{
688 ID: c.GetId(), Author: visibleText(c.GetAuthor()),
689 Parts: plainParts(c.GetText()), Resolved: c.GetResolved(),
690 Outdated: c.GetCommitId() != "" && c.GetCommitId() != head,
691 }
692 v.When, v.WhenAt = whenOf(now, c.GetCreatedAt())
693 if t := c.GetCreatedAt(); t.IsValid() {
694 v.Abs = absolute(t.AsTime())
695 }
696 p.NComments++
697 if !c.GetResolved() {
698 p.NUnresolved++
699 }
700 general := c.GetPath() == ""
701 var ln *DiffLine
702 if !general {
703 ln = find(c.GetPath(), c.GetSide(), int(c.GetLine()))
704 if ln == nil {
705 side := "new"
706 if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD {
707 side = "old"
708 }
709 v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")"
710 }
711 }
712 if canWrite {
713 v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "[resolve]"}
714 if c.GetResolved() {
715 v.Resolve.Label = "[unresolve]"
716 }
717 }
718 switch {
719 case ln != nil:
720 ln.Notes = append(ln.Notes, v)
721 case general:
722 p.General = append(p.General, v)
723 default:
724 p.Loose = append(p.Loose, v)
725 }
726 }
727
728 if on != nil {
729 side := pb.DiffSide_DIFF_SIDE_NEW
730 if on.side == "old" {
731 side = pb.DiffSide_DIFF_SIDE_OLD
732 }
733 if ln := find(on.path, side, on.line); ln != nil {
734 ln.Form = &CommentForm{
735 ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
736 Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
737 Cancel: self + "#" + fileIDs[on.path], Focus: true,
738 }
739 }
740 }
741}
742
743// userText renders the parts of a plain text: the text escaped, the http(s)
744// addresses as links. Its white space is kept by the style of the container.
745func userText(parts []TextPart) templ.Component {
746 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
747 var b strings.Builder
748 for _, part := range parts {
749 if part.Href != "" {
750 b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
751 } else {
752 b.WriteString(html.EscapeString(part.Text))
753 }
754 }
755 _, err := io.WriteString(w, b.String())
756 return err
757 })
758}