detached 4d9eaac

#8 chore(hexcheck): bump to v0.4.0 and drop unused ignore directives

state
merged · 4d9eaac · fast-forward
branch
chore/hexcheck-v0.4.0 → main
opened
bnema ·
updated

Bump hexcheck to v0.4.0 and make hexcheck -fail-on warn pass. Comment-only changes in adapters.

  • Remove 89 ignore directives that v0.4.0 reports as unused.
  • Annotate the 16 new findings with accurate reasons.
  • Rewrite wrong reasons. 14 functions hold business rules marked 'pending move to domain/usecase' (revision resolution, commit budget, diff budget, workflow guard, commitsAhead, banner, mrPage, repoRecord, CreateRun, RotateOIDCKey, issuesOn, issueSubmittedPage); they move in follow-up MRs.

Ignore directives: 129 -> 57.

CommitSubjectAuthorDateSignatureStatus
4d9eaacfix(usecase): drop the unused repository read before the merge lockbriceGPG Verified
05fc636refactor: reuse the domain settings predicates and tighten the moved rules after reviewbriceGPG Verified
558568cfix(web,rpc): handle an API or web server built without merge requests or OIDC keysbriceGPG Verified
5344be5merge: hexmove/workflow into chore/hexcheck-v0.4.0briceGPG Verified
9f9eee5merge: hexmove/history into chore/hexcheck-v0.4.0briceGPG Verified
c902d2fmerge: hexmove/policies into chore/hexcheck-v0.4.0briceGPG Verified
6b4272dmerge: hexmove/budgets into chore/hexcheck-v0.4.0briceGPG Verified
fe7ab7dmerge: hexmove/revision into chore/hexcheck-v0.4.0briceGPG Verified
a1ec1afrefactor(domain): own the workflow-change rule for job tokens in WorkflowChangeOfbriceGPG Verified
6080cberefactor(usecase): own branch comparison, push summaries and unread commits in MergeRequestsbriceGPG Verified
c0f84a6refactor(usecase): own repo defaults, disabled workflows, OIDC rotation and issue visibility rulesbriceGPG Verified
0424a8arefactor(domain): own commit-object and diff read budgetsbriceGPG Verified
ac21956chore(hexcheck): bump to v0.4.0 and drop unused ignore directivesbriceGPG Verified
e188800refactor(domain): own the revision resolution order in Revision.CandidatesbriceGPG Verified

Checks 8

