// SPDX-License-Identifier: AGPL-3.0-or-later
package web
import (
"context"
"net/http"
"slices"
"github.com/a-h/templ"
"bnema.dev/ermite/internal/domain"
"bnema.dev/ermite/internal/ports"
)
// compareDepth bounds the commits read on each side to find where a branch
// left its target.
const compareDepth = maxComparePages * pageSize
// compare shows a branch against the default branch of the repository: the
// commits it is ahead by and, for the owner, the form that opens a merge
// request.
//
// The API has no "diff between two branches" call outside a merge request, so
// the page shows the diff only when it is exact without one: the branch is
// ahead by a single commit that sits on the tip of the target (that commit's
// own diff is then the whole change). Otherwise it lists the commits, which
// link to their own diffs, and says the whole diff comes with the merge
// request.
//
//hexcheck:ignore suspicious-business-logic-in-adapter builds the compare page from the use case results; the merge rules are the use case's
func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
d, err := s.loadRepo(ctx, r.PathValue("repo"))
if err != nil {
return nil, 0, err
}
src, rest, ok := resolveRef(d.branches, r.PathValue("rest"))
if !ok || rest != "" || d.head == nil {
return nil, 0, errNotFound
}
tgt := *d.head
name := d.name()
p := ComparePage{
Source: visibleText(src.ShortName), Target: visibleText(tgt.ShortName),
SourceHref: repoHref(name, "tree", src.ShortName), TargetHref: repoHref(name, "tree", tgt.ShortName),
CLI: Clone{ID: "cli-create", Lines: []string{
"ermite mr create " + shellWord(name) + " --source " + shellWord(src.ShortName) + " --title
",
}},
}
same := src.ShortName == tgt.ShortName
var ahead []domain.Commit
exact, behind := false, false
if same {
p.Note = "this is the default branch: there is nothing to compare it with."
p.AheadText = "0 commits"
} else {
ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String())
if err != nil {
return nil, 0, err
}
now := s.cfg.Now()
for _, c := range ahead {
p.Ahead = append(p.Ahead, commitRow(name, c, now))
}
p.AheadText = plural(len(ahead), "commit", "commits")
if !exact {
p.AheadText += " or more"
}
switch {
case !exact:
p.Note = "the branch left " + p.Target + " too long ago to tell where: only the newest commits are listed."
case len(ahead) == 0:
p.Note = "nothing to merge: " + p.Target + " already has every commit of " + p.Source + "."
case behind:
p.Note = p.Target + " has moved on since the branch started: a merge request can only be merged after " +
p.Source + " is rebased on " + p.Target + " and pushed again."
}
}
// The exact diff: one commit on top of the tip of the target.
if exact && !behind && len(ahead) == 1 {
res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())
if err != nil {
return nil, 0, err
}
p.HasDiff = true
p.Files = diffFiles(res.Diff.Files)
p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
p.Note = n
}
lines, invisible := diffLines(p.Files)
p.Invisible = invisible
self := repoHref(name, "compare", src.ShortName)
p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
} else if len(ahead) > 1 && exact {
if p.Note == "" {
p.Note = "the whole diff of the branch is on its merge request; each commit links to its own."
}
}
// An open request for the same pair, or the form to open one.
var existing *domain.MergeRequest
if !same {
existing, err = s.openRequestFor(ctx, d.repo.Name, src.ShortName, tgt.ShortName)
if err != nil {
return nil, 0, err
}
}
switch {
case existing != nil:
p.Existing = &Link{Label: "#" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)}
case SignedIn(ctx) && !same && len(ahead) > 0:
title := ""
if len(ahead) == 1 {
title = ahead[0].Summary
} else {
title = ahead[len(ahead)-1].Summary
}
p.Create = &CreateForm{
Action: mrBase(name), Source: src.ShortName, Target: tgt.ShortName,
Title: cutRunes(title, 120),
}
}
l := s.repoLayout(ctx, d, "merge-requests")
l.Title = "compare " + p.Source + " · " + name + " · " + s.instance
pinRef(&l, target{rev: src.CommitID.String(), name: src.ShortName})
l.Crumbs[0].Current = false
l.Crumbs = append(l.Crumbs,
Crumb{Label: "merge requests", Href: mrBase(name)},
Crumb{Label: "compare", Current: false},
Crumb{Label: p.Source, Current: true},
)
l.HasRail = true
return Compare(l, p), http.StatusOK, nil
}
// commitsAhead lists the commits of the source that the target lacks, newest
// first. exact is false when the walk gave up before finding where the source
// meets the target. behind is true when the target has commits the source
// lacks (the tip of the target is not an ancestor of the source).
//
//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
func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) {
if srcTip == tgtTip {
return nil, true, false, nil
}
onTarget := map[string]bool{}
tokenT := ""
for len(onTarget) < compareDepth {
res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT)
if err != nil {
return nil, false, false, err
}
for _, c := range res.Commits {
onTarget[c.ID.String()] = true
}
if tokenT = res.NextPageToken; tokenT == "" {
break
}
}
token := ""
for n := 0; n < compareDepth; {
res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token)
if err != nil {
return nil, false, false, err
}
for _, c := range res.Commits {
n++
if onTarget[c.ID.String()] {
// The first commit of the source that the target has: where the
// branch left it. The target moved on when this is not its tip.
return ahead, true, c.ID.String() != tgtTip, nil
}
ahead = append(ahead, c)
}
if token = res.NextPageToken; token == "" {
// The whole history of the source is ahead: no common history.
return ahead, true, true, nil
}
}
return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil
}
// openRequestFor finds the open merge request with this source and target.
func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {
token := ""
for range maxOpenPages {
res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), repo, ports.ListMergeRequestsParams{
State: domain.MRStateOpen, PageSize: pageSize, PageToken: token,
})
if err != nil {
return nil, err
}
for i := range res.MergeRequests {
if m := &res.MergeRequests[i]; m.Source == source && m.Target == target {
return m, nil
}
}
if token = res.NextPageToken; token == "" {
break
}
}
return nil, nil
}
// cutRunes shortens s to at most n characters.
func cutRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}