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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "net/http" | |
| 8 | - | "slices" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/a-h/templ" | |
| 11 | 10 | ||
| @@ -13,10 +12,6 @@ import ( | |||
| 13 | 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 | 15 | // compare shows a branch against the default branch of the repository: the | |
| 21 | 16 | // commits it is ahead by and, for the owner, the form that opens a merge | |
| 22 | 17 | // request. | |
| @@ -54,7 +49,9 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 54 | 49 | p.Note = "this is the default branch: there is nothing to compare it with." | |
| 55 | 50 | p.AheadText = "0 commits" | |
| 56 | 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 | 55 | if err != nil { | |
| 59 | 56 | return nil, 0, err | |
| 60 | 57 | } | |
| @@ -137,53 +134,6 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 137 | 134 | 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 | 137 | // openRequestFor finds the open merge request with this source and target. | |
| 188 | 138 | func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) { | |
| 189 | 139 | 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: the | 15 | // 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 | 16 | // 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, err | 56 | 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, nil | 134 | 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)) | |||
| 355 | 355 | ||
| 356 | 356 | // banner tells the commits page how many commits its branch got since the | |
| 357 | 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 | 358 | func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |
| 361 | 359 | if l.ref == "" || !isFullID(l.head) { | |
| 362 | 360 | return nil, nil | |
| @@ -365,7 +363,7 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment | |||
| 365 | 363 | for _, u := range p.Updates { | |
| 366 | 364 | if u.Ref == "refs/heads/"+l.ref { | |
| 367 | 365 | tip, touched = u.New, true | |
| 368 | - | if u.IsDelete() { | |
| 366 | + | if u.New == domain.ZeroOID { | |
| 369 | 367 | tip = "" | |
| 370 | 368 | } | |
| 371 | 369 | } | |
| @@ -376,37 +374,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment | |||
| 376 | 374 | case tip == "": | |
| 377 | 375 | return gap, nil // the branch was deleted | |
| 378 | 376 | } | |
| 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 | |
| 410 | 380 | } | |
| 411 | 381 | c := LiveSlot("commits-head") | |
| 412 | 382 | if text != "" { | |
| @@ -416,6 +386,40 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment | |||
| 416 | 386 | 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 | 423 | // newCommits is the text of the banner: "1 new commit — reload". | |
| 420 | 424 | func newCommits(count string, n int) string { | |
| 421 | 425 | 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 the | 356 | // 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, nil | 360 | 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, true | 365 | 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 deleted | 375 | 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}, err | 386 | 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 ( | |||
| 62 | 62 | // maxOpenPages bounds the pages read for the open merge requests, which | |
| 63 | 63 | // the first list page shows in full. | |
| 64 | 64 | 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 | 65 | // railBranches is how many branches the list offers to compare. | |
| 69 | 66 | railBranches = 15 | |
| 70 | 67 | ) | |
| @@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, | |||
| 546 | 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 | 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 | 550 | n, err := mrNumber(r) | |
| 552 | 551 | if err != nil { | |
| 553 | 552 | 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 | 554 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 560 | 555 | if err != nil { | |
| 561 | 556 | return nil, 0, err | |
| @@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 574 | 569 | return nil, 0, errNotFound | |
| 575 | 570 | } | |
| 576 | 571 | m := res.MergeRequest | |
| 577 | - | now := s.cfg.Now() | |
| 578 | 572 | signed := SignedIn(ctx) | |
| 579 | 573 | open := m.State == domain.MRStateOpen | |
| 580 | 574 | head := m.HeadID.String() | |
| @@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 595 | 589 | Files: s.hl.files(ctx, res.Diff.Files), | |
| 596 | 590 | } | |
| 597 | 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 | 672 | if desc := m.Description.String(); strings.TrimSpace(desc) != "" { | |
| 599 | 673 | // Relative links point into the source branch as it is now (its head | |
| 600 | 674 | // commit); the heading ids carry their own prefix, apart from the | |
| 601 | 675 | // ids of the diff. | |
| 602 | 676 | h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments)) | |
| 603 | 677 | if err != nil { | |
| 604 | - | return nil, 0, err | |
| 678 | + | return err | |
| 605 | 679 | } | |
| 606 | 680 | p.Desc = h | |
| 607 | 681 | } | |
| @@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 644 | 718 | p.Checks.Note = "" | |
| 645 | 719 | } | |
| 646 | 720 | } | |
| 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 | + | } | |
| 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 | 728 | // 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) | |
| 657 | 731 | p.Commits = append(p.Commits, MRCommit{ | |
| 658 | 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 | 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 | 737 | if res.CommitsTruncated { | |
| 676 | 738 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | |
| 677 | 739 | } | |
| 678 | 740 | ||
| 679 | 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 | 742 | if signed && lastRead != "" { | |
| 684 | 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 | 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 | 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 | 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 | 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 | 778 | b := &MergeBox{ | |
| 771 | 779 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | |
| 772 | 780 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | |
| 773 | - | CanMarkRead: head != "" && head != lastRead, | |
| 781 | + | CanMarkRead: canMarkRead, | |
| 774 | 782 | CanDeleteBranch: source != d.repo.DefaultBranch, | |
| 775 | 783 | Mergeability: mg, | |
| 776 | 784 | } | |
| @@ -62,9 +62,6 @@ const ( | |||||
| 62 | // maxOpenPages bounds the pages read for the open merge requests, which | 62 | // 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 = 5 | 64 | 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 = 15 | 66 | 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, err | 552 | 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, err | 556 | 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, errNotFound | 569 | return nil, 0, errNotFound | ||
| 575 | } | 570 | } | ||
| 576 | m := res.MergeRequest | 571 | m := res.MergeRequest | ||
| 577 | - | now := s.cfg.Now() | |||
| 578 | signed := SignedIn(ctx) | 572 | signed := SignedIn(ctx) | ||
| 579 | open := m.State == domain.MRStateOpen | 573 | 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 head | 673 | // Relative links point into the source branch as it is now (its head | ||
| 600 | // commit); the heading ids carry their own prefix, apart from the | 674 | // 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, err | 678 | + | return err |
| 605 | } | 679 | } | ||
| 606 | p.Desc = h | 680 | 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) { | |||
| 63 | 63 | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |
| 64 | 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 | 67 | require.Len(t, behind.Modes, 3) | |
| 68 | 68 | assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind") | |
| 69 | 69 | assert.NotEmpty(t, behind.Modes[0].Reason) | |
| @@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) { | |||
| 73 | 73 | assert.True(t, behind.CanMerge()) | |
| 74 | 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 | 77 | assert.False(t, conflict.CanMerge()) | |
| 78 | 78 | assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled") | |
| 79 | 79 | assert.Equal(t, 0, conflict.Checked()) | |
| @@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) { | |||
| 84 | 84 | func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) { | |
| 85 | 85 | t.Parallel() | |
| 86 | 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 | 88 | assert.False(t, b.Signed, "no commit is created, nothing to explain") | |
| 89 | 89 | assert.Len(t, b.Modes, 1) | |
| 90 | 90 | } | |
| @@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) { | |||
| 94 | 94 | key := mocks.NewSigningKeyService(t) | |
| 95 | 95 | key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}}) | |
| 96 | 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 | 98 | assert.Equal(t, "SHA256:abc", b.Fingerprint) | |
| 99 | 99 | } | |
| 100 | 100 | ||
| @@ -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 { | |||
| 250 | 250 | } | |
| 251 | 251 | ||
| 252 | 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 | 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 | 271 | MergeRequest domain.MergeRequest | |
| 255 | 272 | // Commits are the commits of the head that the target lacks, newest | |
| 256 | 273 | // 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.MergeRequest | 271 | MergeRequest domain.MergeRequest | ||
| 255 | // Commits are the commits of the head that the target lacks, newest | 272 | // 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 | |||
| 210 | 210 | 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 | 297 | // CountOpen provides a mock function for the type MergeRequestService | |
| 214 | 298 | func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) { | |
| 215 | 299 | ret := _mock.Called(ctx, p, name) | |
| @@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte | |||
| 1020 | 1104 | 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 | 1191 | // ResolveComment provides a mock function for the type MergeRequestService | |
| 1024 | 1192 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | |
| 1025 | 1193 | 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 _c | 210 | 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 MergeRequestService | 297 | // 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 _c | 1104 | 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 MergeRequestService | 1191 | // 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 { | |||
| 109 | 109 | // MergeRequestService is the merge request use cases. *usecase.MergeRequests | |
| 110 | 110 | // implements it. | |
| 111 | 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 | 114 | Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) | |
| 113 | 115 | List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) | |
| 114 | 116 | 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.MergeRequests | 109 | // 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 | |||
| 546 | 546 | if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil { | |
| 547 | 547 | return ports.MergeRequestDetail{}, err | |
| 548 | 548 | } | |
| 549 | + | d.CanMarkRead = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID | |
| 549 | 550 | ||
| 550 | 551 | exclude, base, err := m.rangeOf(ctx, mr) | |
| 551 | 552 | if err != nil { | |
| @@ -574,6 +575,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 574 | 575 | return ports.MergeRequestDetail{}, err | |
| 575 | 576 | } | |
| 576 | 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 | 587 | if opts.SinceLastRead && !d.LastReadID.IsZero() { | |
| 579 | 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 | 1448 | 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 | + | } | |
| @@ -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{}, err | 547 | 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{}, err | 575 | 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, nil | 1448 | 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) { | |||
| 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 | + | } | |
| @@ -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 | + | } | |||