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