102 files · +2491 -1408
7/8
M docs/merge-requests.md +13 -0
M go.mod +1 -1
M go.sum +2 -2
M internal/adapters/cilogs/cilogs.go +2 -0
M internal/adapters/cisummaries/cisummaries.go +2 -0
M internal/adapters/ermitegit/commitobjects.go +12 -11
M internal/adapters/ermitegit/convert.go +25 -36
M internal/adapters/ermitegit/ermitegit.go +1 -1
M internal/adapters/gitcli/archive.go +0 -2
M internal/adapters/gitcli/changed.go +0 -4
M internal/adapters/gitcli/commitobjects.go +11 -15
M internal/adapters/gitcli/diff.go +15 -47
M internal/adapters/gitcli/export_test.go +0 -6
M internal/adapters/gitcli/merge.go +0 -16
M internal/adapters/gitcli/pull.go +0 -2
A internal/adapters/gitcli/push_trees.go +400 -0
R internal/adapters/gitcli/workflow_guard_test.go → internal/adapters/gitcli/push_trees_test.go +158 -217 renamed, 53% similar
M internal/adapters/gitcli/read.go +31 -60
M internal/adapters/gitcli/repos.go +1 -3
M internal/adapters/gitcli/stats.go +0 -6
D internal/adapters/gitcli/workflow_guard.go +0 -438
M internal/adapters/githttp/handler.go +4 -4
M internal/adapters/githttp/hooks.go +0 -2
M internal/adapters/kvstore/ci.go +11 -18
M internal/adapters/kvstore/ci_artifact.go +3 -5
M internal/adapters/kvstore/ci_concurrency.go +0 -4
M internal/adapters/kvstore/ci_settings.go +6 -4
M internal/adapters/kvstore/issues.go +0 -8
M internal/adapters/kvstore/issues_pending.go +0 -5
M internal/adapters/kvstore/mergerequests.go +3 -9
M internal/adapters/kvstore/passkeys.go +0 -2
M internal/adapters/kvstore/repos.go +2 -10
M internal/adapters/kvstore/store.go +0 -2
M internal/adapters/kvstore/tokens.go +0 -6
M internal/adapters/rpc/api.go +2 -4
M internal/adapters/rpc/convert.go +1 -4
M internal/adapters/rpc/git.go +0 -1
M internal/adapters/rpc/mergerequest.go +0 -2
M internal/adapters/rpc/repo.go +0 -2
M internal/adapters/rpc/server.go +5 -8
M internal/adapters/rpc/server_test.go +11 -0
M internal/adapters/sigverify/gpg.go +0 -2
M internal/adapters/sigverify/ssh.go +0 -2
M internal/adapters/sigverify/verifier.go +0 -2
M internal/adapters/web/actions_artifacts.go +0 -4
M internal/adapters/web/browse.go +0 -2
M internal/adapters/web/checks.go +2 -0
M internal/adapters/web/commit.go +0 -2
M internal/adapters/web/compare.go +3 -55
M internal/adapters/web/context.go +1 -1
M internal/adapters/web/diff_highlight.go +0 -2
@@ -370,8 +370,6 @@ func computeFile(ctx context.Context, lx chroma.Lexer, hunks []domain.Hunk) (d *
370370
371371 // applyFile puts the colours on the lines of a file; false when the data is
372372 // not the one of this file (another number of lines), nothing is changed.
373-//
374-//hexcheck:ignore suspicious-business-logic-in-adapter puts coloured runs on the lines of a diff for the page; no rule is decided here
375373 func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool {
376374 n := 0
377375 for _, h := range hunks {
@@ -370,8 +370,6 @@ func computeFile(ctx context.Context, lx chroma.Lexer, hunks []domain.Hunk) (d *
370 370
371 // applyFile puts the colours on the lines of a file; false when the data is371 // applyFile puts the colours on the lines of a file; false when the data is
372 // not the one of this file (another number of lines), nothing is changed.372 // not the one of this file (another number of lines), nothing is changed.
373-//
374-//hexcheck:ignore suspicious-business-logic-in-adapter puts coloured runs on the lines of a diff for the page; no rule is decided here
375 func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool {373 func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool {
376 n := 0374 n := 0
377 for _, h := range hunks {375 for _, h := range hunks {
M internal/adapters/web/events.go +3 -1
@@ -342,6 +342,8 @@ func (s *Server) canSee(ctx context.Context, rawName string) error {
342342
343343 // issuesOn is domain.ErrNotFound unless the repository has issues on and
344344 // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.
345+//
346+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.IssuesEnabled to a not-found stream; decides nothing itself
345347 func (s *Server) issuesOn(ctx context.Context, rawName string) error {
346348 if s.cfg.Issues == nil {
347349 return domain.ErrNotFound
@@ -354,7 +356,7 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error {
354356 if err != nil {
355357 return err
356358 }
357- if v.Settings.Issues.OrDefault() == domain.IssuesOff {
359+ if !v.EffectiveSettings().IssuesEnabled() {
358360 return domain.ErrNotFound
359361 }
360362 return nil
@@ -342,6 +342,8 @@ func (s *Server) canSee(ctx context.Context, rawName string) error {
342 342
343 // issuesOn is domain.ErrNotFound unless the repository has issues on and343 // issuesOn is domain.ErrNotFound unless the repository has issues on and
344 // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.344 // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.
345+//
346+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.IssuesEnabled to a not-found stream; decides nothing itself
345 func (s *Server) issuesOn(ctx context.Context, rawName string) error {347 func (s *Server) issuesOn(ctx context.Context, rawName string) error {
346 if s.cfg.Issues == nil {348 if s.cfg.Issues == nil {
347 return domain.ErrNotFound349 return domain.ErrNotFound
@@ -354,7 +356,7 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error {
354 if err != nil {356 if err != nil {
355 return err357 return err
356 }358 }
357- if v.Settings.Issues.OrDefault() == domain.IssuesOff {359+ if !v.EffectiveSettings().IssuesEnabled() {
358 return domain.ErrNotFound360 return domain.ErrNotFound
359 }361 }
360 return nil362 return nil
M internal/adapters/web/events_test.go +13 -0
@@ -465,3 +465,16 @@ func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) {
465465 resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)
466466 assert.Equal(t, http.StatusOK, resp.StatusCode)
467467 }
468+
469+// Without the merge request service the commits page still gets a banner when
470+// its branch moves; it only cannot count the new commits.
471+func TestCommitsBannerWithoutMergeRequests(t *testing.T) {
472+ t.Parallel()
473+ srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
474+ old, tip := strings.Repeat("a", 40), strings.Repeat("b", 40)
475+ ls := &liveStream{s: srv, repo: "x", view: "commits", ref: "main", head: old}
476+ got, err := ls.banner(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/main", Old: old, New: tip}}})
477+ require.NoError(t, err)
478+ require.Len(t, got, 1)
479+ assert.Contains(t, got[0].html, "the branch moved — reload")
480+}
@@ -465,3 +465,16 @@ func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) {
465 resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)465 resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)
466 assert.Equal(t, http.StatusOK, resp.StatusCode)466 assert.Equal(t, http.StatusOK, resp.StatusCode)
467 }467 }
468+
469+// Without the merge request service the commits page still gets a banner when
470+// its branch moves; it only cannot count the new commits.
471+func TestCommitsBannerWithoutMergeRequests(t *testing.T) {
472+ t.Parallel()
473+ srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
474+ old, tip := strings.Repeat("a", 40), strings.Repeat("b", 40)
475+ ls := &liveStream{s: srv, repo: "x", view: "commits", ref: "main", head: old}
476+ got, err := ls.banner(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/main", Old: old, New: tip}}})
477+ require.NoError(t, err)
478+ require.Len(t, got, 1)
479+ assert.Contains(t, got[0].html, "the branch moved — reload")
480+}
M internal/adapters/web/fragments.go +43 -32
@@ -356,7 +356,7 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error))
356356 // banner tells the commits page how many commits its branch got since the
357357 // head it lists.
358358 //
359-//hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only
359+//hexcheck:ignore suspicious-business-logic-in-adapter picks the update of the listed branch from the push event; the count and rewrite come from MergeRequests.PushSummary
360360 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
361361 if l.ref == "" || !isFullID(l.head) {
362362 return nil, nil
@@ -376,37 +376,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
376376 case tip == "":
377377 return gap, nil // the branch was deleted
378378 }
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- }
379+ text, err := l.bannerText(ctx, tip)
380+ if err != nil {
381+ return nil, err
410382 }
411383 c := LiveSlot("commits-head")
412384 if text != "" {
@@ -416,6 +388,45 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
416388 return []fragment{f}, err
417389 }
418390
391+func (l *liveStream) bannerText(ctx context.Context, tip string) (string, error) {
392+ if tip == l.head {
393+ return "", nil
394+ }
395+ if l.s.cfg.MergeRequests == nil {
396+ // Without the merge request service there is no history summary: the
397+ // banner only says the branch moved.
398+ return "the branch moved — reload", nil
399+ }
400+ name, err := domain.ParseRepoName(l.repo)
401+ if err != nil {
402+ return "", err
403+ }
404+ oldHead, err := domain.ParseObjectID(l.head)
405+ if err != nil {
406+ return "", err
407+ }
408+ newTip, err := domain.ParseObjectID(tip)
409+ if err != nil {
410+ return "", err
411+ }
412+ res, err := l.s.cfg.MergeRequests.PushSummary(ctx, principal(ctx), name, oldHead, newTip)
413+ if err != nil {
414+ return "", err
415+ }
416+ return pushSummaryText(res), nil
417+}
418+
419+func pushSummaryText(res ports.PushSummary) string {
420+ switch {
421+ case res.Rewritten:
422+ return "the branch was rewritten — reload"
423+ case res.More:
424+ return newCommits(strconv.Itoa(res.Count)+"+", 2)
425+ default:
426+ return newCommits(strconv.Itoa(res.Count), res.Count)
427+ }
428+}
429+
419430 // newCommits is the text of the banner: "1 new commit — reload".
420431 func newCommits(count string, n int) string {
421432 if n == 1 {
@@ -356,7 +356,7 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error))
356 // banner tells the commits page how many commits its branch got since the356 // banner tells the commits page how many commits its branch got since the
357 // head it lists.357 // head it lists.
358 //358 //
359-//hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only359+//hexcheck:ignore suspicious-business-logic-in-adapter picks the update of the listed branch from the push event; the count and rewrite come from MergeRequests.PushSummary
360 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {360 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
361 if l.ref == "" || !isFullID(l.head) {361 if l.ref == "" || !isFullID(l.head) {
362 return nil, nil362 return nil, nil
@@ -376,37 +376,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
376 case tip == "":376 case tip == "":
377 return gap, nil // the branch was deleted377 return gap, nil // the branch was deleted
378 }378 }
379- text := ""379+ text, err := l.bannerText(ctx, tip)
380- if tip != l.head {380+ if err != nil {
381- name, err := domain.ParseRepoName(l.repo)381+ 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 }382 }
411 c := LiveSlot("commits-head")383 c := LiveSlot("commits-head")
412 if text != "" {384 if text != "" {
@@ -416,6 +388,45 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
416 return []fragment{f}, err388 return []fragment{f}, err
417 }389 }
418 390
391+func (l *liveStream) bannerText(ctx context.Context, tip string) (string, error) {
392+ if tip == l.head {
393+ return "", nil
394+ }
395+ if l.s.cfg.MergeRequests == nil {
396+ // Without the merge request service there is no history summary: the
397+ // banner only says the branch moved.
398+ return "the branch moved — reload", nil
399+ }
400+ name, err := domain.ParseRepoName(l.repo)
401+ if err != nil {
402+ return "", err
403+ }
404+ oldHead, err := domain.ParseObjectID(l.head)
405+ if err != nil {
406+ return "", err
407+ }
408+ newTip, err := domain.ParseObjectID(tip)
409+ if err != nil {
410+ return "", err
411+ }
412+ res, err := l.s.cfg.MergeRequests.PushSummary(ctx, principal(ctx), name, oldHead, newTip)
413+ if err != nil {
414+ return "", err
415+ }
416+ return pushSummaryText(res), nil
417+}
418+
419+func pushSummaryText(res ports.PushSummary) string {
420+ switch {
421+ case res.Rewritten:
422+ return "the branch was rewritten — reload"
423+ case res.More:
424+ return newCommits(strconv.Itoa(res.Count)+"+", 2)
425+ default:
426+ return newCommits(strconv.Itoa(res.Count), res.Count)
427+ }
428+}
429+
419 // newCommits is the text of the banner: "1 new commit — reload".430 // newCommits is the text of the banner: "1 new commit — reload".
420 func newCommits(count string, n int) string {431 func newCommits(count string, n int) string {
421 if n == 1 {432 if n == 1 {
M internal/adapters/web/issues.go +3 -5
@@ -97,7 +97,7 @@ func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, erro
9797 if err != nil {
9898 return nil, err
9999 }
100- if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff {
100+ if !d.repo.Settings.IssuesEnabled() {
101101 return nil, errNotFound
102102 }
103103 return d, nil
@@ -129,7 +129,7 @@ func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
129129
130130 // issueList lists the issues of one state, a page at a time.
131131 //
132-//hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode
132+//hexcheck:ignore suspicious-business-logic-in-adapter renders issue-list navigation; signed-in users see New issue, while anonymous users need a configured form and the domain AcceptsAnonymousIssues predicate
133133 func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
134134 d, err := s.issueRepo(ctx, r.PathValue("repo"))
135135 if err != nil {
@@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
179179 if token != "" {
180180 p.Newest = link("")
181181 }
182- if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) {
182+ if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.AcceptsAnonymousIssues()) {
183183 p.NewHref = issuesBase(d.name()) + "/new"
184184 }
185185
@@ -216,8 +216,6 @@ func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component
216216 }
217217
218218 // issueShow shows an issue with its comments.
219-//
220-//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
221219 func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
222220 n, err := mrNumber(r)
223221 if err != nil {
@@ -97,7 +97,7 @@ func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, erro
97 if err != nil {97 if err != nil {
98 return nil, err98 return nil, err
99 }99 }
100- if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff {100+ if !d.repo.Settings.IssuesEnabled() {
101 return nil, errNotFound101 return nil, errNotFound
102 }102 }
103 return d, nil103 return d, nil
@@ -129,7 +129,7 @@ func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
129 129
130 // issueList lists the issues of one state, a page at a time.130 // issueList lists the issues of one state, a page at a time.
131 //131 //
132-//hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode132+//hexcheck:ignore suspicious-business-logic-in-adapter renders issue-list navigation; signed-in users see New issue, while anonymous users need a configured form and the domain AcceptsAnonymousIssues predicate
133 func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {133 func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
134 d, err := s.issueRepo(ctx, r.PathValue("repo"))134 d, err := s.issueRepo(ctx, r.PathValue("repo"))
135 if err != nil {135 if err != nil {
@@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
179 if token != "" {179 if token != "" {
180 p.Newest = link("")180 p.Newest = link("")
181 }181 }
182- if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) {182+ if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.AcceptsAnonymousIssues()) {
183 p.NewHref = issuesBase(d.name()) + "/new"183 p.NewHref = issuesBase(d.name()) + "/new"
184 }184 }
185 185
@@ -216,8 +216,6 @@ func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component
216 }216 }
217 217
218 // issueShow shows an issue with its comments.218 // issueShow shows an issue with its comments.
219-//
220-//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
221 func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {219 func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
222 n, err := mrNumber(r)220 n, err := mrNumber(r)
223 if err != nil {221 if err != nil {
M internal/adapters/web/issues_form.go +3 -1
@@ -245,6 +245,8 @@ func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.R
245245 }
246246
247247 // issueSubmittedPage is GET /<repo>/issues/submitted.
248+//
249+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.ShowsIssueSubmissionReceipt to a 404; decides nothing itself
248250 func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249251 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250252 defer cancel()
@@ -253,7 +255,7 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
253255 s.fail(ctx, w, r, err)
254256 return
255257 }
256- if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {
258+ if !d.repo.Settings.ShowsIssueSubmissionReceipt() {
257259 s.fail(ctx, w, r, errNotFound)
258260 return
259261 }
@@ -245,6 +245,8 @@ func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.R
245 }245 }
246 246
247 // issueSubmittedPage is GET /<repo>/issues/submitted.247 // issueSubmittedPage is GET /<repo>/issues/submitted.
248+//
249+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.ShowsIssueSubmissionReceipt to a 404; decides nothing itself
248 func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {250 func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)251 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250 defer cancel()252 defer cancel()
@@ -253,7 +255,7 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
253 s.fail(ctx, w, r, err)255 s.fail(ctx, w, r, err)
254 return256 return
255 }257 }
256- if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {258+ if !d.repo.Settings.ShowsIssueSubmissionReceipt() {
257 s.fail(ctx, w, r, errNotFound)259 s.fail(ctx, w, r, errNotFound)
258 return260 return
259 }261 }
M internal/adapters/web/issues_post.go +0 -1
@@ -67,7 +67,6 @@ func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, stri
6767 return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
6868 }
6969
70-//hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which
7170 func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {
7271 repo := r.PathValue("repo")
7372 n, err := number(r)
@@ -67,7 +67,6 @@ func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, stri
67 return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)67 return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
68 }68 }
69 69
70-//hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which
71 func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {70 func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {
72 repo := r.PathValue("repo")71 repo := r.PathValue("repo")
73 n, err := number(r)72 n, err := number(r)
M internal/adapters/web/mr.go +96 -88
@@ -62,9 +62,6 @@ const (
6262 // maxOpenPages bounds the pages read for the open merge requests, which
6363 // the first list page shows in full.
6464 maxOpenPages = 5
65- // maxComparePages bounds how many pages of history the compare page reads
66- // to find where the branch left the target.
67- maxComparePages = 5
6865 // railBranches is how many branches the list offers to compare.
6966 railBranches = 15
7067 )
@@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
546543 return s.mrPage(ctx, r, true)
547544 }
548545
549-//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
550546 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
547+ if since && !SignedIn(ctx) {
548+ return nil, 0, errNotFound
549+ }
551550 n, err := mrNumber(r)
552551 if err != nil {
553552 return nil, 0, err
554553 }
555- if since && !SignedIn(ctx) {
556- // The read position is the owner's: there is nothing to show others.
557- return nil, 0, errNotFound
558- }
559554 d, err := s.loadRepo(ctx, r.PathValue("repo"))
560555 if err != nil {
561556 return nil, 0, err
@@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
574569 return nil, 0, errNotFound
575570 }
576571 m := res.MergeRequest
577- now := s.cfg.Now()
578572 signed := SignedIn(ctx)
579573 open := m.State == domain.MRStateOpen
580574 head := m.HeadID.String()
@@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
595589 Files: s.hl.files(ctx, res.Diff.Files),
596590 }
597591 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
592+ if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593+ return nil, 0, err
594+ }
595+
596+ p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
597+ countNotes(p.Files, res.Comments)
598+ if !res.DiffBaseID.IsZero() {
599+ lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
600+ }
601+ p.From = from
602+
603+ s.mrReadPresentation(&p, d.name(), n, res, since, signed)
604+
605+ // Comments: under their line, else in the list below the diff. The forms
606+ // are the owner's, on the whole diff of an open request.
607+ canWrite := signed && open && !since
608+ var on *lineRef
609+ if canWrite {
610+ on = parseOn(r.URL.Query().Get("on"))
611+ }
612+ s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
613+ lines, invisible := diffLines(p.Files)
614+ p.Invisible = invisible
615+ p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
616+ if signed && open {
617+ p.Box = s.mrBox(ctx, d, res)
618+ }
619+
620+ if mergeErrorOf(ctx) != "" {
621+ p.Canonical = self
622+ }
623+ if p.Box == nil {
624+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
625+ }
626+ if canWrite {
627+ p.GeneralForm = &CommentForm{
628+ ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
629+ }
630+ }
631+
632+ l := s.repoLayout(ctx, d, "merge-requests")
633+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
634+ if head != "" {
635+ pinRef(&l, target{rev: head, name: m.Source})
636+ if !d.hasBranch(m.Source) {
637+ pinRef(&l, target{rev: head, name: short(head), detached: true})
638+ }
639+ }
640+ l.Crumbs[0].Current = false
641+ l.Crumbs = append(l.Crumbs,
642+ Crumb{Label: "merge requests", Href: mrBase(d.name())},
643+ Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
644+ )
645+ if since {
646+ l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
647+ }
648+ p.Live = true
649+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
650+ l.HasRail = true
651+ l.MR = true
652+ return MergeRequest(l, p), http.StatusOK, nil
653+}
654+
655+func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox {
656+ m := res.MergeRequest
657+ box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.HeadUnread, mergeabilityView(res.Mergeability, m.Target))
658+ box.Error = mergeErrorOf(ctx)
659+ if m.IsDraft() {
660+ box.ReadyAction = mrHref(d.name(), m.Number, "ready")
661+ }
662+ return box
663+}
664+
665+// mrFacts formats the description, metadata, branch links and checks.
666+func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
667+ m := res.MergeRequest
668+ head := m.HeadID.String()
669+ signed := SignedIn(ctx)
670+ open := m.State == domain.MRStateOpen
671+ now := s.cfg.Now()
598672 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
599673 // Relative links point into the source branch as it is now (its head
600674 // commit); the heading ids carry their own prefix, apart from the
601675 // ids of the diff.
602676 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
603677 if err != nil {
604- return nil, 0, err
678+ return err
605679 }
606680 p.Desc = h
607681 }
@@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
644718 p.Checks.Note = ""
645719 }
646720 }
647- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
648- countNotes(p.Files, res.Comments)
649- if !res.DiffBaseID.IsZero() {
650- lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
651- }
652- p.From = from
721+ return nil
722+}
653723
724+// mrReadPresentation maps usecase read tracking to commit rows and navigation.
725+func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) {
726+ now := s.cfg.Now()
727+ head := res.MergeRequest.HeadID.String()
654728 // The commits of the request, newest first.
655- for _, c := range res.Commits {
656- row := commitRow(d.name(), c, now)
729+ for i, c := range res.Commits {
730+ row := commitRow(name, c, now)
657731 p.Commits = append(p.Commits, MRCommit{
658732 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
659- Sig: row.Sig,
733+ Sig: row.Sig, New: signed && i < res.NewCommits,
660734 })
661735 }
662736 lastRead := res.LastReadID.String()
663- if signed && lastRead != "" {
664- // The commits are newest first: those before the last read head are new.
665- // A head that is not among them (the branch was rewritten) marks none.
666- for i, c := range res.Commits {
667- if c.ID.String() != lastRead {
668- continue
669- }
670- for j := 0; j < i; j++ {
671- p.Commits[j].New = true
672- }
673- }
674- }
675737 if res.CommitsTruncated {
676738 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
677739 }
678740
679741 // Since the last read: the navigation, and what the page says about it.
680- if !signed {
681- lastRead = "" // the read position is the owner's
682- }
683742 if signed && lastRead != "" {
684743 p.Since = &SinceNav{
685- AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
744+ AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"),
686745 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
687746 }
688747 }
@@ -702,57 +761,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
702761 p.Since.Files = len(p.Files)
703762 }
704763
705- // Comments: under their line, else in the list below the diff. The forms
706- // are the owner's, on the whole diff of an open request.
707- canWrite := signed && open && !since
708- var on *lineRef
709- if canWrite {
710- on = parseOn(r.URL.Query().Get("on"))
711- }
712- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
713- lines, invisible := diffLines(p.Files)
714- p.Invisible = invisible
715- p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
716- if signed && open {
717- p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718- p.Box.Error = mergeErrorOf(ctx)
719- if m.IsDraft() {
720- p.Box.ReadyAction = mrHref(d.name(), n, "ready")
721- }
722- }
723- if mergeErrorOf(ctx) != "" {
724- p.Canonical = self
725- }
726- if p.Box == nil {
727- p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728- }
729- if canWrite {
730- p.GeneralForm = &CommentForm{
731- ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
732- }
733- }
734-
735- l := s.repoLayout(ctx, d, "merge-requests")
736- l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
737- if head != "" {
738- pinRef(&l, target{rev: head, name: m.Source})
739- if !d.hasBranch(m.Source) {
740- pinRef(&l, target{rev: head, name: short(head), detached: true})
741- }
742- }
743- l.Crumbs[0].Current = false
744- l.Crumbs = append(l.Crumbs,
745- Crumb{Label: "merge requests", Href: mrBase(d.name())},
746- Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
747- )
748- if since {
749- l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
750- }
751- p.Live = true
752- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
753- l.HasRail = true
754- l.MR = true
755- return MergeRequest(l, p), http.StatusOK, nil
756764 }
757765
758766 // hasBranch tells whether the repository has the branch.
@@ -766,11 +774,11 @@ func (d *repoData) hasBranch(name string) bool {
766774 }
767775
768776 // mergeBox is what the owner can do with an open request.
769-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {
777+func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox {
770778 b := &MergeBox{
771779 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
772780 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
773- CanMarkRead: head != "" && head != lastRead,
781+ CanMarkRead: canMarkRead,
774782 CanDeleteBranch: source != d.repo.DefaultBranch,
775783 Mergeability: mg,
776784 }
@@ -62,9 +62,6 @@ const (
62 // maxOpenPages bounds the pages read for the open merge requests, which62 // maxOpenPages bounds the pages read for the open merge requests, which
63 // the first list page shows in full.63 // the first list page shows in full.
64 maxOpenPages = 564 maxOpenPages = 5
65- // maxComparePages bounds how many pages of history the compare page reads
66- // to find where the branch left the target.
67- maxComparePages = 5
68 // railBranches is how many branches the list offers to compare.65 // railBranches is how many branches the list offers to compare.
69 railBranches = 1566 railBranches = 15
70 )67 )
@@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
546 return s.mrPage(ctx, r, true)543 return s.mrPage(ctx, r, true)
547 }544 }
548 545
549-//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
550 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {546 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
547+ if since && !SignedIn(ctx) {
548+ return nil, 0, errNotFound
549+ }
551 n, err := mrNumber(r)550 n, err := mrNumber(r)
552 if err != nil {551 if err != nil {
553 return nil, 0, err552 return nil, 0, err
554 }553 }
555- if since && !SignedIn(ctx) {
556- // The read position is the owner's: there is nothing to show others.
557- return nil, 0, errNotFound
558- }
559 d, err := s.loadRepo(ctx, r.PathValue("repo"))554 d, err := s.loadRepo(ctx, r.PathValue("repo"))
560 if err != nil {555 if err != nil {
561 return nil, 0, err556 return nil, 0, err
@@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
574 return nil, 0, errNotFound569 return nil, 0, errNotFound
575 }570 }
576 m := res.MergeRequest571 m := res.MergeRequest
577- now := s.cfg.Now()
578 signed := SignedIn(ctx)572 signed := SignedIn(ctx)
579 open := m.State == domain.MRStateOpen573 open := m.State == domain.MRStateOpen
580 head := m.HeadID.String()574 head := m.HeadID.String()
@@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
595 Files: s.hl.files(ctx, res.Diff.Files),589 Files: s.hl.files(ctx, res.Diff.Files),
596 }590 }
597 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))591 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
592+ if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593+ return nil, 0, err
594+ }
595+
596+ p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
597+ countNotes(p.Files, res.Comments)
598+ if !res.DiffBaseID.IsZero() {
599+ lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
600+ }
601+ p.From = from
602+
603+ s.mrReadPresentation(&p, d.name(), n, res, since, signed)
604+
605+ // Comments: under their line, else in the list below the diff. The forms
606+ // are the owner's, on the whole diff of an open request.
607+ canWrite := signed && open && !since
608+ var on *lineRef
609+ if canWrite {
610+ on = parseOn(r.URL.Query().Get("on"))
611+ }
612+ s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
613+ lines, invisible := diffLines(p.Files)
614+ p.Invisible = invisible
615+ p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
616+ if signed && open {
617+ p.Box = s.mrBox(ctx, d, res)
618+ }
619+
620+ if mergeErrorOf(ctx) != "" {
621+ p.Canonical = self
622+ }
623+ if p.Box == nil {
624+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
625+ }
626+ if canWrite {
627+ p.GeneralForm = &CommentForm{
628+ ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
629+ }
630+ }
631+
632+ l := s.repoLayout(ctx, d, "merge-requests")
633+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
634+ if head != "" {
635+ pinRef(&l, target{rev: head, name: m.Source})
636+ if !d.hasBranch(m.Source) {
637+ pinRef(&l, target{rev: head, name: short(head), detached: true})
638+ }
639+ }
640+ l.Crumbs[0].Current = false
641+ l.Crumbs = append(l.Crumbs,
642+ Crumb{Label: "merge requests", Href: mrBase(d.name())},
643+ Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
644+ )
645+ if since {
646+ l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
647+ }
648+ p.Live = true
649+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
650+ l.HasRail = true
651+ l.MR = true
652+ return MergeRequest(l, p), http.StatusOK, nil
653+}
654+
655+func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox {
656+ m := res.MergeRequest
657+ box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.HeadUnread, mergeabilityView(res.Mergeability, m.Target))
658+ box.Error = mergeErrorOf(ctx)
659+ if m.IsDraft() {
660+ box.ReadyAction = mrHref(d.name(), m.Number, "ready")
661+ }
662+ return box
663+}
664+
665+// mrFacts formats the description, metadata, branch links and checks.
666+func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
667+ m := res.MergeRequest
668+ head := m.HeadID.String()
669+ signed := SignedIn(ctx)
670+ open := m.State == domain.MRStateOpen
671+ now := s.cfg.Now()
598 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {672 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
599 // Relative links point into the source branch as it is now (its head673 // Relative links point into the source branch as it is now (its head
600 // commit); the heading ids carry their own prefix, apart from the674 // commit); the heading ids carry their own prefix, apart from the
601 // ids of the diff.675 // ids of the diff.
602 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))676 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
603 if err != nil {677 if err != nil {
604- return nil, 0, err678+ return err
605 }679 }
606 p.Desc = h680 p.Desc = h
607 }681 }
@@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
644 p.Checks.Note = ""718 p.Checks.Note = ""
645 }719 }
646 }720 }
647- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)721+ return nil
648- countNotes(p.Files, res.Comments)722+}
649- if !res.DiffBaseID.IsZero() {
650- lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
651- }
652- p.From = from
653 723
724+// mrReadPresentation maps usecase read tracking to commit rows and navigation.
725+func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) {
726+ now := s.cfg.Now()
727+ head := res.MergeRequest.HeadID.String()
654 // The commits of the request, newest first.728 // The commits of the request, newest first.
655- for _, c := range res.Commits {729+ for i, c := range res.Commits {
656- row := commitRow(d.name(), c, now)730+ row := commitRow(name, c, now)
657 p.Commits = append(p.Commits, MRCommit{731 p.Commits = append(p.Commits, MRCommit{
658 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,732 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
659- Sig: row.Sig,733+ Sig: row.Sig, New: signed && i < res.NewCommits,
660 })734 })
661 }735 }
662 lastRead := res.LastReadID.String()736 lastRead := res.LastReadID.String()
663- if signed && lastRead != "" {
664- // The commits are newest first: those before the last read head are new.
665- // A head that is not among them (the branch was rewritten) marks none.
666- for i, c := range res.Commits {
667- if c.ID.String() != lastRead {
668- continue
669- }
670- for j := 0; j < i; j++ {
671- p.Commits[j].New = true
672- }
673- }
674- }
675 if res.CommitsTruncated {737 if res.CommitsTruncated {
676 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."738 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
677 }739 }
678 740
679 // Since the last read: the navigation, and what the page says about it.741 // Since the last read: the navigation, and what the page says about it.
680- if !signed {
681- lastRead = "" // the read position is the owner's
682- }
683 if signed && lastRead != "" {742 if signed && lastRead != "" {
684 p.Since = &SinceNav{743 p.Since = &SinceNav{
685- AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),744+ AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"),
686 ReadShort: short(lastRead), HeadShort: short(head), Current: since,745 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
687 }746 }
688 }747 }
@@ -702,57 +761,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
702 p.Since.Files = len(p.Files)761 p.Since.Files = len(p.Files)
703 }762 }
704 763
705- // Comments: under their line, else in the list below the diff. The forms
706- // are the owner's, on the whole diff of an open request.
707- canWrite := signed && open && !since
708- var on *lineRef
709- if canWrite {
710- on = parseOn(r.URL.Query().Get("on"))
711- }
712- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
713- lines, invisible := diffLines(p.Files)
714- p.Invisible = invisible
715- p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
716- if signed && open {
717- p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718- p.Box.Error = mergeErrorOf(ctx)
719- if m.IsDraft() {
720- p.Box.ReadyAction = mrHref(d.name(), n, "ready")
721- }
722- }
723- if mergeErrorOf(ctx) != "" {
724- p.Canonical = self
725- }
726- if p.Box == nil {
727- p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728- }
729- if canWrite {
730- p.GeneralForm = &CommentForm{
731- ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
732- }
733- }
734-
735- l := s.repoLayout(ctx, d, "merge-requests")
736- l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
737- if head != "" {
738- pinRef(&l, target{rev: head, name: m.Source})
739- if !d.hasBranch(m.Source) {
740- pinRef(&l, target{rev: head, name: short(head), detached: true})
741- }
742- }
743- l.Crumbs[0].Current = false
744- l.Crumbs = append(l.Crumbs,
745- Crumb{Label: "merge requests", Href: mrBase(d.name())},
746- Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
747- )
748- if since {
749- l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
750- }
751- p.Live = true
752- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
753- l.HasRail = true
754- l.MR = true
755- return MergeRequest(l, p), http.StatusOK, nil
756 }764 }
757 765
758 // hasBranch tells whether the repository has the branch.766 // hasBranch tells whether the repository has the branch.
@@ -766,11 +774,11 @@ func (d *repoData) hasBranch(name string) bool {
766 }774 }
767 775
768 // mergeBox is what the owner can do with an open request.776 // mergeBox is what the owner can do with an open request.
769-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {777+func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox {
770 b := &MergeBox{778 b := &MergeBox{
771 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),779 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
772 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",780 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
773- CanMarkRead: head != "" && head != lastRead,781+ CanMarkRead: canMarkRead,
774 CanDeleteBranch: source != d.repo.DefaultBranch,782 CanDeleteBranch: source != d.repo.DefaultBranch,
775 Mergeability: mg,783 Mergeability: mg,
776 }784 }
M internal/adapters/web/mr_files.go +0 -2
@@ -157,8 +157,6 @@ func splitPath(p string) (name, dir string) {
157157 // the files of a window with their hunks and their comments, for a
158158 // placeholder of the page. A base or a head that is not the one of the diff
159159 // any more answers 409: the page says to reload.
160-//
161-//hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here
162160 func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
163161 n, err := mrNumber(r)
164162 if err != nil {
@@ -157,8 +157,6 @@ func splitPath(p string) (name, dir string) {
157 // the files of a window with their hunks and their comments, for a157 // the files of a window with their hunks and their comments, for a
158 // placeholder of the page. A base or a head that is not the one of the diff158 // placeholder of the page. A base or a head that is not the one of the diff
159 // any more answers 409: the page says to reload.159 // any more answers 409: the page says to reload.
160-//
161-//hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here
162 func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {160 func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
163 n, err := mrNumber(r)161 n, err := mrNumber(r)
164 if err != nil {162 if err != nil {
M internal/adapters/web/mr_post.go +0 -2
@@ -295,8 +295,6 @@ func commentID(s string) int64 {
295295 }
296296
297297 // headID parses a commit id of a form; the empty string is the zero id.
298-//
299-//hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here
300298 func headID(s string) (domain.ObjectID, error) {
301299 if s == "" {
302300 return domain.ObjectID{}, nil
@@ -295,8 +295,6 @@ func commentID(s string) int64 {
295 }295 }
296 296
297 // headID parses a commit id of a form; the empty string is the zero id.297 // headID parses a commit id of a form; the empty string is the zero id.
298-//
299-//hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here
300 func headID(s string) (domain.ObjectID, error) {298 func headID(s string) (domain.ObjectID, error) {
301 if s == "" {299 if s == "" {
302 return domain.ObjectID{}, nil300 return domain.ObjectID{}, nil
M internal/adapters/web/mr_test.go +4 -4
@@ -63,7 +63,7 @@ func TestMergeBoxModes(t *testing.T) {
6363 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
6464 s := &Server{}
6565
66- behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
66+ behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
6767 require.Len(t, behind.Modes, 3)
6868 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
6969 assert.NotEmpty(t, behind.Modes[0].Reason)
@@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) {
7373 assert.True(t, behind.CanMerge())
7474 assert.True(t, behind.Signed)
7575
76- conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
76+ conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
7777 assert.False(t, conflict.CanMerge())
7878 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
7979 assert.Equal(t, 0, conflict.Checked())
@@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) {
8484 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
8585 t.Parallel()
8686 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87- b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
8888 assert.False(t, b.Signed, "no commit is created, nothing to explain")
8989 assert.Len(t, b.Modes, 1)
9090 }
@@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) {
9494 key := mocks.NewSigningKeyService(t)
9595 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
9696 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97- b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))
97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
9898 assert.Equal(t, "SHA256:abc", b.Fingerprint)
9999 }
100100
@@ -63,7 +63,7 @@ func TestMergeBoxModes(t *testing.T) {
63 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}63 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
64 s := &Server{}64 s := &Server{}
65 65
66- behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))66+ behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
67 require.Len(t, behind.Modes, 3)67 require.Len(t, behind.Modes, 3)
68 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")68 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
69 assert.NotEmpty(t, behind.Modes[0].Reason)69 assert.NotEmpty(t, behind.Modes[0].Reason)
@@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) {
73 assert.True(t, behind.CanMerge())73 assert.True(t, behind.CanMerge())
74 assert.True(t, behind.Signed)74 assert.True(t, behind.Signed)
75 75
76- conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))76+ conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
77 assert.False(t, conflict.CanMerge())77 assert.False(t, conflict.CanMerge())
78 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")78 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
79 assert.Equal(t, 0, conflict.Checked())79 assert.Equal(t, 0, conflict.Checked())
@@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) {
84 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {84 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
85 t.Parallel()85 t.Parallel()
86 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}86 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87- b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
88 assert.False(t, b.Signed, "no commit is created, nothing to explain")88 assert.False(t, b.Signed, "no commit is created, nothing to explain")
89 assert.Len(t, b.Modes, 1)89 assert.Len(t, b.Modes, 1)
90 }90 }
@@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) {
94 key := mocks.NewSigningKeyService(t)94 key := mocks.NewSigningKeyService(t)
95 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})95 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
96 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}96 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97- b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
98 assert.Equal(t, "SHA256:abc", b.Fingerprint)98 assert.Equal(t, "SHA256:abc", b.Fingerprint)
99 }99 }
100 100
M internal/adapters/web/paths.go +0 -2
@@ -33,8 +33,6 @@ type pathList struct {
3333 // an ETag that makes it a 304 (answered before the list is loaded, so it never
3434 // waits for a build); the owner's copy is private and never stored. The ETag
3535 // differs for the gzip and the plain variant.
36-//
37-//hexcheck:ignore suspicious-business-logic-in-adapter serves a cached list after the use case authorized it; the headers are about the response, no rule is decided here
3836 func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
3937 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
4038 defer cancel()
@@ -33,8 +33,6 @@ type pathList struct {
33 // an ETag that makes it a 304 (answered before the list is loaded, so it never33 // an ETag that makes it a 304 (answered before the list is loaded, so it never
34 // waits for a build); the owner's copy is private and never stored. The ETag34 // waits for a build); the owner's copy is private and never stored. The ETag
35 // differs for the gzip and the plain variant.35 // differs for the gzip and the plain variant.
36-//
37-//hexcheck:ignore suspicious-business-logic-in-adapter serves a cached list after the use case authorized it; the headers are about the response, no rule is decided here
38 func (s *Server) paths(w http.ResponseWriter, r *http.Request) {36 func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
39 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)37 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
40 defer cancel()38 defer cancel()
M internal/adapters/web/raw.go +0 -2
@@ -31,8 +31,6 @@ const rawCSP = "default-src 'none'; sandbox"
3131 // image types (by their bytes, never SVG) as images, and anything else as an
3232 // attachment of unknown type. A file is never served as HTML or SVG, so what
3333 // a repository holds cannot run in the origin of the forge.
34-//
35-//hexcheck:ignore suspicious-business-logic-in-adapter streams a file the use case opened; the headers are about the response, no rule is decided here
3634 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
3735 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
3836 defer cancel()
@@ -31,8 +31,6 @@ const rawCSP = "default-src 'none'; sandbox"
31 // image types (by their bytes, never SVG) as images, and anything else as an31 // image types (by their bytes, never SVG) as images, and anything else as an
32 // attachment of unknown type. A file is never served as HTML or SVG, so what32 // attachment of unknown type. A file is never served as HTML or SVG, so what
33 // a repository holds cannot run in the origin of the forge.33 // a repository holds cannot run in the origin of the forge.
34-//
35-//hexcheck:ignore suspicious-business-logic-in-adapter streams a file the use case opened; the headers are about the response, no rule is decided here
36 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {34 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
37 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)35 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
38 defer cancel()36 defer cancel()
M internal/adapters/web/refs.go +0 -4
@@ -15,8 +15,6 @@ import (
1515 // that is the short name of a branch or tag wins. A branch wins over a tag of
1616 // the same name. rest has no leading slash and is "" when the path is the ref
1717 // alone. ok is false when no ref matches; the caller may then try a commit id.
18-//
19-//hexcheck:ignore suspicious-business-logic-in-adapter finds the ref a URL path starts with; URL routing, no rule is decided here
2018 func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) {
2119 names := refsByName(refs)
2220 p = strings.Trim(p, "/")
@@ -97,8 +95,6 @@ func (t target) commitID() string {
9795 // resolveCommit sets the full id of a target named by an abbreviated commit id,
9896 // with one ResolveRevision. The page renders without it (it only lacks the file
9997 // finder), so a failure is not one.
100-//
101-//hexcheck:ignore suspicious-business-logic-in-adapter learns the full id of the commit a URL names for the finder links; no rule is decided here
10298 func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) {
10399 if !t.detached || isFullID(t.rev) {
104100 return
@@ -15,8 +15,6 @@ import (
15 // that is the short name of a branch or tag wins. A branch wins over a tag of15 // that is the short name of a branch or tag wins. A branch wins over a tag of
16 // the same name. rest has no leading slash and is "" when the path is the ref16 // the same name. rest has no leading slash and is "" when the path is the ref
17 // alone. ok is false when no ref matches; the caller may then try a commit id.17 // alone. ok is false when no ref matches; the caller may then try a commit id.
18-//
19-//hexcheck:ignore suspicious-business-logic-in-adapter finds the ref a URL path starts with; URL routing, no rule is decided here
20 func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) {18 func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) {
21 names := refsByName(refs)19 names := refsByName(refs)
22 p = strings.Trim(p, "/")20 p = strings.Trim(p, "/")
@@ -97,8 +95,6 @@ func (t target) commitID() string {
97 // resolveCommit sets the full id of a target named by an abbreviated commit id,95 // resolveCommit sets the full id of a target named by an abbreviated commit id,
98 // with one ResolveRevision. The page renders without it (it only lacks the file96 // with one ResolveRevision. The page renders without it (it only lacks the file
99 // finder), so a failure is not one.97 // finder), so a failure is not one.
100-//
101-//hexcheck:ignore suspicious-business-logic-in-adapter learns the full id of the commit a URL names for the finder links; no rule is decided here
102 func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) {98 func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) {
103 if !t.detached || isFullID(t.rev) {99 if !t.detached || isFullID(t.rev) {
104 return100 return
M internal/adapters/web/repo.go +3 -7
@@ -44,16 +44,12 @@ func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.Repo
4444
4545 // repoRecord reads the repository record only, without Git: its name,
4646 // visibility and settings (the defaults when it has none).
47-//
48-//hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule
4947 func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
5048 repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
5149 if err != nil {
5250 return domain.Repo{}, err
5351 }
54- if repo.Settings.IsZero() {
55- repo.Settings = domain.DefaultRepoSettings()
56- }
52+ repo.Settings = repo.EffectiveSettings()
5753 return repo, nil
5854 }
5955
@@ -132,7 +128,7 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
132128 // switcher on the head ref and the ":" targets. The caller sets the title,
133129 // the crumbs, the status and the rail.
134130 //
135-//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
131+//hexcheck:ignore suspicious-business-logic-in-adapter renders repository navigation; the issues tab uses the domain IssuesEnabled predicate
136132 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
137133 l := s.base()
138134 l.Repo = d.name()
@@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
157153 mrs.Count = s.openMRCount(ctx, d.repo.Name)
158154 l.Sections = append(l.Sections, mrs)
159155 }
160- if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
156+ if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
161157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162158 }
163159 l.Sections = s.withActions(l.Sections, d.name(), section)
@@ -44,16 +44,12 @@ func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.Repo
44 44
45 // repoRecord reads the repository record only, without Git: its name,45 // repoRecord reads the repository record only, without Git: its name,
46 // visibility and settings (the defaults when it has none).46 // visibility and settings (the defaults when it has none).
47-//
48-//hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule
49 func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {47 func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
50 repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)48 repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
51 if err != nil {49 if err != nil {
52 return domain.Repo{}, err50 return domain.Repo{}, err
53 }51 }
54- if repo.Settings.IsZero() {52+ repo.Settings = repo.EffectiveSettings()
55- repo.Settings = domain.DefaultRepoSettings()
56- }
57 return repo, nil53 return repo, nil
58 }54 }
59 55
@@ -132,7 +128,7 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
132 // switcher on the head ref and the ":" targets. The caller sets the title,128 // switcher on the head ref and the ":" targets. The caller sets the title,
133 // the crumbs, the status and the rail.129 // the crumbs, the status and the rail.
134 //130 //
135-//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here131+//hexcheck:ignore suspicious-business-logic-in-adapter renders repository navigation; the issues tab uses the domain IssuesEnabled predicate
136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {132 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
137 l := s.base()133 l := s.base()
138 l.Repo = d.name()134 l.Repo = d.name()
@@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
157 mrs.Count = s.openMRCount(ctx, d.repo.Name)153 mrs.Count = s.openMRCount(ctx, d.repo.Name)
158 l.Sections = append(l.Sections, mrs)154 l.Sections = append(l.Sections, mrs)
159 }155 }
160- if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {156+ if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
161 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162 }158 }
163 l.Sections = s.withActions(l.Sections, d.name(), section)159 l.Sections = s.withActions(l.Sections, d.name(), section)
M internal/adapters/web/tree.go +0 -2
@@ -72,8 +72,6 @@ func (p TreePage) Unit() string {
7272
7373 // tree lists a directory of a ref, with the last commit of each entry. With
7474 // ?rows it answers the entries alone, for a directory opened in place.
75-//
76-//hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here
7775 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
7876 d, err := s.loadRepo(ctx, r.PathValue("repo"))
7977 if err != nil {
@@ -72,8 +72,6 @@ func (p TreePage) Unit() string {
72 72
73 // tree lists a directory of a ref, with the last commit of each entry. With73 // tree lists a directory of a ref, with the last commit of each entry. With
74 // ?rows it answers the entries alone, for a directory opened in place.74 // ?rows it answers the entries alone, for a directory opened in place.
75-//
76-//hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here
77 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {75 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
78 d, err := s.loadRepo(ctx, r.PathValue("repo"))76 d, err := s.loadRepo(ctx, r.PathValue("repo"))
79 if err != nil {77 if err != nil {
M internal/adapters/webauthn/webauthn.go +0 -2
@@ -137,8 +137,6 @@ func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
137137
138138 // FinishLogin implements ports.PasskeyVerifier. The library compares the user
139139 // handle of the response with userHandle.
140-//
141-//hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; it decides nothing: the use case refuses a counter that does not advance
142140 func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
143141 var session gowebauthn.SessionData
144142 if err := json.Unmarshal(ceremony, &session); err != nil {
@@ -137,8 +137,6 @@ func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
137 137
138 // FinishLogin implements ports.PasskeyVerifier. The library compares the user138 // FinishLogin implements ports.PasskeyVerifier. The library compares the user
139 // handle of the response with userHandle.139 // handle of the response with userHandle.
140-//
141-//hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; it decides nothing: the use case refuses a counter that does not advance
142 func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {140 func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
143 var session gowebauthn.SessionData141 var session gowebauthn.SessionData
144 if err := json.Unmarshal(ceremony, &session); err != nil {142 if err := json.Unmarshal(ceremony, &session); err != nil {
M internal/app/forge.go +3 -8
@@ -151,8 +151,7 @@ type forge struct {
151151 CI *usecase.CI
152152 // Settings are the secrets, variables and environments of CI.
153153 Settings *usecase.CISettings
154- // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets
155- // key.
154+ // OIDC is the provider of ID tokens, disabled without a secrets key.
156155 OIDC *usecase.OIDC
157156 API *rpc.API
158157 Bus ports.EventBus
@@ -313,7 +312,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
313312 opts.ci(&ciCfg)
314313 }
315314 f.CI = usecase.NewCI(ctx, ciCfg)
316- f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI())
315+ f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI())
317316 f.Repos.WithCIRunCleaner(f.CI)
318317 f.CI.Start()
319318 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
@@ -394,10 +393,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
394393 if f.Passkeys != nil {
395394 apiPasskeys = f.Passkeys
396395 }
397- var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
398- if f.OIDC != nil {
399- apiOIDC = f.OIDC
400- }
401396 var jobArtifacts ports.JobArtifactService
402397 var artifacts ports.ArtifactService
403398 if cfg.CIArtifacts {
@@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
421416 Secrets: f.Settings,
422417 Variables: f.Settings,
423418 Environments: f.Settings,
424- OIDCKeys: apiOIDC,
419+ OIDCKeys: f.OIDC,
425420 JobArtifacts: jobArtifacts,
426421 Artifacts: artifacts,
427422 Version: serverVersion(),
@@ -151,8 +151,7 @@ type forge struct {
151 CI *usecase.CI151 CI *usecase.CI
152 // Settings are the secrets, variables and environments of CI.152 // Settings are the secrets, variables and environments of CI.
153 Settings *usecase.CISettings153 Settings *usecase.CISettings
154- // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets154+ // OIDC is the provider of ID tokens, disabled without a secrets key.
155- // key.
156 OIDC *usecase.OIDC155 OIDC *usecase.OIDC
157 API *rpc.API156 API *rpc.API
158 Bus ports.EventBus157 Bus ports.EventBus
@@ -313,7 +312,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
313 opts.ci(&ciCfg)312 opts.ci(&ciCfg)
314 }313 }
315 f.CI = usecase.NewCI(ctx, ciCfg)314 f.CI = usecase.NewCI(ctx, ciCfg)
316- f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI())315+ f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI())
317 f.Repos.WithCIRunCleaner(f.CI)316 f.Repos.WithCIRunCleaner(f.CI)
318 f.CI.Start()317 f.CI.Start()
319 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{318 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
@@ -394,10 +393,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
394 if f.Passkeys != nil {393 if f.Passkeys != nil {
395 apiPasskeys = f.Passkeys394 apiPasskeys = f.Passkeys
396 }395 }
397- var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
398- if f.OIDC != nil {
399- apiOIDC = f.OIDC
400- }
401 var jobArtifacts ports.JobArtifactService396 var jobArtifacts ports.JobArtifactService
402 var artifacts ports.ArtifactService397 var artifacts ports.ArtifactService
403 if cfg.CIArtifacts {398 if cfg.CIArtifacts {
@@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
421 Secrets: f.Settings,416 Secrets: f.Settings,
422 Variables: f.Settings,417 Variables: f.Settings,
423 Environments: f.Settings,418 Environments: f.Settings,
424- OIDCKeys: apiOIDC,419+ OIDCKeys: f.OIDC,
425 JobArtifacts: jobArtifacts,420 JobArtifacts: jobArtifacts,
426 Artifacts: artifacts,421 Artifacts: artifacts,
427 Version: serverVersion(),422 Version: serverVersion(),
A internal/domain/budget.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// ValidateCommitObjects checks a request before any repository I/O. max is the
8+// caller's commit-count limit.
9+func ValidateCommitObjects(ids []ObjectID, max int) error {
10+ if len(ids) > max {
11+ return fmt.Errorf("%w: %d commit objects asked, at most %d", ErrInvalid, len(ids), max)
12+ }
13+ for _, id := range ids {
14+ if id.IsZero() {
15+ return fmt.Errorf("%w: empty commit id", ErrInvalidObjectID)
16+ }
17+ }
18+ return nil
19+}
20+
21+// CommitObjectBudget bounds a batch of raw commits. Its zero value is ready to
22+// use. Admission is a preview; Charge records only successfully read content.
23+type CommitObjectBudget struct {
24+ total int64
25+ seen map[ObjectID]bool
26+}
27+
28+func (b *CommitObjectBudget) Seen(id ObjectID) bool { return b.seen[id] }
29+
30+func (b *CommitObjectBudget) Admit(id ObjectID, commit bool, size int64) (take, stop bool) {
31+ if b.Seen(id) || !commit || size < 0 || size > MaxCommitObjectBytes {
32+ return false, false
33+ }
34+ if size > MaxCommitObjectsBytes-b.total {
35+ return false, true
36+ }
37+ return true, false
38+}
39+
40+func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
41+ if b.seen == nil {
42+ b.seen = make(map[ObjectID]bool)
43+ }
44+ b.seen[id] = true
45+ b.total += size
46+}
47+
48+// DiffBudget owns file selection and retained hunk accounting. Truncated files
49+// consume no budget; streaming readers may use a copy while attempting a file.
50+type DiffBudget struct {
51+ limits DiffLimits
52+ linesLeft, bytesLeft int
53+}
54+
55+func NewDiffBudget(limits DiffLimits) DiffBudget {
56+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}
57+}
58+
59+func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
60+ kept = files
61+ if b.limits.MaxFiles > 0 && kept > b.limits.MaxFiles {
62+ kept = b.limits.MaxFiles
63+ truncated = true
64+ }
65+ from, to = max(b.limits.From, 0), kept
66+ if b.limits.To > 0 {
67+ to = min(b.limits.To, kept)
68+ }
69+ return
70+}
71+
72+func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }
73+
74+func (b DiffBudget) WantsHunks(binary bool, n int) bool {
75+ return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0
76+}
77+
78+func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
79+
80+// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81+func (b *DiffBudget) ChargeLine(bytes int) bool {
82+ b.linesLeft--
83+ b.bytesLeft -= bytes
84+ return b.linesLeft >= 0 && b.bytesLeft >= 0
85+}
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// ValidateCommitObjects checks a request before any repository I/O. max is the
8+// caller's commit-count limit.
9+func ValidateCommitObjects(ids []ObjectID, max int) error {
10+ if len(ids) > max {
11+ return fmt.Errorf("%w: %d commit objects asked, at most %d", ErrInvalid, len(ids), max)
12+ }
13+ for _, id := range ids {
14+ if id.IsZero() {
15+ return fmt.Errorf("%w: empty commit id", ErrInvalidObjectID)
16+ }
17+ }
18+ return nil
19+}
20+
21+// CommitObjectBudget bounds a batch of raw commits. Its zero value is ready to
22+// use. Admission is a preview; Charge records only successfully read content.
23+type CommitObjectBudget struct {
24+ total int64
25+ seen map[ObjectID]bool
26+}
27+
28+func (b *CommitObjectBudget) Seen(id ObjectID) bool { return b.seen[id] }
29+
30+func (b *CommitObjectBudget) Admit(id ObjectID, commit bool, size int64) (take, stop bool) {
31+ if b.Seen(id) || !commit || size < 0 || size > MaxCommitObjectBytes {
32+ return false, false
33+ }
34+ if size > MaxCommitObjectsBytes-b.total {
35+ return false, true
36+ }
37+ return true, false
38+}
39+
40+func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
41+ if b.seen == nil {
42+ b.seen = make(map[ObjectID]bool)
43+ }
44+ b.seen[id] = true
45+ b.total += size
46+}
47+
48+// DiffBudget owns file selection and retained hunk accounting. Truncated files
49+// consume no budget; streaming readers may use a copy while attempting a file.
50+type DiffBudget struct {
51+ limits DiffLimits
52+ linesLeft, bytesLeft int
53+}
54+
55+func NewDiffBudget(limits DiffLimits) DiffBudget {
56+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}
57+}
58+
59+func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
60+ kept = files
61+ if b.limits.MaxFiles > 0 && kept > b.limits.MaxFiles {
62+ kept = b.limits.MaxFiles
63+ truncated = true
64+ }
65+ from, to = max(b.limits.From, 0), kept
66+ if b.limits.To > 0 {
67+ to = min(b.limits.To, kept)
68+ }
69+ return
70+}
71+
72+func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }
73+
74+func (b DiffBudget) WantsHunks(binary bool, n int) bool {
75+ return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0
76+}
77+
78+func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
79+
80+// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81+func (b *DiffBudget) ChargeLine(bytes int) bool {
82+ b.linesLeft--
83+ b.bytesLeft -= bytes
84+ return b.linesLeft >= 0 && b.bytesLeft >= 0
85+}
A internal/domain/budget_test.go +141 -0
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func budgetID(t *testing.T, s string) ObjectID {
11+ t.Helper()
12+ id, err := ParseObjectID(s)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ return id
17+}
18+
19+func TestValidateCommitObjects(t *testing.T) {
20+ id := budgetID(t, "1111111111111111111111111111111111111111")
21+ for _, tt := range []struct {
22+ name string
23+ ids []ObjectID
24+ max int
25+ want error
26+ }{
27+ {"empty", nil, 1, nil}, {"boundary", []ObjectID{id}, 1, nil},
28+ {"too many", []ObjectID{id, id}, 1, ErrInvalid}, {"zero", []ObjectID{{}}, 1, ErrInvalidObjectID},
29+ } {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if err := ValidateCommitObjects(tt.ids, tt.max); !errors.Is(err, tt.want) {
32+ t.Fatalf("got %v, want %v", err, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestCommitObjectBudget(t *testing.T) {
39+ id := budgetID(t, "1111111111111111111111111111111111111111")
40+ for _, tt := range []struct {
41+ name string
42+ commit bool
43+ size, total int64
44+ seen, take, stop bool
45+ }{
46+ {"non commit", false, 1, 0, false, false, false},
47+ {"oversize", true, MaxCommitObjectBytes + 1, 0, false, false, false},
48+ {"exact object", true, MaxCommitObjectBytes, 0, false, true, false},
49+ {"exact total", true, 1, MaxCommitObjectsBytes - 1, false, true, false},
50+ {"over total", true, 2, MaxCommitObjectsBytes - 1, false, false, true},
51+ {"duplicate", true, 1, MaxCommitObjectsBytes, true, false, false},
52+ {"negative", true, -1, 0, false, false, false},
53+ } {
54+ t.Run(tt.name, func(t *testing.T) {
55+ b := CommitObjectBudget{total: tt.total}
56+ if tt.seen {
57+ b.seen = map[ObjectID]bool{id: true}
58+ }
59+ take, stop := b.Admit(id, tt.commit, tt.size)
60+ if take != tt.take || stop != tt.stop {
61+ t.Fatalf("got %v,%v", take, stop)
62+ }
63+ if take {
64+ b.Charge(id, tt.size)
65+ if !b.Seen(id) || b.total != tt.total+tt.size {
66+ t.Fatal("charge not recorded")
67+ }
68+ }
69+ })
70+ }
71+}
72+
73+func TestDiffBudgetWindow(t *testing.T) {
74+ for _, tt := range []struct {
75+ name string
76+ limits DiffLimits
77+ kept, from, to int
78+ truncated bool
79+ }{
80+ {"unlimited", DiffLimits{}, 5, 0, 5, false},
81+ {"cap", DiffLimits{MaxFiles: 3}, 3, 0, 3, true},
82+ {"window", DiffLimits{From: 1, To: 4}, 5, 1, 4, false},
83+ {"clamped", DiffLimits{From: -1, To: 9, MaxFiles: 3}, 3, 0, 3, true},
84+ {"past end", DiffLimits{From: 8}, 5, 8, 5, false},
85+ {"reversed", DiffLimits{From: 4, To: 2}, 5, 4, 2, false},
86+ } {
87+ t.Run(tt.name, func(t *testing.T) {
88+ b := NewDiffBudget(tt.limits)
89+ k, f, to, tr := b.Window(5)
90+ if k != tt.kept || f != tt.from || to != tt.to || tr != tt.truncated {
91+ t.Fatalf("got %d,%d,%d,%v", k, f, to, tr)
92+ }
93+ })
94+ }
95+}
96+
97+func TestDiffBudgetHunks(t *testing.T) {
98+ for _, tt := range []struct {
99+ name string
100+ binary bool
101+ n, lines, bytes int
102+ want bool
103+ }{
104+ {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},
105+ {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},
106+ {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},
107+ } {
108+ t.Run(tt.name, func(t *testing.T) {
109+ b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})
110+ if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {
111+ t.Fatalf("got %v", got)
112+ }
113+ })
114+ }
115+}
116+
117+func TestDiffBudgetCharge(t *testing.T) {
118+ for _, tt := range []struct {
119+ name string
120+ bytes int
121+ fits bool
122+ }{{"exact", 3, true}, {"over", 4, false}} {
123+ t.Run(tt.name, func(t *testing.T) {
124+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125+ before := b
126+ if b.FileLines() != 1 {
127+ t.Fatal("wrong file limit")
128+ }
129+ if b.ChargeLine(tt.bytes) != tt.fits {
130+ t.Fatal("wrong charge result")
131+ }
132+ if b.ChargeLine(0) {
133+ t.Fatal("line budget exceeded")
134+ }
135+ b = before
136+ if !b.WantsHunks(false, 1) {
137+ t.Fatal("discarded file consumed budget")
138+ }
139+ })
140+ }
141+}
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func budgetID(t *testing.T, s string) ObjectID {
11+ t.Helper()
12+ id, err := ParseObjectID(s)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ return id
17+}
18+
19+func TestValidateCommitObjects(t *testing.T) {
20+ id := budgetID(t, "1111111111111111111111111111111111111111")
21+ for _, tt := range []struct {
22+ name string
23+ ids []ObjectID
24+ max int
25+ want error
26+ }{
27+ {"empty", nil, 1, nil}, {"boundary", []ObjectID{id}, 1, nil},
28+ {"too many", []ObjectID{id, id}, 1, ErrInvalid}, {"zero", []ObjectID{{}}, 1, ErrInvalidObjectID},
29+ } {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if err := ValidateCommitObjects(tt.ids, tt.max); !errors.Is(err, tt.want) {
32+ t.Fatalf("got %v, want %v", err, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestCommitObjectBudget(t *testing.T) {
39+ id := budgetID(t, "1111111111111111111111111111111111111111")
40+ for _, tt := range []struct {
41+ name string
42+ commit bool
43+ size, total int64
44+ seen, take, stop bool
45+ }{
46+ {"non commit", false, 1, 0, false, false, false},
47+ {"oversize", true, MaxCommitObjectBytes + 1, 0, false, false, false},
48+ {"exact object", true, MaxCommitObjectBytes, 0, false, true, false},
49+ {"exact total", true, 1, MaxCommitObjectsBytes - 1, false, true, false},
50+ {"over total", true, 2, MaxCommitObjectsBytes - 1, false, false, true},
51+ {"duplicate", true, 1, MaxCommitObjectsBytes, true, false, false},
52+ {"negative", true, -1, 0, false, false, false},
53+ } {
54+ t.Run(tt.name, func(t *testing.T) {
55+ b := CommitObjectBudget{total: tt.total}
56+ if tt.seen {
57+ b.seen = map[ObjectID]bool{id: true}
58+ }
59+ take, stop := b.Admit(id, tt.commit, tt.size)
60+ if take != tt.take || stop != tt.stop {
61+ t.Fatalf("got %v,%v", take, stop)
62+ }
63+ if take {
64+ b.Charge(id, tt.size)
65+ if !b.Seen(id) || b.total != tt.total+tt.size {
66+ t.Fatal("charge not recorded")
67+ }
68+ }
69+ })
70+ }
71+}
72+
73+func TestDiffBudgetWindow(t *testing.T) {
74+ for _, tt := range []struct {
75+ name string
76+ limits DiffLimits
77+ kept, from, to int
78+ truncated bool
79+ }{
80+ {"unlimited", DiffLimits{}, 5, 0, 5, false},
81+ {"cap", DiffLimits{MaxFiles: 3}, 3, 0, 3, true},
82+ {"window", DiffLimits{From: 1, To: 4}, 5, 1, 4, false},
83+ {"clamped", DiffLimits{From: -1, To: 9, MaxFiles: 3}, 3, 0, 3, true},
84+ {"past end", DiffLimits{From: 8}, 5, 8, 5, false},
85+ {"reversed", DiffLimits{From: 4, To: 2}, 5, 4, 2, false},
86+ } {
87+ t.Run(tt.name, func(t *testing.T) {
88+ b := NewDiffBudget(tt.limits)
89+ k, f, to, tr := b.Window(5)
90+ if k != tt.kept || f != tt.from || to != tt.to || tr != tt.truncated {
91+ t.Fatalf("got %d,%d,%d,%v", k, f, to, tr)
92+ }
93+ })
94+ }
95+}
96+
97+func TestDiffBudgetHunks(t *testing.T) {
98+ for _, tt := range []struct {
99+ name string
100+ binary bool
101+ n, lines, bytes int
102+ want bool
103+ }{
104+ {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},
105+ {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},
106+ {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},
107+ } {
108+ t.Run(tt.name, func(t *testing.T) {
109+ b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})
110+ if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {
111+ t.Fatalf("got %v", got)
112+ }
113+ })
114+ }
115+}
116+
117+func TestDiffBudgetCharge(t *testing.T) {
118+ for _, tt := range []struct {
119+ name string
120+ bytes int
121+ fits bool
122+ }{{"exact", 3, true}, {"over", 4, false}} {
123+ t.Run(tt.name, func(t *testing.T) {
124+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125+ before := b
126+ if b.FileLines() != 1 {
127+ t.Fatal("wrong file limit")
128+ }
129+ if b.ChargeLine(tt.bytes) != tt.fits {
130+ t.Fatal("wrong charge result")
131+ }
132+ if b.ChargeLine(0) {
133+ t.Fatal("line budget exceeded")
134+ }
135+ b = before
136+ if !b.WantsHunks(false, 1) {
137+ t.Fatal("discarded file consumed budget")
138+ }
139+ })
140+ }
141+}
A internal/domain/ci_workflow_guard.go +123 -0
@@ -0,0 +1,123 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// PushObjectType is the type of a peeled Git object.
8+type PushObjectType string
9+
10+// Peeled object types accepted by the push tree reader.
11+const (
12+ PushObjectCommit PushObjectType = "commit"
13+ PushObjectTree PushObjectType = "tree"
14+ PushObjectBlob PushObjectType = "blob"
15+)
16+
17+// PushWalkBounds bounds the walk over the commits of a push.
18+type PushWalkBounds struct{ Commits, IDs int }
19+
20+// Bounds fail closed and count repeated parent IDs too.
21+const (
22+ MaxPushedCommits = 10000
23+ MaxPushedIDs = 4 * MaxPushedCommits
24+)
25+
26+// DefaultPushWalkBounds returns the job-token push walk limits.
27+func DefaultPushWalkBounds() PushWalkBounds { return PushWalkBounds{MaxPushedCommits, MaxPushedIDs} }
28+
29+// Subtree is the kind and identity of an entry at a tree path.
30+type Subtree struct {
31+ Kind EntryKind
32+ ID ObjectID
33+}
34+
35+// PushedCommit is a newly reachable commit with its ordered parents.
36+type PushedCommit struct {
37+ ID ObjectID
38+ Parents []ObjectID
39+}
40+
41+// HeadKey identifies the default branch head in PushSubtrees.Subtrees.
42+const HeadKey = "HEAD"
43+
44+// PushSubtrees contains endpoint types and entries keyed by original object ID
45+// (or HeadKey), and newly reachable commits in rev-list order. Absent entries
46+// mean there is no subtree at the requested path.
47+type PushSubtrees struct {
48+ Ends map[string]PushObjectType
49+ Commits []PushedCommit
50+ Subtrees map[string]Subtree
51+}
52+
53+// WorkflowTreeChange is the first forbidden workflow-tree replacement.
54+// A zero Old denotes no workflow directory.
55+type WorkflowTreeChange struct{ Old, New ObjectID }
56+
57+// WorkflowChangeOf applies the job-token workflow policy. Every new commit must
58+// keep the workflows of at least one parent; then each ref must keep its old
59+// workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed.
60+// New commits are judged before ref ends, in their supplied order. Missing ends
61+// are a reader contract breach and fail closed with ErrInvalid.
62+func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) {
63+ for _, up := range updates {
64+ if up.IsDelete() {
65+ continue
66+ }
67+ ids := []string{up.New}
68+ if !up.IsCreate() {
69+ ids = append(ids, up.Old)
70+ }
71+ for _, id := range ids {
72+ if _, ok := t.Ends[id]; !ok {
73+ return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id)
74+ }
75+ }
76+ }
77+ dirOf := func(key string) ObjectID {
78+ s := t.Subtrees[key]
79+ if s.Kind != EntryDirectory {
80+ return ObjectID{}
81+ }
82+ return s.ID
83+ }
84+ for _, c := range t.Commits {
85+ cur := dirOf(c.ID.String())
86+ if cur.IsZero() {
87+ continue
88+ }
89+ var first ObjectID
90+ same := false
91+ for i, p := range c.Parents {
92+ w := dirOf(p.String())
93+ if i == 0 {
94+ first = w
95+ }
96+ if w == cur {
97+ same = true
98+ }
99+ }
100+ if !same {
101+ return WorkflowTreeChange{first, cur}, true, nil
102+ }
103+ }
104+ for _, up := range updates {
105+ if up.IsDelete() || t.Ends[up.New] == PushObjectBlob {
106+ continue
107+ }
108+ cur := dirOf(up.New)
109+ if cur.IsZero() {
110+ continue
111+ }
112+ var base ObjectID
113+ if up.IsCreate() {
114+ base = dirOf(HeadKey)
115+ } else if t.Ends[up.Old] != PushObjectBlob {
116+ base = dirOf(up.Old)
117+ }
118+ if base != cur {
119+ return WorkflowTreeChange{base, cur}, true, nil
120+ }
121+ }
122+ return WorkflowTreeChange{}, false, nil
123+}
@@ -0,0 +1,123 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// PushObjectType is the type of a peeled Git object.
8+type PushObjectType string
9+
10+// Peeled object types accepted by the push tree reader.
11+const (
12+ PushObjectCommit PushObjectType = "commit"
13+ PushObjectTree PushObjectType = "tree"
14+ PushObjectBlob PushObjectType = "blob"
15+)
16+
17+// PushWalkBounds bounds the walk over the commits of a push.
18+type PushWalkBounds struct{ Commits, IDs int }
19+
20+// Bounds fail closed and count repeated parent IDs too.
21+const (
22+ MaxPushedCommits = 10000
23+ MaxPushedIDs = 4 * MaxPushedCommits
24+)
25+
26+// DefaultPushWalkBounds returns the job-token push walk limits.
27+func DefaultPushWalkBounds() PushWalkBounds { return PushWalkBounds{MaxPushedCommits, MaxPushedIDs} }
28+
29+// Subtree is the kind and identity of an entry at a tree path.
30+type Subtree struct {
31+ Kind EntryKind
32+ ID ObjectID
33+}
34+
35+// PushedCommit is a newly reachable commit with its ordered parents.
36+type PushedCommit struct {
37+ ID ObjectID
38+ Parents []ObjectID
39+}
40+
41+// HeadKey identifies the default branch head in PushSubtrees.Subtrees.
42+const HeadKey = "HEAD"
43+
44+// PushSubtrees contains endpoint types and entries keyed by original object ID
45+// (or HeadKey), and newly reachable commits in rev-list order. Absent entries
46+// mean there is no subtree at the requested path.
47+type PushSubtrees struct {
48+ Ends map[string]PushObjectType
49+ Commits []PushedCommit
50+ Subtrees map[string]Subtree
51+}
52+
53+// WorkflowTreeChange is the first forbidden workflow-tree replacement.
54+// A zero Old denotes no workflow directory.
55+type WorkflowTreeChange struct{ Old, New ObjectID }
56+
57+// WorkflowChangeOf applies the job-token workflow policy. Every new commit must
58+// keep the workflows of at least one parent; then each ref must keep its old
59+// workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed.
60+// New commits are judged before ref ends, in their supplied order. Missing ends
61+// are a reader contract breach and fail closed with ErrInvalid.
62+func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) {
63+ for _, up := range updates {
64+ if up.IsDelete() {
65+ continue
66+ }
67+ ids := []string{up.New}
68+ if !up.IsCreate() {
69+ ids = append(ids, up.Old)
70+ }
71+ for _, id := range ids {
72+ if _, ok := t.Ends[id]; !ok {
73+ return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id)
74+ }
75+ }
76+ }
77+ dirOf := func(key string) ObjectID {
78+ s := t.Subtrees[key]
79+ if s.Kind != EntryDirectory {
80+ return ObjectID{}
81+ }
82+ return s.ID
83+ }
84+ for _, c := range t.Commits {
85+ cur := dirOf(c.ID.String())
86+ if cur.IsZero() {
87+ continue
88+ }
89+ var first ObjectID
90+ same := false
91+ for i, p := range c.Parents {
92+ w := dirOf(p.String())
93+ if i == 0 {
94+ first = w
95+ }
96+ if w == cur {
97+ same = true
98+ }
99+ }
100+ if !same {
101+ return WorkflowTreeChange{first, cur}, true, nil
102+ }
103+ }
104+ for _, up := range updates {
105+ if up.IsDelete() || t.Ends[up.New] == PushObjectBlob {
106+ continue
107+ }
108+ cur := dirOf(up.New)
109+ if cur.IsZero() {
110+ continue
111+ }
112+ var base ObjectID
113+ if up.IsCreate() {
114+ base = dirOf(HeadKey)
115+ } else if t.Ends[up.Old] != PushObjectBlob {
116+ base = dirOf(up.Old)
117+ }
118+ if base != cur {
119+ return WorkflowTreeChange{base, cur}, true, nil
120+ }
121+ }
122+ return WorkflowTreeChange{}, false, nil
123+}
A internal/domain/ci_workflow_guard_test.go +120 -0
@@ -0,0 +1,120 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "fmt"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestWorkflowChangeOf(t *testing.T) {
15+ id := func(n int) domain.ObjectID {
16+ v, err := domain.ParseObjectID(fmt.Sprintf("%040x", n))
17+ require.NoError(t, err)
18+ return v
19+ }
20+ a, b, c, d := id(1), id(2), id(3), id(4)
21+ x, y, z := id(11), id(12), id(13)
22+ dir := func(v domain.ObjectID) domain.Subtree { return domain.Subtree{Kind: domain.EntryDirectory, ID: v} }
23+ up := func(old, new domain.ObjectID) domain.RefUpdate {
24+ o, n := old.String(), new.String()
25+ if old.IsZero() {
26+ o = domain.ZeroOID
27+ }
28+ if new.IsZero() {
29+ n = domain.ZeroOID
30+ }
31+ return domain.RefUpdate{Old: o, New: n}
32+ }
33+ zero := domain.ObjectID{}
34+ commit := func(v domain.ObjectID, parents ...domain.ObjectID) domain.PushedCommit {
35+ return domain.PushedCommit{ID: v, Parents: parents}
36+ }
37+ type row struct {
38+ name string
39+ ups []domain.RefUpdate
40+ commits []domain.PushedCommit
41+ trees map[string]domain.Subtree
42+ treeEnds []domain.ObjectID
43+ blobs []domain.ObjectID
44+ want domain.WorkflowTreeChange
45+ found bool
46+ missing bool
47+ }
48+ change := func(old, new domain.ObjectID) domain.WorkflowTreeChange {
49+ return domain.WorkflowTreeChange{Old: old, New: new}
50+ }
51+ rows := []row{
52+ {name: "no updates"},
53+ {name: "delete", ups: []domain.RefUpdate{up(a, zero)}},
54+ {name: "new blob ignores even a misleading subtree", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{b}},
55+ {name: "old blob is not a directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{a}, want: change(zero, y), found: true},
56+ {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}},
57+ {name: "new ref same directory as HEAD", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(x)}},
58+ {name: "new ref old workflows", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(y)}, want: change(x, y), found: true},
59+ {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
60+ {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}},
61+ {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
62+ {name: "move back or remove one file from surviving directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, want: change(x, y), found: true},
63+ {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
64+ {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}},
65+ {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
66+ {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}},
67+ {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
68+ {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
69+ {name: "merge keeps second parent", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}},
70+ {name: "merge differs from all parents", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z)}, want: change(x, z), found: true},
71+ {name: "merge new side changes workflows", commits: []domain.PushedCommit{commit(d, a, c), commit(c, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z), d.String(): dir(x)}, want: change(y, z), found: true},
72+ {name: "merge adopts other parent refused by ref rule", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}, want: change(x, y), found: true},
73+ {name: "change then revert", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, b), commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(x)}, want: change(y, x), found: true},
74+ {name: "change below plain tip", commits: []domain.PushedCommit{commit(d, c), commit(c, b)}, trees: map[string]domain.Subtree{b.String(): dir(x), c.String(): dir(y), d.String(): dir(y)}, want: change(x, y), found: true},
75+ {name: "all new commits before any ref", ups: []domain.RefUpdate{up(a, b)}, commits: []domain.PushedCommit{commit(d, c)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true},
76+ {name: "first ref wins", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(x, y), found: true},
77+ {name: "plain ref then changed ref", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true},
78+ {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true},
79+ }
80+ rows = append(rows,
81+ row{name: "new tree end with workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
82+ row{name: "new tree end without workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}},
83+ row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}},
84+ )
85+
86+ for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} {
87+ rows = append(rows,
88+ row{name: string(kind) + " to directory same id", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): dir(x)}, want: change(zero, x), found: true},
89+ row{name: "directory to " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): {Kind: kind, ID: x}}},
90+ row{name: "same " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): {Kind: kind, ID: x}}},
91+ )
92+ }
93+ rows = append(rows, row{name: "gitlink to symlink", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: domain.EntrySubmodule, ID: x}, b.String(): {Kind: domain.EntrySymlink, ID: y}}})
94+ for _, r := range rows {
95+ t.Run(r.name, func(t *testing.T) {
96+ data := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Commits: r.commits, Subtrees: r.trees}
97+ if !r.missing {
98+ for _, u := range r.ups {
99+ for _, oid := range []string{u.Old, u.New} {
100+ data.Ends[oid] = domain.PushObjectCommit
101+ }
102+ }
103+ }
104+ for _, tree := range r.treeEnds {
105+ data.Ends[tree.String()] = domain.PushObjectTree
106+ }
107+ for _, b := range r.blobs {
108+ data.Ends[b.String()] = domain.PushObjectBlob
109+ }
110+ got, found, err := domain.WorkflowChangeOf(r.ups, data)
111+ if r.missing {
112+ require.ErrorIs(t, err, domain.ErrInvalid)
113+ return
114+ }
115+ require.NoError(t, err)
116+ assert.Equal(t, r.found, found)
117+ assert.Equal(t, r.want, got)
118+ })
119+ }
120+}
@@ -0,0 +1,120 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "fmt"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestWorkflowChangeOf(t *testing.T) {
15+ id := func(n int) domain.ObjectID {
16+ v, err := domain.ParseObjectID(fmt.Sprintf("%040x", n))
17+ require.NoError(t, err)
18+ return v
19+ }
20+ a, b, c, d := id(1), id(2), id(3), id(4)
21+ x, y, z := id(11), id(12), id(13)
22+ dir := func(v domain.ObjectID) domain.Subtree { return domain.Subtree{Kind: domain.EntryDirectory, ID: v} }
23+ up := func(old, new domain.ObjectID) domain.RefUpdate {
24+ o, n := old.String(), new.String()
25+ if old.IsZero() {
26+ o = domain.ZeroOID
27+ }
28+ if new.IsZero() {
29+ n = domain.ZeroOID
30+ }
31+ return domain.RefUpdate{Old: o, New: n}
32+ }
33+ zero := domain.ObjectID{}
34+ commit := func(v domain.ObjectID, parents ...domain.ObjectID) domain.PushedCommit {
35+ return domain.PushedCommit{ID: v, Parents: parents}
36+ }
37+ type row struct {
38+ name string
39+ ups []domain.RefUpdate
40+ commits []domain.PushedCommit
41+ trees map[string]domain.Subtree
42+ treeEnds []domain.ObjectID
43+ blobs []domain.ObjectID
44+ want domain.WorkflowTreeChange
45+ found bool
46+ missing bool
47+ }
48+ change := func(old, new domain.ObjectID) domain.WorkflowTreeChange {
49+ return domain.WorkflowTreeChange{Old: old, New: new}
50+ }
51+ rows := []row{
52+ {name: "no updates"},
53+ {name: "delete", ups: []domain.RefUpdate{up(a, zero)}},
54+ {name: "new blob ignores even a misleading subtree", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{b}},
55+ {name: "old blob is not a directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{a}, want: change(zero, y), found: true},
56+ {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}},
57+ {name: "new ref same directory as HEAD", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(x)}},
58+ {name: "new ref old workflows", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(y)}, want: change(x, y), found: true},
59+ {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
60+ {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}},
61+ {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
62+ {name: "move back or remove one file from surviving directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, want: change(x, y), found: true},
63+ {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
64+ {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}},
65+ {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
66+ {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}},
67+ {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
68+ {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
69+ {name: "merge keeps second parent", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}},
70+ {name: "merge differs from all parents", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z)}, want: change(x, z), found: true},
71+ {name: "merge new side changes workflows", commits: []domain.PushedCommit{commit(d, a, c), commit(c, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z), d.String(): dir(x)}, want: change(y, z), found: true},
72+ {name: "merge adopts other parent refused by ref rule", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}, want: change(x, y), found: true},
73+ {name: "change then revert", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, b), commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(x)}, want: change(y, x), found: true},
74+ {name: "change below plain tip", commits: []domain.PushedCommit{commit(d, c), commit(c, b)}, trees: map[string]domain.Subtree{b.String(): dir(x), c.String(): dir(y), d.String(): dir(y)}, want: change(x, y), found: true},
75+ {name: "all new commits before any ref", ups: []domain.RefUpdate{up(a, b)}, commits: []domain.PushedCommit{commit(d, c)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true},
76+ {name: "first ref wins", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(x, y), found: true},
77+ {name: "plain ref then changed ref", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true},
78+ {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true},
79+ }
80+ rows = append(rows,
81+ row{name: "new tree end with workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
82+ row{name: "new tree end without workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}},
83+ row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}},
84+ )
85+
86+ for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} {
87+ rows = append(rows,
88+ row{name: string(kind) + " to directory same id", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): dir(x)}, want: change(zero, x), found: true},
89+ row{name: "directory to " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): {Kind: kind, ID: x}}},
90+ row{name: "same " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): {Kind: kind, ID: x}}},
91+ )
92+ }
93+ rows = append(rows, row{name: "gitlink to symlink", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: domain.EntrySubmodule, ID: x}, b.String(): {Kind: domain.EntrySymlink, ID: y}}})
94+ for _, r := range rows {
95+ t.Run(r.name, func(t *testing.T) {
96+ data := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Commits: r.commits, Subtrees: r.trees}
97+ if !r.missing {
98+ for _, u := range r.ups {
99+ for _, oid := range []string{u.Old, u.New} {
100+ data.Ends[oid] = domain.PushObjectCommit
101+ }
102+ }
103+ }
104+ for _, tree := range r.treeEnds {
105+ data.Ends[tree.String()] = domain.PushObjectTree
106+ }
107+ for _, b := range r.blobs {
108+ data.Ends[b.String()] = domain.PushObjectBlob
109+ }
110+ got, found, err := domain.WorkflowChangeOf(r.ups, data)
111+ if r.missing {
112+ require.ErrorIs(t, err, domain.ErrInvalid)
113+ return
114+ }
115+ require.NoError(t, err)
116+ assert.Equal(t, r.found, found)
117+ assert.Equal(t, r.want, got)
118+ })
119+ }
120+}
M internal/domain/mergerequest.go +9 -0
@@ -569,3 +569,12 @@ func BoundBehind(n int) (count int, capped bool) {
569569 }
570570 return n, false
571571 }
572+
573+// IssuesEnabled reports whether the repository exposes issue streams and pages.
574+func (s RepoSettings) IssuesEnabled() bool { return s.Issues.OrDefault() != IssuesOff }
575+
576+// ShowsIssueSubmissionReceipt reports whether submissions await public moderation.
577+func (s RepoSettings) ShowsIssueSubmissionReceipt() bool { return s.Issues.OrDefault() == IssuesPublic }
578+
579+// AcceptsAnonymousIssues reports whether anyone may submit an issue.
580+func (s RepoSettings) AcceptsAnonymousIssues() bool { return s.Issues.OrDefault() == IssuesPublic }
@@ -569,3 +569,12 @@ func BoundBehind(n int) (count int, capped bool) {
569 }569 }
570 return n, false570 return n, false
571 }571 }
572+
573+// IssuesEnabled reports whether the repository exposes issue streams and pages.
574+func (s RepoSettings) IssuesEnabled() bool { return s.Issues.OrDefault() != IssuesOff }
575+
576+// ShowsIssueSubmissionReceipt reports whether submissions await public moderation.
577+func (s RepoSettings) ShowsIssueSubmissionReceipt() bool { return s.Issues.OrDefault() == IssuesPublic }
578+
579+// AcceptsAnonymousIssues reports whether anyone may submit an issue.
580+func (s RepoSettings) AcceptsAnonymousIssues() bool { return s.Issues.OrDefault() == IssuesPublic }
M internal/domain/repo.go +8 -0
@@ -92,3 +92,11 @@ func validTopic(t string) bool {
9292 }
9393 return true
9494 }
95+
96+// EffectiveSettings returns the repository settings, using defaults for an unset record.
97+func (r Repo) EffectiveSettings() RepoSettings {
98+ if r.Settings.IsZero() {
99+ return DefaultRepoSettings()
100+ }
101+ return r.Settings
102+}
@@ -92,3 +92,11 @@ func validTopic(t string) bool {
92 }92 }
93 return true93 return true
94 }94 }
95+
96+// EffectiveSettings returns the repository settings, using defaults for an unset record.
97+func (r Repo) EffectiveSettings() RepoSettings {
98+ if r.Settings.IsZero() {
99+ return DefaultRepoSettings()
100+ }
101+ return r.Settings
102+}
M internal/domain/repo_test.go +34 -0
@@ -60,3 +60,37 @@ func TestNormalizeTopics(t *testing.T) {
6060 })
6161 }
6262 }
63+
64+func TestRepoEffectiveSettings(t *testing.T) {
65+ custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeMode("squash")}, Issues: domain.IssuesPublic}
66+ for _, tc := range []struct {
67+ name string
68+ settings, want domain.RepoSettings
69+ }{
70+ {"unset", domain.RepoSettings{}, domain.DefaultRepoSettings()},
71+ {"custom", custom, custom},
72+ } {
73+ t.Run(tc.name, func(t *testing.T) {
74+ repo := domain.Repo{Settings: tc.settings}
75+ assert.Equal(t, tc.want, repo.EffectiveSettings())
76+ assert.Equal(t, tc.settings, repo.Settings)
77+ })
78+ }
79+}
80+
81+func TestRepoIssuePresentationRules(t *testing.T) {
82+ for _, tc := range []struct {
83+ mode domain.IssuesMode
84+ enabled, receipt bool
85+ }{
86+ {"", false, false}, {domain.IssuesOff, false, false},
87+ {domain.IssuesOwner, true, false}, {domain.IssuesPublic, true, true},
88+ } {
89+ t.Run(string(tc.mode), func(t *testing.T) {
90+ s := domain.RepoSettings{Issues: tc.mode}
91+ assert.Equal(t, tc.enabled, s.IssuesEnabled())
92+ assert.Equal(t, tc.receipt, s.ShowsIssueSubmissionReceipt())
93+ assert.Equal(t, tc.receipt, s.AcceptsAnonymousIssues())
94+ })
95+ }
96+}
@@ -60,3 +60,37 @@ func TestNormalizeTopics(t *testing.T) {
60 })60 })
61 }61 }
62 }62 }
63+
64+func TestRepoEffectiveSettings(t *testing.T) {
65+ custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeMode("squash")}, Issues: domain.IssuesPublic}
66+ for _, tc := range []struct {
67+ name string
68+ settings, want domain.RepoSettings
69+ }{
70+ {"unset", domain.RepoSettings{}, domain.DefaultRepoSettings()},
71+ {"custom", custom, custom},
72+ } {
73+ t.Run(tc.name, func(t *testing.T) {
74+ repo := domain.Repo{Settings: tc.settings}
75+ assert.Equal(t, tc.want, repo.EffectiveSettings())
76+ assert.Equal(t, tc.settings, repo.Settings)
77+ })
78+ }
79+}
80+
81+func TestRepoIssuePresentationRules(t *testing.T) {
82+ for _, tc := range []struct {
83+ mode domain.IssuesMode
84+ enabled, receipt bool
85+ }{
86+ {"", false, false}, {domain.IssuesOff, false, false},
87+ {domain.IssuesOwner, true, false}, {domain.IssuesPublic, true, true},
88+ } {
89+ t.Run(string(tc.mode), func(t *testing.T) {
90+ s := domain.RepoSettings{Issues: tc.mode}
91+ assert.Equal(t, tc.enabled, s.IssuesEnabled())
92+ assert.Equal(t, tc.receipt, s.ShowsIssueSubmissionReceipt())
93+ assert.Equal(t, tc.receipt, s.AcceptsAnonymousIssues())
94+ })
95+ }
96+}
A internal/domain/revision.go +54 -0
A internal/domain/revision_test.go +58 -0
M internal/ports/ci.go +41 -15
M internal/ports/dto.go +28 -0
M internal/ports/mocks/MergeRequestService.go +168 -0
M internal/ports/mocks/OIDCKeyService.go +44 -0
A internal/ports/mocks/PushTreeReader.go +223 -0
D internal/ports/mocks/WorkflowGuard.go +0 -127
M internal/ports/oidc.go +2 -0
M internal/ports/services.go +4 -0
M internal/usecase/ci.go +1 -1
A internal/usecase/ci_creation_policy_test.go +48 -0
M internal/usecase/ci_oidc.go +3 -9
M internal/usecase/ci_pull.go +9 -1
M internal/usecase/ci_runner.go +1 -1
M internal/usecase/events.go +2 -2
M internal/usecase/inbox.go +1 -1
M internal/usecase/issue_moderation.go +1 -1
M internal/usecase/issue_submissions.go +1 -1
M internal/usecase/issues.go +1 -1
M internal/usecase/mergerequests.go +57 -8
M internal/usecase/mergerequests_test.go +144 -0
M internal/usecase/oidc.go +27 -3
M internal/usecase/oidc_test.go +44 -1
M internal/usecase/pushes.go +30 -14
M internal/usecase/pushes_test.go +79 -17
M internal/usecase/repos_write.go +1 -3

Comments1 open / 1

bnema outdated

Scope extended: the business rules flagged in adapters now live in domain/usecase.

  • domain: Revision.Candidates, CommitObjectBudget/ValidateCommitObjects/DiffBudget, WorkflowChangeOf (port WorkflowGuard -> PushTreeReader), Repo.EffectiveSettings, IssuesEnabled/ShowsIssueSubmissionReceipt/AcceptsAnonymousIssues.
  • usecase: CI.createRun refuses disabled workflows (kvstore keeps the atomic precondition); OIDC service always built, decides disabled-mode errors; MergeRequests.Compare/PushSummary, Get returns NewCommits/HeadUnread.
  • Deliberate changes: the commits banner detects a rewrite by ancestry; the compare page uses the real target..source range.
  • Ignore directives: 129 -> 49, none marked 'pending move'.

Reviewed (correctness/security + architecture/maintainability/tests), findings fixed or deferred (GO-005 wiring, GO-011/012 budget loop factoring). Rebased on main 4534992. Build, vet, gofmt, hexcheck -fail-on warn, go test ./internal/... and -race on touched packages pass.

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close