1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8
9 "github.com/a-h/templ"
10
11 "bnema.dev/ermite/internal/domain"
12 "bnema.dev/ermite/internal/ports"
13)
14
15// compare shows a branch against the default branch of the repository: the
16// commits it is ahead by and, for the owner, the form that opens a merge
17// request.
18//
19// The API has no "diff between two branches" call outside a merge request, so
20// the page shows the diff only when it is exact without one: the branch is
21// ahead by a single commit that sits on the tip of the target (that commit's
22// own diff is then the whole change). Otherwise it lists the commits, which
23// link to their own diffs, and says the whole diff comes with the merge
24// request.
25func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
26 d, err := s.loadRepo(ctx, r.PathValue("repo"))
27 if err != nil {
28 return nil, 0, err
29 }
30 src, rest, ok := resolveRef(d.branches, r.PathValue("rest"))
31 if !ok || rest != "" || d.head == nil {
32 return nil, 0, errNotFound
33 }
34 tgt := *d.head
35 name := d.name()
36
37 p := ComparePage{
38 Source: visibleText(src.ShortName), Target: visibleText(tgt.ShortName),
39 SourceHref: repoHref(name, "tree", src.ShortName), TargetHref: repoHref(name, "tree", tgt.ShortName),
40 CLI: Clone{ID: "cli-create", Lines: []string{
41 "ermite mr create " + shellWord(name) + " --source " + shellWord(src.ShortName) + " --title <title>",
42 }},
43 }
44
45 same := src.ShortName == tgt.ShortName
46 var ahead []domain.Commit
47 exact, behind := false, false
48 if same {
49 p.Note = "this is the default branch: there is nothing to compare it with."
50 p.AheadText = "0 commits"
51 } else {
52 var comparison ports.CompareResult
53 comparison, err = s.cfg.MergeRequests.Compare(ctx, principal(ctx), d.repo.Name, tgt.CommitID, src.CommitID)
54 ahead, exact, behind = comparison.Ahead, comparison.Exact, comparison.Behind
55 if err != nil {
56 return nil, 0, err
57 }
58 now := s.cfg.Now()
59 for _, c := range ahead {
60 p.Ahead = append(p.Ahead, commitRow(name, c, now))
61 }
62 p.AheadText = plural(len(ahead), "commit", "commits")
63 if !exact {
64 p.AheadText += " or more"
65 }
66 switch {
67 case !exact:
68 p.Note = "the branch left " + p.Target + " too long ago to tell where: only the newest commits are listed."
69 case len(ahead) == 0:
70 p.Note = "nothing to merge: " + p.Target + " already has every commit of " + p.Source + "."
71 case behind:
72 p.Note = p.Target + " has moved on since the branch started: a merge request can only be merged after " +
73 p.Source + " is rebased on " + p.Target + " and pushed again."
74 }
75 }
76
77 // The exact diff: one commit on top of the tip of the target.
78 if exact && !behind && len(ahead) == 1 {
79 res, err := s.commitDiff(ctx, r, d.repo.Name, ahead[0].ID.String())
80 if err != nil {
81 return nil, 0, err
82 }
83 p.HasDiff = true
84 p.Files = s.hl.files(ctx, res.Diff.Files)
85 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86 collapseFiles(p.Files, res.Diff.Files, commitLinks(name, res.Commit.ID.String()))
87 p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
88 if n := diffNote(res.Diff); n != "" {
89 p.Note = n
90 }
91 lines, invisible := diffLines(p.Files)
92 p.Invisible = invisible
93 self := repoHref(name, "compare", src.ShortName)
94 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
95 } else if len(ahead) > 1 && exact {
96 if p.Note == "" {
97 p.Note = "the whole diff of the branch is on its merge request; each commit links to its own."
98 }
99 }
100
101 // An open request for the same pair, or the form to open one.
102 var existing *domain.MergeRequest
103 if !same {
104 existing, err = s.openRequestFor(ctx, d.repo.Name, src.ShortName, tgt.ShortName)
105 if err != nil {
106 return nil, 0, err
107 }
108 }
109 switch {
110 case existing != nil:
111 p.Existing = &Link{Label: "#" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)}
112 case SignedIn(ctx) && !same && len(ahead) > 0:
113 title := ""
114 if len(ahead) == 1 {
115 title = ahead[0].Summary
116 } else {
117 title = ahead[len(ahead)-1].Summary
118 }
119 p.Create = &CreateForm{
120 Action: mrBase(name), Source: src.ShortName, Target: tgt.ShortName,
121 Title: cutRunes(title, 120),
122 }
123 }
124
125 l := s.repoLayout(ctx, d, "merge-requests")
126 l.Title = "compare " + p.Source + " · " + name + " · " + s.instance
127 pinRef(&l, target{rev: src.CommitID.String(), name: src.ShortName})
128 l.Crumbs[0].Current = false
129 l.Crumbs = append(l.Crumbs,
130 Crumb{Label: "merge requests", Href: mrBase(name)},
131 Crumb{Label: "compare", Current: false},
132 Crumb{Label: p.Source, Current: true},
133 )
134 l.HasRail = true
135 l.Lazy = true
136 return Compare(l, p), http.StatusOK, nil
137}
138
139// openRequestFor finds the open merge request with this source and target.
140func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {
141 token := ""
142 for range maxOpenPages {
143 res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), repo, ports.ListMergeRequestsParams{
144 State: domain.MRStateOpen, PageSize: pageSize, PageToken: token,
145 })
146 if err != nil {
147 return nil, err
148 }
149 for i := range res.MergeRequests {
150 if m := &res.MergeRequests[i]; m.Source == source && m.Target == target {
151 return m, nil
152 }
153 }
154 if token = res.NextPageToken; token == "" {
155 break
156 }
157 }
158 return nil, nil
159}
160
161// cutRunes shortens s to at most n characters.
162func cutRunes(s string, n int) string {
163 r := []rune(s)
164 if len(r) <= n {
165 return s
166 }
167 return string(r[:n])
168}