detached 6080cbe

refactor(usecase): own branch comparison, push summaries and unread commits in MergeRequests

MergeRequests.Compare and PushSummary use IsAncestor and ListRange instead of walking one history page in the web adapter; a rewrite is detected by ancestry. Get returns NewCommits and CanMarkRead; the web page only maps them.
commit
6080cbe5e98df5635735997d4a137c6e912d4474
author
brice · · 2026-10-19 19:35 UTC
committer
brice · · 2026-10-19 20:01 UTC
parent
ac21956
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +484 -179
M internal/adapters/web/compare.go +3 -53
@@ -5,7 +5,6 @@ package web
55 import (
66 "context"
77 "net/http"
8- "slices"
98
109 "github.com/a-h/templ"
1110
@@ -13,10 +12,6 @@ import (
1312 "bnema.dev/ermite/internal/ports"
1413 )
1514
16-// compareDepth bounds the commits read on each side to find where a branch
17-// left its target.
18-const compareDepth = maxComparePages * pageSize
19-
2015 // compare shows a branch against the default branch of the repository: the
2116 // commits it is ahead by and, for the owner, the form that opens a merge
2217 // request.
@@ -54,7 +49,9 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
5449 p.Note = "this is the default branch: there is nothing to compare it with."
5550 p.AheadText = "0 commits"
5651 } else {
57- ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String())
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
5855 if err != nil {
5956 return nil, 0, err
6057 }
@@ -137,53 +134,6 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
137134 return Compare(l, p), http.StatusOK, nil
138135 }
139136
140-// commitsAhead lists the commits of the source that the target lacks, newest
141-// first. exact is false when the walk gave up before finding where the source
142-// meets the target. behind is true when the target has commits the source
143-// lacks (the tip of the target is not an ancestor of the source).
144-//
145-//hexcheck:ignore suspicious-business-logic-in-adapter business rule inferring ahead/behind from bounded history walks; pending move to domain/usecase
146-func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) {
147- if srcTip == tgtTip {
148- return nil, true, false, nil
149- }
150- onTarget := map[string]bool{}
151- tokenT := ""
152- for len(onTarget) < compareDepth {
153- res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT)
154- if err != nil {
155- return nil, false, false, err
156- }
157- for _, c := range res.Commits {
158- onTarget[c.ID.String()] = true
159- }
160- if tokenT = res.NextPageToken; tokenT == "" {
161- break
162- }
163- }
164- token := ""
165- for n := 0; n < compareDepth; {
166- res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token)
167- if err != nil {
168- return nil, false, false, err
169- }
170- for _, c := range res.Commits {
171- n++
172- if onTarget[c.ID.String()] {
173- // The first commit of the source that the target has: where the
174- // branch left it. The target moved on when this is not its tip.
175- return ahead, true, c.ID.String() != tgtTip, nil
176- }
177- ahead = append(ahead, c)
178- }
179- if token = res.NextPageToken; token == "" {
180- // The whole history of the source is ahead: no common history.
181- return ahead, true, true, nil
182- }
183- }
184- return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil
185-}
186-
187137 // openRequestFor finds the open merge request with this source and target.
188138 func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {
189139 token := ""
@@ -5,7 +5,6 @@ package web
5 import (5 import (
6 "context"6 "context"
7 "net/http"7 "net/http"
8- "slices"
9 8
10 "github.com/a-h/templ"9 "github.com/a-h/templ"
11 10
@@ -13,10 +12,6 @@ import (
13 "bnema.dev/ermite/internal/ports"12 "bnema.dev/ermite/internal/ports"
14 )13 )
15 14
16-// compareDepth bounds the commits read on each side to find where a branch
17-// left its target.
18-const compareDepth = maxComparePages * pageSize
19-
20 // compare shows a branch against the default branch of the repository: the15 // 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 merge16 // commits it is ahead by and, for the owner, the form that opens a merge
22 // request.17 // request.
@@ -54,7 +49,9 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
54 p.Note = "this is the default branch: there is nothing to compare it with."49 p.Note = "this is the default branch: there is nothing to compare it with."
55 p.AheadText = "0 commits"50 p.AheadText = "0 commits"
56 } else {51 } else {
57- ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String())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
58 if err != nil {55 if err != nil {
59 return nil, 0, err56 return nil, 0, err
60 }57 }
@@ -137,53 +134,6 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
137 return Compare(l, p), http.StatusOK, nil134 return Compare(l, p), http.StatusOK, nil
138 }135 }
139 136
140-// commitsAhead lists the commits of the source that the target lacks, newest
141-// first. exact is false when the walk gave up before finding where the source
142-// meets the target. behind is true when the target has commits the source
143-// lacks (the tip of the target is not an ancestor of the source).
144-//
145-//hexcheck:ignore suspicious-business-logic-in-adapter business rule inferring ahead/behind from bounded history walks; pending move to domain/usecase
146-func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) {
147- if srcTip == tgtTip {
148- return nil, true, false, nil
149- }
150- onTarget := map[string]bool{}
151- tokenT := ""
152- for len(onTarget) < compareDepth {
153- res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT)
154- if err != nil {
155- return nil, false, false, err
156- }
157- for _, c := range res.Commits {
158- onTarget[c.ID.String()] = true
159- }
160- if tokenT = res.NextPageToken; tokenT == "" {
161- break
162- }
163- }
164- token := ""
165- for n := 0; n < compareDepth; {
166- res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token)
167- if err != nil {
168- return nil, false, false, err
169- }
170- for _, c := range res.Commits {
171- n++
172- if onTarget[c.ID.String()] {
173- // The first commit of the source that the target has: where the
174- // branch left it. The target moved on when this is not its tip.
175- return ahead, true, c.ID.String() != tgtTip, nil
176- }
177- ahead = append(ahead, c)
178- }
179- if token = res.NextPageToken; token == "" {
180- // The whole history of the source is ahead: no common history.
181- return ahead, true, true, nil
182- }
183- }
184- return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil
185-}
186-
187 // openRequestFor finds the open merge request with this source and target.137 // openRequestFor finds the open merge request with this source and target.
188 func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {138 func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {
189 token := ""139 token := ""
M internal/adapters/web/fragments.go +38 -34
@@ -355,8 +355,6 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error))
355355
356356 // banner tells the commits page how many commits its branch got since the
357357 // head it lists.
358-//
359-//hexcheck:ignore suspicious-business-logic-in-adapter business rule inferring new commit counts and branch rewrites from one history page; pending move to domain/usecase
360358 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
361359 if l.ref == "" || !isFullID(l.head) {
362360 return nil, nil
@@ -365,7 +363,7 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
365363 for _, u := range p.Updates {
366364 if u.Ref == "refs/heads/"+l.ref {
367365 tip, touched = u.New, true
368- if u.IsDelete() {
366+ if u.New == domain.ZeroOID {
369367 tip = ""
370368 }
371369 }
@@ -376,37 +374,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
376374 case tip == "":
377375 return gap, nil // the branch was deleted
378376 }
379- text := ""
380- if tip != l.head {
381- name, err := domain.ParseRepoName(l.repo)
382- if err != nil {
383- return nil, err
384- }
385- res, err := l.s.commitList(ctx, name, tip, "", pageSize, "")
386- if err != nil {
387- return nil, err
388- }
389- // The count is approximate for a history with merges: the log is
390- // ordered by date, not as "head..tip", so commits that are not new may
391- // be counted before the head is met. And one page of the log being
392- // all that is read, "100+" cannot be told from a rewritten branch
393- // whose old head is further back: the use cases have no "is ancestor".
394- n, found := 0, false
395- for _, c := range res.Commits {
396- if c.ID.String() == l.head {
397- found = true
398- break
399- }
400- n++
401- }
402- switch {
403- case found:
404- text = newCommits(strconv.Itoa(n), n)
405- case res.NextPageToken != "":
406- text = newCommits(strconv.Itoa(n)+"+", 2)
407- default:
408- text = "the branch was rewritten — reload"
409- }
377+ text, err := l.bannerText(ctx, tip)
378+ if err != nil {
379+ return nil, err
410380 }
411381 c := LiveSlot("commits-head")
412382 if text != "" {
@@ -416,6 +386,40 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
416386 return []fragment{f}, err
417387 }
418388
389+func (l *liveStream) bannerText(ctx context.Context, tip string) (string, error) {
390+ if tip == l.head {
391+ return "", nil
392+ }
393+ name, err := domain.ParseRepoName(l.repo)
394+ if err != nil {
395+ return "", err
396+ }
397+ oldHead, err := domain.ParseObjectID(l.head)
398+ if err != nil {
399+ return "", err
400+ }
401+ newTip, err := domain.ParseObjectID(tip)
402+ if err != nil {
403+ return "", err
404+ }
405+ res, err := l.s.cfg.MergeRequests.PushSummary(ctx, principal(ctx), name, oldHead, newTip)
406+ if err != nil {
407+ return "", err
408+ }
409+ return pushSummaryText(res), nil
410+}
411+
412+func pushSummaryText(res ports.PushSummary) string {
413+ switch {
414+ case res.Rewritten:
415+ return "the branch was rewritten — reload"
416+ case res.More:
417+ return newCommits(strconv.Itoa(res.Count)+"+", 2)
418+ default:
419+ return newCommits(strconv.Itoa(res.Count), res.Count)
420+ }
421+}
422+
419423 // newCommits is the text of the banner: "1 new commit — reload".
420424 func newCommits(count string, n int) string {
421425 if n == 1 {
@@ -355,8 +355,6 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error))
355 355
356 // banner tells the commits page how many commits its branch got since the356 // banner tells the commits page how many commits its branch got since the
357 // head it lists.357 // head it lists.
358-//
359-//hexcheck:ignore suspicious-business-logic-in-adapter business rule inferring new commit counts and branch rewrites from one history page; pending move to domain/usecase
360 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {358 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
361 if l.ref == "" || !isFullID(l.head) {359 if l.ref == "" || !isFullID(l.head) {
362 return nil, nil360 return nil, nil
@@ -365,7 +363,7 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
365 for _, u := range p.Updates {363 for _, u := range p.Updates {
366 if u.Ref == "refs/heads/"+l.ref {364 if u.Ref == "refs/heads/"+l.ref {
367 tip, touched = u.New, true365 tip, touched = u.New, true
368- if u.IsDelete() {366+ if u.New == domain.ZeroOID {
369 tip = ""367 tip = ""
370 }368 }
371 }369 }
@@ -376,37 +374,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
376 case tip == "":374 case tip == "":
377 return gap, nil // the branch was deleted375 return gap, nil // the branch was deleted
378 }376 }
379- text := ""377+ text, err := l.bannerText(ctx, tip)
380- if tip != l.head {378+ if err != nil {
381- name, err := domain.ParseRepoName(l.repo)379+ return nil, err
382- if err != nil {
383- return nil, err
384- }
385- res, err := l.s.commitList(ctx, name, tip, "", pageSize, "")
386- if err != nil {
387- return nil, err
388- }
389- // The count is approximate for a history with merges: the log is
390- // ordered by date, not as "head..tip", so commits that are not new may
391- // be counted before the head is met. And one page of the log being
392- // all that is read, "100+" cannot be told from a rewritten branch
393- // whose old head is further back: the use cases have no "is ancestor".
394- n, found := 0, false
395- for _, c := range res.Commits {
396- if c.ID.String() == l.head {
397- found = true
398- break
399- }
400- n++
401- }
402- switch {
403- case found:
404- text = newCommits(strconv.Itoa(n), n)
405- case res.NextPageToken != "":
406- text = newCommits(strconv.Itoa(n)+"+", 2)
407- default:
408- text = "the branch was rewritten — reload"
409- }
410 }380 }
411 c := LiveSlot("commits-head")381 c := LiveSlot("commits-head")
412 if text != "" {382 if text != "" {
@@ -416,6 +386,40 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
416 return []fragment{f}, err386 return []fragment{f}, err
417 }387 }
418 388
389+func (l *liveStream) bannerText(ctx context.Context, tip string) (string, error) {
390+ if tip == l.head {
391+ return "", nil
392+ }
393+ name, err := domain.ParseRepoName(l.repo)
394+ if err != nil {
395+ return "", err
396+ }
397+ oldHead, err := domain.ParseObjectID(l.head)
398+ if err != nil {
399+ return "", err
400+ }
401+ newTip, err := domain.ParseObjectID(tip)
402+ if err != nil {
403+ return "", err
404+ }
405+ res, err := l.s.cfg.MergeRequests.PushSummary(ctx, principal(ctx), name, oldHead, newTip)
406+ if err != nil {
407+ return "", err
408+ }
409+ return pushSummaryText(res), nil
410+}
411+
412+func pushSummaryText(res ports.PushSummary) string {
413+ switch {
414+ case res.Rewritten:
415+ return "the branch was rewritten — reload"
416+ case res.More:
417+ return newCommits(strconv.Itoa(res.Count)+"+", 2)
418+ default:
419+ return newCommits(strconv.Itoa(res.Count), res.Count)
420+ }
421+}
422+
419 // newCommits is the text of the banner: "1 new commit — reload".423 // newCommits is the text of the banner: "1 new commit — reload".
420 func newCommits(count string, n int) string {424 func newCommits(count string, n int) string {
421 if n == 1 {425 if n == 1 {
M internal/adapters/web/mr.go +96 -88
@@ -62,9 +62,6 @@ const (
6262 // maxOpenPages bounds the pages read for the open merge requests, which
6363 // the first list page shows in full.
6464 maxOpenPages = 5
65- // maxComparePages bounds how many pages of history the compare page reads
66- // to find where the branch left the target.
67- maxComparePages = 5
6865 // railBranches is how many branches the list offers to compare.
6966 railBranches = 15
7067 )
@@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
546543 return s.mrPage(ctx, r, true)
547544 }
548545
549-//hexcheck:ignore suspicious-business-logic-in-adapter business rules for owner-only read positions and identifying unread commits; pending move to domain/usecase
550546 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
547+ if since && !SignedIn(ctx) {
548+ return nil, 0, errNotFound
549+ }
551550 n, err := mrNumber(r)
552551 if err != nil {
553552 return nil, 0, err
554553 }
555- if since && !SignedIn(ctx) {
556- // The read position is the owner's: there is nothing to show others.
557- return nil, 0, errNotFound
558- }
559554 d, err := s.loadRepo(ctx, r.PathValue("repo"))
560555 if err != nil {
561556 return nil, 0, err
@@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
574569 return nil, 0, errNotFound
575570 }
576571 m := res.MergeRequest
577- now := s.cfg.Now()
578572 signed := SignedIn(ctx)
579573 open := m.State == domain.MRStateOpen
580574 head := m.HeadID.String()
@@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
595589 Files: s.hl.files(ctx, res.Diff.Files),
596590 }
597591 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
592+ if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593+ return nil, 0, err
594+ }
595+
596+ p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
597+ countNotes(p.Files, res.Comments)
598+ if !res.DiffBaseID.IsZero() {
599+ lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
600+ }
601+ p.From = from
602+
603+ s.mrReadPresentation(&p, d.name(), n, res, since, signed)
604+
605+ // Comments: under their line, else in the list below the diff. The forms
606+ // are the owner's, on the whole diff of an open request.
607+ canWrite := signed && open && !since
608+ var on *lineRef
609+ if canWrite {
610+ on = parseOn(r.URL.Query().Get("on"))
611+ }
612+ s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
613+ lines, invisible := diffLines(p.Files)
614+ p.Invisible = invisible
615+ p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
616+ if signed && open {
617+ p.Box = s.mrBox(ctx, d, res)
618+ }
619+
620+ if mergeErrorOf(ctx) != "" {
621+ p.Canonical = self
622+ }
623+ if p.Box == nil {
624+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
625+ }
626+ if canWrite {
627+ p.GeneralForm = &CommentForm{
628+ ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
629+ }
630+ }
631+
632+ l := s.repoLayout(ctx, d, "merge-requests")
633+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
634+ if head != "" {
635+ pinRef(&l, target{rev: head, name: m.Source})
636+ if !d.hasBranch(m.Source) {
637+ pinRef(&l, target{rev: head, name: short(head), detached: true})
638+ }
639+ }
640+ l.Crumbs[0].Current = false
641+ l.Crumbs = append(l.Crumbs,
642+ Crumb{Label: "merge requests", Href: mrBase(d.name())},
643+ Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
644+ )
645+ if since {
646+ l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
647+ }
648+ p.Live = true
649+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
650+ l.HasRail = true
651+ l.MR = true
652+ return MergeRequest(l, p), http.StatusOK, nil
653+}
654+
655+func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox {
656+ m := res.MergeRequest
657+ box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.CanMarkRead, mergeabilityView(res.Mergeability, m.Target))
658+ box.Error = mergeErrorOf(ctx)
659+ if m.IsDraft() {
660+ box.ReadyAction = mrHref(d.name(), m.Number, "ready")
661+ }
662+ return box
663+}
664+
665+// mrFacts formats the description, metadata, branch links and checks.
666+func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
667+ m := res.MergeRequest
668+ head := m.HeadID.String()
669+ signed := SignedIn(ctx)
670+ open := m.State == domain.MRStateOpen
671+ now := s.cfg.Now()
598672 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
599673 // Relative links point into the source branch as it is now (its head
600674 // commit); the heading ids carry their own prefix, apart from the
601675 // ids of the diff.
602676 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
603677 if err != nil {
604- return nil, 0, err
678+ return err
605679 }
606680 p.Desc = h
607681 }
@@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
644718 p.Checks.Note = ""
645719 }
646720 }
647- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
648- countNotes(p.Files, res.Comments)
649- if !res.DiffBaseID.IsZero() {
650- lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
651- }
652- p.From = from
721+ return nil
722+}
653723
724+// mrReadPresentation maps usecase read tracking to commit rows and navigation.
725+func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) {
726+ now := s.cfg.Now()
727+ head := res.MergeRequest.HeadID.String()
654728 // The commits of the request, newest first.
655- for _, c := range res.Commits {
656- row := commitRow(d.name(), c, now)
729+ for i, c := range res.Commits {
730+ row := commitRow(name, c, now)
657731 p.Commits = append(p.Commits, MRCommit{
658732 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
659- Sig: row.Sig,
733+ Sig: row.Sig, New: signed && i < res.NewCommits,
660734 })
661735 }
662736 lastRead := res.LastReadID.String()
663- if signed && lastRead != "" {
664- // The commits are newest first: those before the last read head are new.
665- // A head that is not among them (the branch was rewritten) marks none.
666- for i, c := range res.Commits {
667- if c.ID.String() != lastRead {
668- continue
669- }
670- for j := 0; j < i; j++ {
671- p.Commits[j].New = true
672- }
673- }
674- }
675737 if res.CommitsTruncated {
676738 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
677739 }
678740
679741 // Since the last read: the navigation, and what the page says about it.
680- if !signed {
681- lastRead = "" // the read position is the owner's
682- }
683742 if signed && lastRead != "" {
684743 p.Since = &SinceNav{
685- AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
744+ AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"),
686745 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
687746 }
688747 }
@@ -702,57 +761,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
702761 p.Since.Files = len(p.Files)
703762 }
704763
705- // Comments: under their line, else in the list below the diff. The forms
706- // are the owner's, on the whole diff of an open request.
707- canWrite := signed && open && !since
708- var on *lineRef
709- if canWrite {
710- on = parseOn(r.URL.Query().Get("on"))
711- }
712- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
713- lines, invisible := diffLines(p.Files)
714- p.Invisible = invisible
715- p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
716- if signed && open {
717- p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718- p.Box.Error = mergeErrorOf(ctx)
719- if m.IsDraft() {
720- p.Box.ReadyAction = mrHref(d.name(), n, "ready")
721- }
722- }
723- if mergeErrorOf(ctx) != "" {
724- p.Canonical = self
725- }
726- if p.Box == nil {
727- p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728- }
729- if canWrite {
730- p.GeneralForm = &CommentForm{
731- ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
732- }
733- }
734-
735- l := s.repoLayout(ctx, d, "merge-requests")
736- l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
737- if head != "" {
738- pinRef(&l, target{rev: head, name: m.Source})
739- if !d.hasBranch(m.Source) {
740- pinRef(&l, target{rev: head, name: short(head), detached: true})
741- }
742- }
743- l.Crumbs[0].Current = false
744- l.Crumbs = append(l.Crumbs,
745- Crumb{Label: "merge requests", Href: mrBase(d.name())},
746- Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
747- )
748- if since {
749- l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
750- }
751- p.Live = true
752- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
753- l.HasRail = true
754- l.MR = true
755- return MergeRequest(l, p), http.StatusOK, nil
756764 }
757765
758766 // hasBranch tells whether the repository has the branch.
@@ -766,11 +774,11 @@ func (d *repoData) hasBranch(name string) bool {
766774 }
767775
768776 // mergeBox is what the owner can do with an open request.
769-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {
777+func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox {
770778 b := &MergeBox{
771779 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
772780 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
773- CanMarkRead: head != "" && head != lastRead,
781+ CanMarkRead: canMarkRead,
774782 CanDeleteBranch: source != d.repo.DefaultBranch,
775783 Mergeability: mg,
776784 }
@@ -62,9 +62,6 @@ const (
62 // maxOpenPages bounds the pages read for the open merge requests, which62 // maxOpenPages bounds the pages read for the open merge requests, which
63 // the first list page shows in full.63 // the first list page shows in full.
64 maxOpenPages = 564 maxOpenPages = 5
65- // maxComparePages bounds how many pages of history the compare page reads
66- // to find where the branch left the target.
67- maxComparePages = 5
68 // railBranches is how many branches the list offers to compare.65 // railBranches is how many branches the list offers to compare.
69 railBranches = 1566 railBranches = 15
70 )67 )
@@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
546 return s.mrPage(ctx, r, true)543 return s.mrPage(ctx, r, true)
547 }544 }
548 545
549-//hexcheck:ignore suspicious-business-logic-in-adapter business rules for owner-only read positions and identifying unread commits; pending move to domain/usecase
550 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {546 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
547+ if since && !SignedIn(ctx) {
548+ return nil, 0, errNotFound
549+ }
551 n, err := mrNumber(r)550 n, err := mrNumber(r)
552 if err != nil {551 if err != nil {
553 return nil, 0, err552 return nil, 0, err
554 }553 }
555- if since && !SignedIn(ctx) {
556- // The read position is the owner's: there is nothing to show others.
557- return nil, 0, errNotFound
558- }
559 d, err := s.loadRepo(ctx, r.PathValue("repo"))554 d, err := s.loadRepo(ctx, r.PathValue("repo"))
560 if err != nil {555 if err != nil {
561 return nil, 0, err556 return nil, 0, err
@@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
574 return nil, 0, errNotFound569 return nil, 0, errNotFound
575 }570 }
576 m := res.MergeRequest571 m := res.MergeRequest
577- now := s.cfg.Now()
578 signed := SignedIn(ctx)572 signed := SignedIn(ctx)
579 open := m.State == domain.MRStateOpen573 open := m.State == domain.MRStateOpen
580 head := m.HeadID.String()574 head := m.HeadID.String()
@@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
595 Files: s.hl.files(ctx, res.Diff.Files),589 Files: s.hl.files(ctx, res.Diff.Files),
596 }590 }
597 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))591 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
592+ if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593+ return nil, 0, err
594+ }
595+
596+ p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
597+ countNotes(p.Files, res.Comments)
598+ if !res.DiffBaseID.IsZero() {
599+ lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
600+ }
601+ p.From = from
602+
603+ s.mrReadPresentation(&p, d.name(), n, res, since, signed)
604+
605+ // Comments: under their line, else in the list below the diff. The forms
606+ // are the owner's, on the whole diff of an open request.
607+ canWrite := signed && open && !since
608+ var on *lineRef
609+ if canWrite {
610+ on = parseOn(r.URL.Query().Get("on"))
611+ }
612+ s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
613+ lines, invisible := diffLines(p.Files)
614+ p.Invisible = invisible
615+ p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
616+ if signed && open {
617+ p.Box = s.mrBox(ctx, d, res)
618+ }
619+
620+ if mergeErrorOf(ctx) != "" {
621+ p.Canonical = self
622+ }
623+ if p.Box == nil {
624+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
625+ }
626+ if canWrite {
627+ p.GeneralForm = &CommentForm{
628+ ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
629+ }
630+ }
631+
632+ l := s.repoLayout(ctx, d, "merge-requests")
633+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
634+ if head != "" {
635+ pinRef(&l, target{rev: head, name: m.Source})
636+ if !d.hasBranch(m.Source) {
637+ pinRef(&l, target{rev: head, name: short(head), detached: true})
638+ }
639+ }
640+ l.Crumbs[0].Current = false
641+ l.Crumbs = append(l.Crumbs,
642+ Crumb{Label: "merge requests", Href: mrBase(d.name())},
643+ Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
644+ )
645+ if since {
646+ l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
647+ }
648+ p.Live = true
649+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
650+ l.HasRail = true
651+ l.MR = true
652+ return MergeRequest(l, p), http.StatusOK, nil
653+}
654+
655+func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox {
656+ m := res.MergeRequest
657+ box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.CanMarkRead, mergeabilityView(res.Mergeability, m.Target))
658+ box.Error = mergeErrorOf(ctx)
659+ if m.IsDraft() {
660+ box.ReadyAction = mrHref(d.name(), m.Number, "ready")
661+ }
662+ return box
663+}
664+
665+// mrFacts formats the description, metadata, branch links and checks.
666+func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
667+ m := res.MergeRequest
668+ head := m.HeadID.String()
669+ signed := SignedIn(ctx)
670+ open := m.State == domain.MRStateOpen
671+ now := s.cfg.Now()
598 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {672 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
599 // Relative links point into the source branch as it is now (its head673 // Relative links point into the source branch as it is now (its head
600 // commit); the heading ids carry their own prefix, apart from the674 // commit); the heading ids carry their own prefix, apart from the
601 // ids of the diff.675 // ids of the diff.
602 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))676 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
603 if err != nil {677 if err != nil {
604- return nil, 0, err678+ return err
605 }679 }
606 p.Desc = h680 p.Desc = h
607 }681 }
@@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
644 p.Checks.Note = ""718 p.Checks.Note = ""
645 }719 }
646 }720 }
647- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)721+ return nil
648- countNotes(p.Files, res.Comments)722+}
649- if !res.DiffBaseID.IsZero() {
650- lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
651- }
652- p.From = from
653 723
724+// mrReadPresentation maps usecase read tracking to commit rows and navigation.
725+func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) {
726+ now := s.cfg.Now()
727+ head := res.MergeRequest.HeadID.String()
654 // The commits of the request, newest first.728 // The commits of the request, newest first.
655- for _, c := range res.Commits {729+ for i, c := range res.Commits {
656- row := commitRow(d.name(), c, now)730+ row := commitRow(name, c, now)
657 p.Commits = append(p.Commits, MRCommit{731 p.Commits = append(p.Commits, MRCommit{
658 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,732 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
659- Sig: row.Sig,733+ Sig: row.Sig, New: signed && i < res.NewCommits,
660 })734 })
661 }735 }
662 lastRead := res.LastReadID.String()736 lastRead := res.LastReadID.String()
663- if signed && lastRead != "" {
664- // The commits are newest first: those before the last read head are new.
665- // A head that is not among them (the branch was rewritten) marks none.
666- for i, c := range res.Commits {
667- if c.ID.String() != lastRead {
668- continue
669- }
670- for j := 0; j < i; j++ {
671- p.Commits[j].New = true
672- }
673- }
674- }
675 if res.CommitsTruncated {737 if res.CommitsTruncated {
676 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."738 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
677 }739 }
678 740
679 // Since the last read: the navigation, and what the page says about it.741 // Since the last read: the navigation, and what the page says about it.
680- if !signed {
681- lastRead = "" // the read position is the owner's
682- }
683 if signed && lastRead != "" {742 if signed && lastRead != "" {
684 p.Since = &SinceNav{743 p.Since = &SinceNav{
685- AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),744+ AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"),
686 ReadShort: short(lastRead), HeadShort: short(head), Current: since,745 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
687 }746 }
688 }747 }
@@ -702,57 +761,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
702 p.Since.Files = len(p.Files)761 p.Since.Files = len(p.Files)
703 }762 }
704 763
705- // Comments: under their line, else in the list below the diff. The forms
706- // are the owner's, on the whole diff of an open request.
707- canWrite := signed && open && !since
708- var on *lineRef
709- if canWrite {
710- on = parseOn(r.URL.Query().Get("on"))
711- }
712- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
713- lines, invisible := diffLines(p.Files)
714- p.Invisible = invisible
715- p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
716- if signed && open {
717- p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718- p.Box.Error = mergeErrorOf(ctx)
719- if m.IsDraft() {
720- p.Box.ReadyAction = mrHref(d.name(), n, "ready")
721- }
722- }
723- if mergeErrorOf(ctx) != "" {
724- p.Canonical = self
725- }
726- if p.Box == nil {
727- p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728- }
729- if canWrite {
730- p.GeneralForm = &CommentForm{
731- ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
732- }
733- }
734-
735- l := s.repoLayout(ctx, d, "merge-requests")
736- l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
737- if head != "" {
738- pinRef(&l, target{rev: head, name: m.Source})
739- if !d.hasBranch(m.Source) {
740- pinRef(&l, target{rev: head, name: short(head), detached: true})
741- }
742- }
743- l.Crumbs[0].Current = false
744- l.Crumbs = append(l.Crumbs,
745- Crumb{Label: "merge requests", Href: mrBase(d.name())},
746- Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
747- )
748- if since {
749- l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
750- }
751- p.Live = true
752- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
753- l.HasRail = true
754- l.MR = true
755- return MergeRequest(l, p), http.StatusOK, nil
756 }764 }
757 765
758 // hasBranch tells whether the repository has the branch.766 // hasBranch tells whether the repository has the branch.
@@ -766,11 +774,11 @@ func (d *repoData) hasBranch(name string) bool {
766 }774 }
767 775
768 // mergeBox is what the owner can do with an open request.776 // mergeBox is what the owner can do with an open request.
769-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {777+func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox {
770 b := &MergeBox{778 b := &MergeBox{
771 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),779 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
772 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",780 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
773- CanMarkRead: head != "" && head != lastRead,781+ CanMarkRead: canMarkRead,
774 CanDeleteBranch: source != d.repo.DefaultBranch,782 CanDeleteBranch: source != d.repo.DefaultBranch,
775 Mergeability: mg,783 Mergeability: mg,
776 }784 }
M internal/adapters/web/mr_test.go +4 -4
@@ -63,7 +63,7 @@ func TestMergeBoxModes(t *testing.T) {
6363 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
6464 s := &Server{}
6565
66- behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
66+ behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
6767 require.Len(t, behind.Modes, 3)
6868 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
6969 assert.NotEmpty(t, behind.Modes[0].Reason)
@@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) {
7373 assert.True(t, behind.CanMerge())
7474 assert.True(t, behind.Signed)
7575
76- conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
76+ conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
7777 assert.False(t, conflict.CanMerge())
7878 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
7979 assert.Equal(t, 0, conflict.Checked())
@@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) {
8484 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
8585 t.Parallel()
8686 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87- b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
8888 assert.False(t, b.Signed, "no commit is created, nothing to explain")
8989 assert.Len(t, b.Modes, 1)
9090 }
@@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) {
9494 key := mocks.NewSigningKeyService(t)
9595 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
9696 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97- b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))
97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
9898 assert.Equal(t, "SHA256:abc", b.Fingerprint)
9999 }
100100
@@ -63,7 +63,7 @@ func TestMergeBoxModes(t *testing.T) {
63 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}63 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
64 s := &Server{}64 s := &Server{}
65 65
66- behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))66+ behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
67 require.Len(t, behind.Modes, 3)67 require.Len(t, behind.Modes, 3)
68 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")68 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
69 assert.NotEmpty(t, behind.Modes[0].Reason)69 assert.NotEmpty(t, behind.Modes[0].Reason)
@@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) {
73 assert.True(t, behind.CanMerge())73 assert.True(t, behind.CanMerge())
74 assert.True(t, behind.Signed)74 assert.True(t, behind.Signed)
75 75
76- conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))76+ conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
77 assert.False(t, conflict.CanMerge())77 assert.False(t, conflict.CanMerge())
78 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")78 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
79 assert.Equal(t, 0, conflict.Checked())79 assert.Equal(t, 0, conflict.Checked())
@@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) {
84 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {84 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
85 t.Parallel()85 t.Parallel()
86 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}86 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87- b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
88 assert.False(t, b.Signed, "no commit is created, nothing to explain")88 assert.False(t, b.Signed, "no commit is created, nothing to explain")
89 assert.Len(t, b.Modes, 1)89 assert.Len(t, b.Modes, 1)
90 }90 }
@@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) {
94 key := mocks.NewSigningKeyService(t)94 key := mocks.NewSigningKeyService(t)
95 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})95 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
96 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}96 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97- b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
98 assert.Equal(t, "SHA256:abc", b.Fingerprint)98 assert.Equal(t, "SHA256:abc", b.Fingerprint)
99 }99 }
100 100
M internal/ports/dto.go +17 -0
@@ -250,7 +250,24 @@ type IssueDetail struct {
250250 }
251251
252252 // MergeRequestDetail is a merge request with its review data.
253+// CompareResult describes source commits absent from target, newest first.
254+type CompareResult struct {
255+ Ahead []domain.Commit
256+ Behind bool
257+ Exact bool
258+}
259+
260+// PushSummary counts a bounded range; More means additional commits exist.
261+type PushSummary struct {
262+ Count int
263+ More bool
264+ Rewritten bool
265+}
266+
253267 type MergeRequestDetail struct {
268+ // NewCommits is the unread prefix of Commits; zero if the read head is absent.
269+ NewCommits int
270+ CanMarkRead bool
254271 MergeRequest domain.MergeRequest
255272 // Commits are the commits of the head that the target lacks, newest
256273 // first, at most MaxMergeRequestCommits.
@@ -250,7 +250,24 @@ type IssueDetail struct {
250 }250 }
251 251
252 // MergeRequestDetail is a merge request with its review data.252 // MergeRequestDetail is a merge request with its review data.
253+// CompareResult describes source commits absent from target, newest first.
254+type CompareResult struct {
255+ Ahead []domain.Commit
256+ Behind bool
257+ Exact bool
258+}
259+
260+// PushSummary counts a bounded range; More means additional commits exist.
261+type PushSummary struct {
262+ Count int
263+ More bool
264+ Rewritten bool
265+}
266+
253 type MergeRequestDetail struct {267 type MergeRequestDetail struct {
268+ // NewCommits is the unread prefix of Commits; zero if the read head is absent.
269+ NewCommits int
270+ CanMarkRead bool
254 MergeRequest domain.MergeRequest271 MergeRequest domain.MergeRequest
255 // Commits are the commits of the head that the target lacks, newest272 // Commits are the commits of the head that the target lacks, newest
256 // first, at most MaxMergeRequestCommits.273 // first, at most MaxMergeRequestCommits.
M internal/ports/mocks/MergeRequestService.go +168 -0
@@ -210,6 +210,90 @@ func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Cont
210210 return _c
211211 }
212212
213+// Compare provides a mock function for the type MergeRequestService
214+func (_mock *MergeRequestService) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error) {
215+ ret := _mock.Called(ctx, p, repo, target, source)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Compare")
219+ }
220+
221+ var r0 ports.CompareResult
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.CompareResult, error)); ok {
224+ return returnFunc(ctx, p, repo, target, source)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.CompareResult); ok {
227+ r0 = returnFunc(ctx, p, repo, target, source)
228+ } else {
229+ r0 = ret.Get(0).(ports.CompareResult)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
232+ r1 = returnFunc(ctx, p, repo, target, source)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// MergeRequestService_Compare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Compare'
240+type MergeRequestService_Compare_Call struct {
241+ *mock.Call
242+}
243+
244+// Compare is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - repo domain.RepoName
248+// - target domain.ObjectID
249+// - source domain.ObjectID
250+func (_e *MergeRequestService_Expecter) Compare(ctx any, p any, repo any, target any, source any) *MergeRequestService_Compare_Call {
251+ return &MergeRequestService_Compare_Call{Call: _e.mock.On("Compare", ctx, p, repo, target, source)}
252+}
253+
254+func (_c *MergeRequestService_Compare_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID)) *MergeRequestService_Compare_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 domain.Principal
261+ if args[1] != nil {
262+ arg1 = args[1].(domain.Principal)
263+ }
264+ var arg2 domain.RepoName
265+ if args[2] != nil {
266+ arg2 = args[2].(domain.RepoName)
267+ }
268+ var arg3 domain.ObjectID
269+ if args[3] != nil {
270+ arg3 = args[3].(domain.ObjectID)
271+ }
272+ var arg4 domain.ObjectID
273+ if args[4] != nil {
274+ arg4 = args[4].(domain.ObjectID)
275+ }
276+ run(
277+ arg0,
278+ arg1,
279+ arg2,
280+ arg3,
281+ arg4,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *MergeRequestService_Compare_Call) Return(compareResult ports.CompareResult, err error) *MergeRequestService_Compare_Call {
288+ _c.Call.Return(compareResult, err)
289+ return _c
290+}
291+
292+func (_c *MergeRequestService_Compare_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error)) *MergeRequestService_Compare_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
213297 // CountOpen provides a mock function for the type MergeRequestService
214298 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
215299 ret := _mock.Called(ctx, p, name)
@@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte
10201104 return _c
10211105 }
10221106
1107+// PushSummary provides a mock function for the type MergeRequestService
1108+func (_mock *MergeRequestService) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error) {
1109+ ret := _mock.Called(ctx, p, repo, oldHead, newTip)
1110+
1111+ if len(ret) == 0 {
1112+ panic("no return value specified for PushSummary")
1113+ }
1114+
1115+ var r0 ports.PushSummary
1116+ var r1 error
1117+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.PushSummary, error)); ok {
1118+ return returnFunc(ctx, p, repo, oldHead, newTip)
1119+ }
1120+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.PushSummary); ok {
1121+ r0 = returnFunc(ctx, p, repo, oldHead, newTip)
1122+ } else {
1123+ r0 = ret.Get(0).(ports.PushSummary)
1124+ }
1125+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
1126+ r1 = returnFunc(ctx, p, repo, oldHead, newTip)
1127+ } else {
1128+ r1 = ret.Error(1)
1129+ }
1130+ return r0, r1
1131+}
1132+
1133+// MergeRequestService_PushSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSummary'
1134+type MergeRequestService_PushSummary_Call struct {
1135+ *mock.Call
1136+}
1137+
1138+// PushSummary is a helper method to define mock.On call
1139+// - ctx context.Context
1140+// - p domain.Principal
1141+// - repo domain.RepoName
1142+// - oldHead domain.ObjectID
1143+// - newTip domain.ObjectID
1144+func (_e *MergeRequestService_Expecter) PushSummary(ctx any, p any, repo any, oldHead any, newTip any) *MergeRequestService_PushSummary_Call {
1145+ return &MergeRequestService_PushSummary_Call{Call: _e.mock.On("PushSummary", ctx, p, repo, oldHead, newTip)}
1146+}
1147+
1148+func (_c *MergeRequestService_PushSummary_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID)) *MergeRequestService_PushSummary_Call {
1149+ _c.Call.Run(func(args mock.Arguments) {
1150+ var arg0 context.Context
1151+ if args[0] != nil {
1152+ arg0 = args[0].(context.Context)
1153+ }
1154+ var arg1 domain.Principal
1155+ if args[1] != nil {
1156+ arg1 = args[1].(domain.Principal)
1157+ }
1158+ var arg2 domain.RepoName
1159+ if args[2] != nil {
1160+ arg2 = args[2].(domain.RepoName)
1161+ }
1162+ var arg3 domain.ObjectID
1163+ if args[3] != nil {
1164+ arg3 = args[3].(domain.ObjectID)
1165+ }
1166+ var arg4 domain.ObjectID
1167+ if args[4] != nil {
1168+ arg4 = args[4].(domain.ObjectID)
1169+ }
1170+ run(
1171+ arg0,
1172+ arg1,
1173+ arg2,
1174+ arg3,
1175+ arg4,
1176+ )
1177+ })
1178+ return _c
1179+}
1180+
1181+func (_c *MergeRequestService_PushSummary_Call) Return(pushSummary ports.PushSummary, err error) *MergeRequestService_PushSummary_Call {
1182+ _c.Call.Return(pushSummary, err)
1183+ return _c
1184+}
1185+
1186+func (_c *MergeRequestService_PushSummary_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error)) *MergeRequestService_PushSummary_Call {
1187+ _c.Call.Return(run)
1188+ return _c
1189+}
1190+
10231191 // ResolveComment provides a mock function for the type MergeRequestService
10241192 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
10251193 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
@@ -210,6 +210,90 @@ func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Cont
210 return _c210 return _c
211 }211 }
212 212
213+// Compare provides a mock function for the type MergeRequestService
214+func (_mock *MergeRequestService) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error) {
215+ ret := _mock.Called(ctx, p, repo, target, source)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Compare")
219+ }
220+
221+ var r0 ports.CompareResult
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.CompareResult, error)); ok {
224+ return returnFunc(ctx, p, repo, target, source)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.CompareResult); ok {
227+ r0 = returnFunc(ctx, p, repo, target, source)
228+ } else {
229+ r0 = ret.Get(0).(ports.CompareResult)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
232+ r1 = returnFunc(ctx, p, repo, target, source)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// MergeRequestService_Compare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Compare'
240+type MergeRequestService_Compare_Call struct {
241+ *mock.Call
242+}
243+
244+// Compare is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - repo domain.RepoName
248+// - target domain.ObjectID
249+// - source domain.ObjectID
250+func (_e *MergeRequestService_Expecter) Compare(ctx any, p any, repo any, target any, source any) *MergeRequestService_Compare_Call {
251+ return &MergeRequestService_Compare_Call{Call: _e.mock.On("Compare", ctx, p, repo, target, source)}
252+}
253+
254+func (_c *MergeRequestService_Compare_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID)) *MergeRequestService_Compare_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 domain.Principal
261+ if args[1] != nil {
262+ arg1 = args[1].(domain.Principal)
263+ }
264+ var arg2 domain.RepoName
265+ if args[2] != nil {
266+ arg2 = args[2].(domain.RepoName)
267+ }
268+ var arg3 domain.ObjectID
269+ if args[3] != nil {
270+ arg3 = args[3].(domain.ObjectID)
271+ }
272+ var arg4 domain.ObjectID
273+ if args[4] != nil {
274+ arg4 = args[4].(domain.ObjectID)
275+ }
276+ run(
277+ arg0,
278+ arg1,
279+ arg2,
280+ arg3,
281+ arg4,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *MergeRequestService_Compare_Call) Return(compareResult ports.CompareResult, err error) *MergeRequestService_Compare_Call {
288+ _c.Call.Return(compareResult, err)
289+ return _c
290+}
291+
292+func (_c *MergeRequestService_Compare_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error)) *MergeRequestService_Compare_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
213 // CountOpen provides a mock function for the type MergeRequestService297 // CountOpen provides a mock function for the type MergeRequestService
214 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {298 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
215 ret := _mock.Called(ctx, p, name)299 ret := _mock.Called(ctx, p, name)
@@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte
1020 return _c1104 return _c
1021 }1105 }
1022 1106
1107+// PushSummary provides a mock function for the type MergeRequestService
1108+func (_mock *MergeRequestService) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error) {
1109+ ret := _mock.Called(ctx, p, repo, oldHead, newTip)
1110+
1111+ if len(ret) == 0 {
1112+ panic("no return value specified for PushSummary")
1113+ }
1114+
1115+ var r0 ports.PushSummary
1116+ var r1 error
1117+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.PushSummary, error)); ok {
1118+ return returnFunc(ctx, p, repo, oldHead, newTip)
1119+ }
1120+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.PushSummary); ok {
1121+ r0 = returnFunc(ctx, p, repo, oldHead, newTip)
1122+ } else {
1123+ r0 = ret.Get(0).(ports.PushSummary)
1124+ }
1125+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
1126+ r1 = returnFunc(ctx, p, repo, oldHead, newTip)
1127+ } else {
1128+ r1 = ret.Error(1)
1129+ }
1130+ return r0, r1
1131+}
1132+
1133+// MergeRequestService_PushSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSummary'
1134+type MergeRequestService_PushSummary_Call struct {
1135+ *mock.Call
1136+}
1137+
1138+// PushSummary is a helper method to define mock.On call
1139+// - ctx context.Context
1140+// - p domain.Principal
1141+// - repo domain.RepoName
1142+// - oldHead domain.ObjectID
1143+// - newTip domain.ObjectID
1144+func (_e *MergeRequestService_Expecter) PushSummary(ctx any, p any, repo any, oldHead any, newTip any) *MergeRequestService_PushSummary_Call {
1145+ return &MergeRequestService_PushSummary_Call{Call: _e.mock.On("PushSummary", ctx, p, repo, oldHead, newTip)}
1146+}
1147+
1148+func (_c *MergeRequestService_PushSummary_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID)) *MergeRequestService_PushSummary_Call {
1149+ _c.Call.Run(func(args mock.Arguments) {
1150+ var arg0 context.Context
1151+ if args[0] != nil {
1152+ arg0 = args[0].(context.Context)
1153+ }
1154+ var arg1 domain.Principal
1155+ if args[1] != nil {
1156+ arg1 = args[1].(domain.Principal)
1157+ }
1158+ var arg2 domain.RepoName
1159+ if args[2] != nil {
1160+ arg2 = args[2].(domain.RepoName)
1161+ }
1162+ var arg3 domain.ObjectID
1163+ if args[3] != nil {
1164+ arg3 = args[3].(domain.ObjectID)
1165+ }
1166+ var arg4 domain.ObjectID
1167+ if args[4] != nil {
1168+ arg4 = args[4].(domain.ObjectID)
1169+ }
1170+ run(
1171+ arg0,
1172+ arg1,
1173+ arg2,
1174+ arg3,
1175+ arg4,
1176+ )
1177+ })
1178+ return _c
1179+}
1180+
1181+func (_c *MergeRequestService_PushSummary_Call) Return(pushSummary ports.PushSummary, err error) *MergeRequestService_PushSummary_Call {
1182+ _c.Call.Return(pushSummary, err)
1183+ return _c
1184+}
1185+
1186+func (_c *MergeRequestService_PushSummary_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error)) *MergeRequestService_PushSummary_Call {
1187+ _c.Call.Return(run)
1188+ return _c
1189+}
1190+
1023 // ResolveComment provides a mock function for the type MergeRequestService1191 // ResolveComment provides a mock function for the type MergeRequestService
1024 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {1192 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
1025 ret := _mock.Called(ctx, p, name, number, commentID, resolved)1193 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
M internal/ports/services.go +2 -0
@@ -109,6 +109,8 @@ type EventService interface {
109109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests
110110 // implements it.
111111 type MergeRequestService interface {
112+ Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (CompareResult, error)
113+ PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (PushSummary, error)
112114 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
113115 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114116 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
@@ -109,6 +109,8 @@ type EventService interface {
109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests
110 // implements it.110 // implements it.
111 type MergeRequestService interface {111 type MergeRequestService interface {
112+ Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (CompareResult, error)
113+ PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (PushSummary, error)
112 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)114 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
113 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)115 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)116 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
M internal/usecase/mergerequests.go +53 -0
@@ -546,6 +546,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
546546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
547547 return ports.MergeRequestDetail{}, err
548548 }
549+ d.CanMarkRead = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID
549550
550551 exclude, base, err := m.rangeOf(ctx, mr)
551552 if err != nil {
@@ -574,6 +575,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
574575 return ports.MergeRequestDetail{}, err
575576 }
576577 verifyCommits(ctx, m.verifier, name, d.Commits)
578+ if !d.LastReadID.IsZero() {
579+ for i, c := range d.Commits {
580+ if c.ID == d.LastReadID {
581+ d.NewCommits = i
582+ break
583+ }
584+ }
585+ }
577586 }
578587 if opts.SinceLastRead && !d.LastReadID.IsZero() {
579588 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
@@ -1438,3 +1447,47 @@ func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, num
14381447 }
14391448 return root, nil
14401449 }
1450+
1451+// Compare lists at most 500 source commits absent from target. Unlike bounded
1452+// history intersection, ancestry remains accurate even for deep histories.
1453+func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) {
1454+ if _, err := m.readable(ctx, p, repo); err != nil {
1455+ return ports.CompareResult{}, err
1456+ }
1457+ if target == source {
1458+ return ports.CompareResult{Exact: true}, nil
1459+ }
1460+ ancestor, err := m.git.IsAncestor(ctx, repo, target, source)
1461+ if err != nil {
1462+ return ports.CompareResult{}, err
1463+ }
1464+ commits, more, err := m.git.ListRange(ctx, repo, target, source, 500)
1465+ if err != nil {
1466+ return ports.CompareResult{}, err
1467+ }
1468+ verifyCommits(ctx, m.verifier, repo, commits)
1469+ return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil
1470+}
1471+
1472+// PushSummary detects rewrites by ancestry, not log ordering, and counts at
1473+// most 100 new commits. More distinguishes a longer range from a rewrite.
1474+func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) {
1475+ if _, err := m.readable(ctx, p, repo); err != nil {
1476+ return ports.PushSummary{}, err
1477+ }
1478+ if oldHead == newTip {
1479+ return ports.PushSummary{}, nil
1480+ }
1481+ ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip)
1482+ if err != nil {
1483+ return ports.PushSummary{}, err
1484+ }
1485+ if !ancestor {
1486+ return ports.PushSummary{Rewritten: true}, nil
1487+ }
1488+ commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, 100)
1489+ if err != nil {
1490+ return ports.PushSummary{}, err
1491+ }
1492+ return ports.PushSummary{Count: len(commits), More: more}, nil
1493+}
@@ -546,6 +546,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
547 return ports.MergeRequestDetail{}, err547 return ports.MergeRequestDetail{}, err
548 }548 }
549+ d.CanMarkRead = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID
549 550
550 exclude, base, err := m.rangeOf(ctx, mr)551 exclude, base, err := m.rangeOf(ctx, mr)
551 if err != nil {552 if err != nil {
@@ -574,6 +575,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
574 return ports.MergeRequestDetail{}, err575 return ports.MergeRequestDetail{}, err
575 }576 }
576 verifyCommits(ctx, m.verifier, name, d.Commits)577 verifyCommits(ctx, m.verifier, name, d.Commits)
578+ if !d.LastReadID.IsZero() {
579+ for i, c := range d.Commits {
580+ if c.ID == d.LastReadID {
581+ d.NewCommits = i
582+ break
583+ }
584+ }
585+ }
577 }586 }
578 if opts.SinceLastRead && !d.LastReadID.IsZero() {587 if opts.SinceLastRead && !d.LastReadID.IsZero() {
579 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {588 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
@@ -1438,3 +1447,47 @@ func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, num
1438 }1447 }
1439 return root, nil1448 return root, nil
1440 }1449 }
1450+
1451+// Compare lists at most 500 source commits absent from target. Unlike bounded
1452+// history intersection, ancestry remains accurate even for deep histories.
1453+func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) {
1454+ if _, err := m.readable(ctx, p, repo); err != nil {
1455+ return ports.CompareResult{}, err
1456+ }
1457+ if target == source {
1458+ return ports.CompareResult{Exact: true}, nil
1459+ }
1460+ ancestor, err := m.git.IsAncestor(ctx, repo, target, source)
1461+ if err != nil {
1462+ return ports.CompareResult{}, err
1463+ }
1464+ commits, more, err := m.git.ListRange(ctx, repo, target, source, 500)
1465+ if err != nil {
1466+ return ports.CompareResult{}, err
1467+ }
1468+ verifyCommits(ctx, m.verifier, repo, commits)
1469+ return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil
1470+}
1471+
1472+// PushSummary detects rewrites by ancestry, not log ordering, and counts at
1473+// most 100 new commits. More distinguishes a longer range from a rewrite.
1474+func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) {
1475+ if _, err := m.readable(ctx, p, repo); err != nil {
1476+ return ports.PushSummary{}, err
1477+ }
1478+ if oldHead == newTip {
1479+ return ports.PushSummary{}, nil
1480+ }
1481+ ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip)
1482+ if err != nil {
1483+ return ports.PushSummary{}, err
1484+ }
1485+ if !ancestor {
1486+ return ports.PushSummary{Rewritten: true}, nil
1487+ }
1488+ commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, 100)
1489+ if err != nil {
1490+ return ports.PushSummary{}, err
1491+ }
1492+ return ports.PushSummary{Count: len(commits), More: more}, nil
1493+}
M internal/usecase/mergerequests_test.go +103 -0
@@ -233,3 +233,106 @@ func TestAddNoteValidation(t *testing.T) {
233233 })
234234 }
235235 }
236+
237+func TestHistorySummaries(t *testing.T) {
238+ for _, tc := range []struct {
239+ name string
240+ ancestor, more bool
241+ }{
242+ {"linear", true, false}, {"bounded", true, true}, {"rewritten", false, false},
243+ } {
244+ t.Run(tc.name, func(t *testing.T) {
245+ u := newMRUnit(t)
246+ old := someID(t)
247+ tip := oidFrom(t, "2222222222222222222222222222222222222222")
248+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
249+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, nil).Twice()
250+ commits := []domain.Commit{{ID: tip}}
251+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, 500).Return(commits, tc.more, nil)
252+ comparison, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip)
253+ require.NoError(t, err)
254+ assert.Equal(t, !tc.ancestor, comparison.Behind)
255+ assert.Equal(t, !tc.more, comparison.Exact)
256+ assert.Equal(t, commits, comparison.Ahead)
257+ if tc.ancestor {
258+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, 100).Return(commits, tc.more, nil)
259+ }
260+ summary, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip)
261+ require.NoError(t, err)
262+ assert.Equal(t, !tc.ancestor, summary.Rewritten)
263+ if tc.ancestor {
264+ assert.Equal(t, 1, summary.Count)
265+ assert.Equal(t, tc.more, summary.More)
266+ }
267+ })
268+ }
269+}
270+
271+func TestReadTrackingSharedPosition(t *testing.T) {
272+ for _, tc := range []struct {
273+ name string
274+ principal domain.Principal
275+ readIndex int
276+ }{
277+ {"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0},
278+ {"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1},
279+ } {
280+ t.Run(tc.name, func(t *testing.T) {
281+ u := newMRUnit(t)
282+ u.repo.Visibility = domain.VisibilityPublic
283+ head := someID(t)
284+ old := oidFrom(t, "2222222222222222222222222222222222222222")
285+ read := old
286+ if tc.readIndex == 0 {
287+ read = head
288+ }
289+ mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head}
290+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
291+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil)
292+ u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil)
293+ u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil)
294+ u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil)
295+ commits := []domain.Commit{{ID: head}}
296+ if tc.readIndex >= 0 {
297+ commits = append(commits, domain.Commit{ID: old})
298+ }
299+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil)
300+ detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{})
301+ require.NoError(t, err)
302+ expected := 0
303+ assert.Equal(t, read, detail.LastReadID)
304+ assert.Equal(t, read != head, detail.CanMarkRead)
305+ if tc.readIndex > 0 {
306+ expected = tc.readIndex
307+ }
308+ assert.Equal(t, expected, detail.NewCommits)
309+ })
310+ }
311+}
312+
313+func TestHistoryAuthorizationAndEqualTips(t *testing.T) {
314+ for _, allowed := range []bool{false, true} {
315+ t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) {
316+ u := newMRUnit(t)
317+ if allowed {
318+ u.repo.Visibility = domain.VisibilityPublic
319+ }
320+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
321+ id := someID(t)
322+ comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
323+ if allowed {
324+ require.NoError(t, err)
325+ assert.True(t, comparison.Exact)
326+ } else {
327+ require.ErrorIs(t, err, domain.ErrNotFound)
328+ }
329+ summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
330+ if allowed {
331+ require.NoError(t, err)
332+ assert.Zero(t, summary)
333+ } else {
334+ require.ErrorIs(t, err, domain.ErrNotFound)
335+ }
336+ })
337+ }
338+}
@@ -233,3 +233,106 @@ func TestAddNoteValidation(t *testing.T) {
233 })233 })
234 }234 }
235 }235 }
236+
237+func TestHistorySummaries(t *testing.T) {
238+ for _, tc := range []struct {
239+ name string
240+ ancestor, more bool
241+ }{
242+ {"linear", true, false}, {"bounded", true, true}, {"rewritten", false, false},
243+ } {
244+ t.Run(tc.name, func(t *testing.T) {
245+ u := newMRUnit(t)
246+ old := someID(t)
247+ tip := oidFrom(t, "2222222222222222222222222222222222222222")
248+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
249+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, nil).Twice()
250+ commits := []domain.Commit{{ID: tip}}
251+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, 500).Return(commits, tc.more, nil)
252+ comparison, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip)
253+ require.NoError(t, err)
254+ assert.Equal(t, !tc.ancestor, comparison.Behind)
255+ assert.Equal(t, !tc.more, comparison.Exact)
256+ assert.Equal(t, commits, comparison.Ahead)
257+ if tc.ancestor {
258+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, 100).Return(commits, tc.more, nil)
259+ }
260+ summary, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip)
261+ require.NoError(t, err)
262+ assert.Equal(t, !tc.ancestor, summary.Rewritten)
263+ if tc.ancestor {
264+ assert.Equal(t, 1, summary.Count)
265+ assert.Equal(t, tc.more, summary.More)
266+ }
267+ })
268+ }
269+}
270+
271+func TestReadTrackingSharedPosition(t *testing.T) {
272+ for _, tc := range []struct {
273+ name string
274+ principal domain.Principal
275+ readIndex int
276+ }{
277+ {"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0},
278+ {"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1},
279+ } {
280+ t.Run(tc.name, func(t *testing.T) {
281+ u := newMRUnit(t)
282+ u.repo.Visibility = domain.VisibilityPublic
283+ head := someID(t)
284+ old := oidFrom(t, "2222222222222222222222222222222222222222")
285+ read := old
286+ if tc.readIndex == 0 {
287+ read = head
288+ }
289+ mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head}
290+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
291+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil)
292+ u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil)
293+ u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil)
294+ u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil)
295+ commits := []domain.Commit{{ID: head}}
296+ if tc.readIndex >= 0 {
297+ commits = append(commits, domain.Commit{ID: old})
298+ }
299+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil)
300+ detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{})
301+ require.NoError(t, err)
302+ expected := 0
303+ assert.Equal(t, read, detail.LastReadID)
304+ assert.Equal(t, read != head, detail.CanMarkRead)
305+ if tc.readIndex > 0 {
306+ expected = tc.readIndex
307+ }
308+ assert.Equal(t, expected, detail.NewCommits)
309+ })
310+ }
311+}
312+
313+func TestHistoryAuthorizationAndEqualTips(t *testing.T) {
314+ for _, allowed := range []bool{false, true} {
315+ t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) {
316+ u := newMRUnit(t)
317+ if allowed {
318+ u.repo.Visibility = domain.VisibilityPublic
319+ }
320+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
321+ id := someID(t)
322+ comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
323+ if allowed {
324+ require.NoError(t, err)
325+ assert.True(t, comparison.Exact)
326+ } else {
327+ require.ErrorIs(t, err, domain.ErrNotFound)
328+ }
329+ summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
330+ if allowed {
331+ require.NoError(t, err)
332+ assert.Zero(t, summary)
333+ } else {
334+ require.ErrorIs(t, err, domain.ErrNotFound)
335+ }
336+ })
337+ }
338+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close