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