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
M internal/adapters/web/events.go +3 -1
M internal/adapters/web/events_test.go +13 -0
M internal/adapters/web/fragments.go +43 -32
M internal/adapters/web/issues.go +3 -5
M internal/adapters/web/issues_form.go +3 -1
M internal/adapters/web/issues_post.go +0 -1
M internal/adapters/web/mr.go +96 -88
M internal/adapters/web/mr_files.go +0 -2
M internal/adapters/web/mr_post.go +0 -2
M internal/adapters/web/mr_test.go +4 -4
M internal/adapters/web/paths.go +0 -2
M internal/adapters/web/raw.go +0 -2
M internal/adapters/web/refs.go +0 -4
M internal/adapters/web/repo.go +3 -7
M internal/adapters/web/tree.go +0 -2
M internal/adapters/webauthn/webauthn.go +0 -2
M internal/app/forge.go +3 -8
A internal/domain/budget.go +85 -0
A internal/domain/budget_test.go +141 -0
A internal/domain/ci_workflow_guard.go +123 -0
A internal/domain/ci_workflow_guard_test.go +120 -0
M internal/domain/mergerequest.go +9 -0
M internal/domain/repo.go +8 -0
M internal/domain/repo_test.go +34 -0
A internal/domain/revision.go +54 -0
@@ -0,0 +1,54 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "strings"
6+
7+// RevisionCandidateKind identifies the lookup needed to resolve a revision.
8+type RevisionCandidateKind uint8
9+
10+const (
11+ RevisionCandidateHead RevisionCandidateKind = iota
12+ RevisionCandidateObject
13+ RevisionCandidateRef
14+ RevisionCandidateAbbrevID
15+)
16+
17+// RevisionCandidate is one lookup to try when resolving a revision.
18+// Name is HEAD, an object id, a full ref name, or an abbreviated object id.
19+type RevisionCandidate struct {
20+ Kind RevisionCandidateKind
21+ Name string
22+}
23+
24+// Candidates returns the resolution order. HEAD is special; full object ids
25+// are objects before refs, so a ref cannot redirect an existing object link.
26+// Otherwise branches beat tags. Full ref names and abbreviated ids are tried
27+// as written only after the branch and tag forms.
28+func (r Revision) Candidates() []RevisionCandidate {
29+ s := r.String()
30+ if r.IsHead() || s == "HEAD" {
31+ return []RevisionCandidate{{Kind: RevisionCandidateHead, Name: "HEAD"}}
32+ }
33+ refs := []RevisionCandidate{
34+ {Kind: RevisionCandidateRef, Name: "refs/heads/" + s},
35+ {Kind: RevisionCandidateRef, Name: "refs/tags/" + s},
36+ }
37+ switch {
38+ case r.IsObjectID():
39+ return append([]RevisionCandidate{{Kind: RevisionCandidateObject, Name: s}}, refs...)
40+ case strings.HasPrefix(s, "refs/"):
41+ return append(refs, RevisionCandidate{Kind: RevisionCandidateRef, Name: s})
42+ case r.IsAbbrevID():
43+ return append(refs, RevisionCandidate{Kind: RevisionCandidateAbbrevID, Name: s})
44+ }
45+ return refs
46+}
47+
48+// ResolvedRef returns the full ref name for a ref candidate, or empty otherwise.
49+func (c RevisionCandidate) ResolvedRef() string {
50+ if c.Kind == RevisionCandidateRef {
51+ return c.Name
52+ }
53+ return ""
54+}
@@ -0,0 +1,54 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "strings"
6+
7+// RevisionCandidateKind identifies the lookup needed to resolve a revision.
8+type RevisionCandidateKind uint8
9+
10+const (
11+ RevisionCandidateHead RevisionCandidateKind = iota
12+ RevisionCandidateObject
13+ RevisionCandidateRef
14+ RevisionCandidateAbbrevID
15+)
16+
17+// RevisionCandidate is one lookup to try when resolving a revision.
18+// Name is HEAD, an object id, a full ref name, or an abbreviated object id.
19+type RevisionCandidate struct {
20+ Kind RevisionCandidateKind
21+ Name string
22+}
23+
24+// Candidates returns the resolution order. HEAD is special; full object ids
25+// are objects before refs, so a ref cannot redirect an existing object link.
26+// Otherwise branches beat tags. Full ref names and abbreviated ids are tried
27+// as written only after the branch and tag forms.
28+func (r Revision) Candidates() []RevisionCandidate {
29+ s := r.String()
30+ if r.IsHead() || s == "HEAD" {
31+ return []RevisionCandidate{{Kind: RevisionCandidateHead, Name: "HEAD"}}
32+ }
33+ refs := []RevisionCandidate{
34+ {Kind: RevisionCandidateRef, Name: "refs/heads/" + s},
35+ {Kind: RevisionCandidateRef, Name: "refs/tags/" + s},
36+ }
37+ switch {
38+ case r.IsObjectID():
39+ return append([]RevisionCandidate{{Kind: RevisionCandidateObject, Name: s}}, refs...)
40+ case strings.HasPrefix(s, "refs/"):
41+ return append(refs, RevisionCandidate{Kind: RevisionCandidateRef, Name: s})
42+ case r.IsAbbrevID():
43+ return append(refs, RevisionCandidate{Kind: RevisionCandidateAbbrevID, Name: s})
44+ }
45+ return refs
46+}
47+
48+// ResolvedRef returns the full ref name for a ref candidate, or empty otherwise.
49+func (c RevisionCandidate) ResolvedRef() string {
50+ if c.Kind == RevisionCandidateRef {
51+ return c.Name
52+ }
53+ return ""
54+}
A internal/domain/revision_test.go +58 -0
@@ -0,0 +1,58 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestRevisionCandidates(t *testing.T) {
16+ full := strings.Repeat("a", 40)
17+ ref := func(s string) domain.RevisionCandidate {
18+ return domain.RevisionCandidate{Kind: domain.RevisionCandidateRef, Name: s}
19+ }
20+ branches := func(s string) []domain.RevisionCandidate {
21+ return []domain.RevisionCandidate{ref("refs/heads/" + s), ref("refs/tags/" + s)}
22+ }
23+ for _, tt := range []struct {
24+ name, text string
25+ want []domain.RevisionCandidate
26+ }{
27+ {"zero HEAD", "", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}},
28+ {"explicit HEAD", "HEAD", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}},
29+ {"full object first", full, append([]domain.RevisionCandidate{{Kind: domain.RevisionCandidateObject, Name: full}}, branches(full)...)},
30+ {"full branch ref last", "refs/heads/main", append(branches("refs/heads/main"), ref("refs/heads/main"))},
31+ {"full tag ref last", "refs/tags/v1", append(branches("refs/tags/v1"), ref("refs/tags/v1"))},
32+ {"other full ref last", "refs/custom/x", append(branches("refs/custom/x"), ref("refs/custom/x"))},
33+ {"minimum abbreviation last", "abc1234", append(branches("abc1234"), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: "abc1234"})},
34+ {"maximum abbreviation last", full[:39], append(branches(full[:39]), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: full[:39]})},
35+ {"short hex is ref", "abc123", branches("abc123")},
36+ {"uppercase hex is ref", "ABC1234", branches("ABC1234")},
37+ {"ordinary name", "main", branches("main")},
38+ {"slash name", "feature/x", branches("feature/x")},
39+ } {
40+ t.Run(tt.name, func(t *testing.T) {
41+ r, err := domain.ParseRevision(tt.text)
42+ require.NoError(t, err)
43+ assert.Equal(t, tt.want, r.Candidates())
44+ })
45+ }
46+}
47+
48+func TestRevisionCandidateResolvedRef(t *testing.T) {
49+ for _, tc := range []struct {
50+ kind domain.RevisionCandidateKind
51+ want string
52+ }{
53+ {domain.RevisionCandidateHead, ""}, {domain.RevisionCandidateObject, ""},
54+ {domain.RevisionCandidateRef, "refs/heads/main"}, {domain.RevisionCandidateAbbrevID, ""},
55+ } {
56+ assert.Equal(t, tc.want, (domain.RevisionCandidate{Kind: tc.kind, Name: "refs/heads/main"}).ResolvedRef())
57+ }
58+}
@@ -0,0 +1,58 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestRevisionCandidates(t *testing.T) {
16+ full := strings.Repeat("a", 40)
17+ ref := func(s string) domain.RevisionCandidate {
18+ return domain.RevisionCandidate{Kind: domain.RevisionCandidateRef, Name: s}
19+ }
20+ branches := func(s string) []domain.RevisionCandidate {
21+ return []domain.RevisionCandidate{ref("refs/heads/" + s), ref("refs/tags/" + s)}
22+ }
23+ for _, tt := range []struct {
24+ name, text string
25+ want []domain.RevisionCandidate
26+ }{
27+ {"zero HEAD", "", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}},
28+ {"explicit HEAD", "HEAD", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}},
29+ {"full object first", full, append([]domain.RevisionCandidate{{Kind: domain.RevisionCandidateObject, Name: full}}, branches(full)...)},
30+ {"full branch ref last", "refs/heads/main", append(branches("refs/heads/main"), ref("refs/heads/main"))},
31+ {"full tag ref last", "refs/tags/v1", append(branches("refs/tags/v1"), ref("refs/tags/v1"))},
32+ {"other full ref last", "refs/custom/x", append(branches("refs/custom/x"), ref("refs/custom/x"))},
33+ {"minimum abbreviation last", "abc1234", append(branches("abc1234"), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: "abc1234"})},
34+ {"maximum abbreviation last", full[:39], append(branches(full[:39]), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: full[:39]})},
35+ {"short hex is ref", "abc123", branches("abc123")},
36+ {"uppercase hex is ref", "ABC1234", branches("ABC1234")},
37+ {"ordinary name", "main", branches("main")},
38+ {"slash name", "feature/x", branches("feature/x")},
39+ } {
40+ t.Run(tt.name, func(t *testing.T) {
41+ r, err := domain.ParseRevision(tt.text)
42+ require.NoError(t, err)
43+ assert.Equal(t, tt.want, r.Candidates())
44+ })
45+ }
46+}
47+
48+func TestRevisionCandidateResolvedRef(t *testing.T) {
49+ for _, tc := range []struct {
50+ kind domain.RevisionCandidateKind
51+ want string
52+ }{
53+ {domain.RevisionCandidateHead, ""}, {domain.RevisionCandidateObject, ""},
54+ {domain.RevisionCandidateRef, "refs/heads/main"}, {domain.RevisionCandidateAbbrevID, ""},
55+ } {
56+ assert.Equal(t, tc.want, (domain.RevisionCandidate{Kind: tc.kind, Name: "refs/heads/main"}).ResolvedRef())
57+ }
58+}
M internal/ports/ci.go +41 -15
@@ -407,21 +407,47 @@ type PushObjects struct {
407407 Alternates string
408408 }
409409
410-// WorkflowGuard finds the changes of a push to the workflow files of a
411-// repository.
412-type WorkflowGuard interface {
413- // ChangedWorkflow returns the first path under .github/workflows/ that the
414- // updates change, "" for none. Two checks, as GitHub judges the token of a
415- // job: (1) every commit the push brings (those no ref has yet) is compared
416- // with each of its parents (a root commit with nothing) and is a change when
417- // it has workflows that differ from every parent's; (2) the workflows a ref
418- // ends with must be its old ones or none for an update, and the default
419- // branch head's or none for a new ref (none when there is no head). A
420- // workflows directory that disappears is no change, and a deleted ref changes
421- // nothing. objects are those of the push in progress; a directory that is not
422- // inside the repository is refused. A push beyond the bounds of the check
423- // (commits, parents) is an error that matches domain.ErrInvalid.
424- ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error)
410+// PushTreeReader reads, for a push still in quarantine, the objects the
411+// workflow policy (domain.WorkflowChangeOf) judges. It knows nothing of the
412+// policy: it peels, classifies, walks and compares trees.
413+type PushTreeReader interface {
414+ // PushSubtrees reads what a push brings, and the entry at path (a
415+ // directory path without leading or trailing slash, e.g.
416+ // domain.WorkflowDir) in each object concerned.
417+ //
418+ // Updates that delete (New == ZeroOID) have no objects: they are skipped.
419+ // For every other update, Old (unless ZeroOID) and New are peeled
420+ // (`<oid>^{}`): the result is in Ends keyed by the oid as given. An end
421+ // that is missing, malformed, or peels to neither commit, tree nor blob is
422+ // an error matching domain.ErrInvalid.
423+ //
424+ // Commits is `rev-list --parents <tips> --not --all` where tips are the
425+ // distinct New ends that peel to a commit: the commits no ref has yet,
426+ // each with its parents, in rev-list order. More than bounds.Commits
427+ // commits, more than bounds.IDs ids (commits and parents, repeats
428+ // included) or a commit whose parent line exceeds the adapter's line
429+ // buffer is an error matching domain.ErrInvalid, raised as soon as the
430+ // line crossing the bound is read.
431+ //
432+ // Subtrees holds the entry at path for every end, every commit, every
433+ // parent, and, when a create has a non-blob New, the default branch head
434+ // (key domain.HeadKey, absent when HEAD is unborn or has no such entry). An object that peels to a blob, or an
435+ // object with no entry at path (including a path that crosses a
436+ // gitlink, symlink or file), has no key. Kind is EntryDirectory only for
437+ // mode 040000 and type tree.
438+ //
439+ // objects are the quarantine directories of the push; one outside the
440+ // repository's objects directory (symlinks resolved) is an error matching
441+ // domain.ErrInvalid, checked before anything is read.
442+ PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)
443+
444+ // FirstDifferentEntry returns the name of the first top-level entry that
445+ // differs between the trees old and new (`diff-tree --name-only -z
446+ // --no-renames`, read as a stream up to the first name, then git is
447+ // stopped). A zero old is the empty tree. It returns "" when nothing
448+ // differs. A name longer than the adapter's path bound is an error
449+ // matching domain.ErrInvalid. objects as above.
450+ FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects PushObjects, old, new domain.ObjectID) (name string, err error)
425451 }
426452
427453 // --- inbound ---
@@ -407,21 +407,47 @@ type PushObjects struct {
407 Alternates string407 Alternates string
408 }408 }
409 409
410-// WorkflowGuard finds the changes of a push to the workflow files of a410+// PushTreeReader reads, for a push still in quarantine, the objects the
411-// repository.411+// workflow policy (domain.WorkflowChangeOf) judges. It knows nothing of the
412-type WorkflowGuard interface {412+// policy: it peels, classifies, walks and compares trees.
413- // ChangedWorkflow returns the first path under .github/workflows/ that the413+type PushTreeReader interface {
414- // updates change, "" for none. Two checks, as GitHub judges the token of a414+ // PushSubtrees reads what a push brings, and the entry at path (a
415- // job: (1) every commit the push brings (those no ref has yet) is compared415+ // directory path without leading or trailing slash, e.g.
416- // with each of its parents (a root commit with nothing) and is a change when416+ // domain.WorkflowDir) in each object concerned.
417- // it has workflows that differ from every parent's; (2) the workflows a ref417+ //
418- // ends with must be its old ones or none for an update, and the default418+ // Updates that delete (New == ZeroOID) have no objects: they are skipped.
419- // branch head's or none for a new ref (none when there is no head). A419+ // For every other update, Old (unless ZeroOID) and New are peeled
420- // workflows directory that disappears is no change, and a deleted ref changes420+ // (`<oid>^{}`): the result is in Ends keyed by the oid as given. An end
421- // nothing. objects are those of the push in progress; a directory that is not421+ // that is missing, malformed, or peels to neither commit, tree nor blob is
422- // inside the repository is refused. A push beyond the bounds of the check422+ // an error matching domain.ErrInvalid.
423- // (commits, parents) is an error that matches domain.ErrInvalid.423+ //
424- ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error)424+ // Commits is `rev-list --parents <tips> --not --all` where tips are the
425+ // distinct New ends that peel to a commit: the commits no ref has yet,
426+ // each with its parents, in rev-list order. More than bounds.Commits
427+ // commits, more than bounds.IDs ids (commits and parents, repeats
428+ // included) or a commit whose parent line exceeds the adapter's line
429+ // buffer is an error matching domain.ErrInvalid, raised as soon as the
430+ // line crossing the bound is read.
431+ //
432+ // Subtrees holds the entry at path for every end, every commit, every
433+ // parent, and, when a create has a non-blob New, the default branch head
434+ // (key domain.HeadKey, absent when HEAD is unborn or has no such entry). An object that peels to a blob, or an
435+ // object with no entry at path (including a path that crosses a
436+ // gitlink, symlink or file), has no key. Kind is EntryDirectory only for
437+ // mode 040000 and type tree.
438+ //
439+ // objects are the quarantine directories of the push; one outside the
440+ // repository's objects directory (symlinks resolved) is an error matching
441+ // domain.ErrInvalid, checked before anything is read.
442+ PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)
443+
444+ // FirstDifferentEntry returns the name of the first top-level entry that
445+ // differs between the trees old and new (`diff-tree --name-only -z
446+ // --no-renames`, read as a stream up to the first name, then git is
447+ // stopped). A zero old is the empty tree. It returns "" when nothing
448+ // differs. A name longer than the adapter's path bound is an error
449+ // matching domain.ErrInvalid. objects as above.
450+ FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects PushObjects, old, new domain.ObjectID) (name string, err error)
425 }451 }
426 452
427 // --- inbound ---453 // --- inbound ---
M internal/ports/dto.go +28 -0
@@ -249,9 +249,27 @@ type IssueDetail struct {
249249 Comments []domain.IssueComment
250250 }
251251
252+// CompareResult describes source commits absent from target, newest first.
253+type CompareResult struct {
254+ Ahead []domain.Commit
255+ Behind bool
256+ Exact bool
257+}
258+
259+// PushSummary counts a bounded range; More means additional commits exist.
260+type PushSummary struct {
261+ Count int
262+ More bool
263+ Rewritten bool
264+}
265+
252266 // MergeRequestDetail is a merge request with its review data.
253267 type MergeRequestDetail struct {
254268 MergeRequest domain.MergeRequest
269+ // NewCommits is the unread prefix of Commits; zero if the read head is absent.
270+ NewCommits int
271+ // HeadUnread reports whether the nonzero head differs from the shared read position.
272+ HeadUnread bool
255273 // Commits are the commits of the head that the target lacks, newest
256274 // first, at most MaxMergeRequestCommits.
257275 Commits []domain.Commit
@@ -357,3 +375,13 @@ type InboxOverview struct {
357375 // whatever the filter: they feed the filter itself.
358376 Repos []domain.RepoName
359377 }
378+
379+// History query limits.
380+const (
381+ // MaxMergeRequestCommits bounds the commits returned with a merge request.
382+ MaxMergeRequestCommits = 100
383+ // MaxCompareCommits bounds a repository comparison.
384+ MaxCompareCommits = 500
385+ // MaxPushSummaryCommits bounds the commits counted in a push summary.
386+ MaxPushSummaryCommits = 100
387+)
@@ -249,9 +249,27 @@ type IssueDetail struct {
249 Comments []domain.IssueComment249 Comments []domain.IssueComment
250 }250 }
251 251
252+// CompareResult describes source commits absent from target, newest first.
253+type CompareResult struct {
254+ Ahead []domain.Commit
255+ Behind bool
256+ Exact bool
257+}
258+
259+// PushSummary counts a bounded range; More means additional commits exist.
260+type PushSummary struct {
261+ Count int
262+ More bool
263+ Rewritten bool
264+}
265+
252 // MergeRequestDetail is a merge request with its review data.266 // MergeRequestDetail is a merge request with its review data.
253 type MergeRequestDetail struct {267 type MergeRequestDetail struct {
254 MergeRequest domain.MergeRequest268 MergeRequest domain.MergeRequest
269+ // NewCommits is the unread prefix of Commits; zero if the read head is absent.
270+ NewCommits int
271+ // HeadUnread reports whether the nonzero head differs from the shared read position.
272+ HeadUnread bool
255 // Commits are the commits of the head that the target lacks, newest273 // Commits are the commits of the head that the target lacks, newest
256 // first, at most MaxMergeRequestCommits.274 // first, at most MaxMergeRequestCommits.
257 Commits []domain.Commit275 Commits []domain.Commit
@@ -357,3 +375,13 @@ type InboxOverview struct {
357 // whatever the filter: they feed the filter itself.375 // whatever the filter: they feed the filter itself.
358 Repos []domain.RepoName376 Repos []domain.RepoName
359 }377 }
378+
379+// History query limits.
380+const (
381+ // MaxMergeRequestCommits bounds the commits returned with a merge request.
382+ MaxMergeRequestCommits = 100
383+ // MaxCompareCommits bounds a repository comparison.
384+ MaxCompareCommits = 500
385+ // MaxPushSummaryCommits bounds the commits counted in a push summary.
386+ MaxPushSummaryCommits = 100
387+)
M internal/ports/mocks/MergeRequestService.go +168 -0
@@ -210,6 +210,90 @@ func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Cont
210210 return _c
211211 }
212212
213+// Compare provides a mock function for the type MergeRequestService
214+func (_mock *MergeRequestService) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error) {
215+ ret := _mock.Called(ctx, p, repo, target, source)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Compare")
219+ }
220+
221+ var r0 ports.CompareResult
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.CompareResult, error)); ok {
224+ return returnFunc(ctx, p, repo, target, source)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.CompareResult); ok {
227+ r0 = returnFunc(ctx, p, repo, target, source)
228+ } else {
229+ r0 = ret.Get(0).(ports.CompareResult)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
232+ r1 = returnFunc(ctx, p, repo, target, source)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// MergeRequestService_Compare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Compare'
240+type MergeRequestService_Compare_Call struct {
241+ *mock.Call
242+}
243+
244+// Compare is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - repo domain.RepoName
248+// - target domain.ObjectID
249+// - source domain.ObjectID
250+func (_e *MergeRequestService_Expecter) Compare(ctx any, p any, repo any, target any, source any) *MergeRequestService_Compare_Call {
251+ return &MergeRequestService_Compare_Call{Call: _e.mock.On("Compare", ctx, p, repo, target, source)}
252+}
253+
254+func (_c *MergeRequestService_Compare_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID)) *MergeRequestService_Compare_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 domain.Principal
261+ if args[1] != nil {
262+ arg1 = args[1].(domain.Principal)
263+ }
264+ var arg2 domain.RepoName
265+ if args[2] != nil {
266+ arg2 = args[2].(domain.RepoName)
267+ }
268+ var arg3 domain.ObjectID
269+ if args[3] != nil {
270+ arg3 = args[3].(domain.ObjectID)
271+ }
272+ var arg4 domain.ObjectID
273+ if args[4] != nil {
274+ arg4 = args[4].(domain.ObjectID)
275+ }
276+ run(
277+ arg0,
278+ arg1,
279+ arg2,
280+ arg3,
281+ arg4,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *MergeRequestService_Compare_Call) Return(compareResult ports.CompareResult, err error) *MergeRequestService_Compare_Call {
288+ _c.Call.Return(compareResult, err)
289+ return _c
290+}
291+
292+func (_c *MergeRequestService_Compare_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error)) *MergeRequestService_Compare_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
213297 // CountOpen provides a mock function for the type MergeRequestService
214298 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
215299 ret := _mock.Called(ctx, p, name)
@@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte
10201104 return _c
10211105 }
10221106
1107+// PushSummary provides a mock function for the type MergeRequestService
1108+func (_mock *MergeRequestService) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error) {
1109+ ret := _mock.Called(ctx, p, repo, oldHead, newTip)
1110+
1111+ if len(ret) == 0 {
1112+ panic("no return value specified for PushSummary")
1113+ }
1114+
1115+ var r0 ports.PushSummary
1116+ var r1 error
1117+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.PushSummary, error)); ok {
1118+ return returnFunc(ctx, p, repo, oldHead, newTip)
1119+ }
1120+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.PushSummary); ok {
1121+ r0 = returnFunc(ctx, p, repo, oldHead, newTip)
1122+ } else {
1123+ r0 = ret.Get(0).(ports.PushSummary)
1124+ }
1125+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
1126+ r1 = returnFunc(ctx, p, repo, oldHead, newTip)
1127+ } else {
1128+ r1 = ret.Error(1)
1129+ }
1130+ return r0, r1
1131+}
1132+
1133+// MergeRequestService_PushSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSummary'
1134+type MergeRequestService_PushSummary_Call struct {
1135+ *mock.Call
1136+}
1137+
1138+// PushSummary is a helper method to define mock.On call
1139+// - ctx context.Context
1140+// - p domain.Principal
1141+// - repo domain.RepoName
1142+// - oldHead domain.ObjectID
1143+// - newTip domain.ObjectID
1144+func (_e *MergeRequestService_Expecter) PushSummary(ctx any, p any, repo any, oldHead any, newTip any) *MergeRequestService_PushSummary_Call {
1145+ return &MergeRequestService_PushSummary_Call{Call: _e.mock.On("PushSummary", ctx, p, repo, oldHead, newTip)}
1146+}
1147+
1148+func (_c *MergeRequestService_PushSummary_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID)) *MergeRequestService_PushSummary_Call {
1149+ _c.Call.Run(func(args mock.Arguments) {
1150+ var arg0 context.Context
1151+ if args[0] != nil {
1152+ arg0 = args[0].(context.Context)
1153+ }
1154+ var arg1 domain.Principal
1155+ if args[1] != nil {
1156+ arg1 = args[1].(domain.Principal)
1157+ }
1158+ var arg2 domain.RepoName
1159+ if args[2] != nil {
1160+ arg2 = args[2].(domain.RepoName)
1161+ }
1162+ var arg3 domain.ObjectID
1163+ if args[3] != nil {
1164+ arg3 = args[3].(domain.ObjectID)
1165+ }
1166+ var arg4 domain.ObjectID
1167+ if args[4] != nil {
1168+ arg4 = args[4].(domain.ObjectID)
1169+ }
1170+ run(
1171+ arg0,
1172+ arg1,
1173+ arg2,
1174+ arg3,
1175+ arg4,
1176+ )
1177+ })
1178+ return _c
1179+}
1180+
1181+func (_c *MergeRequestService_PushSummary_Call) Return(pushSummary ports.PushSummary, err error) *MergeRequestService_PushSummary_Call {
1182+ _c.Call.Return(pushSummary, err)
1183+ return _c
1184+}
1185+
1186+func (_c *MergeRequestService_PushSummary_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error)) *MergeRequestService_PushSummary_Call {
1187+ _c.Call.Return(run)
1188+ return _c
1189+}
1190+
10231191 // ResolveComment provides a mock function for the type MergeRequestService
10241192 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
10251193 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
@@ -210,6 +210,90 @@ func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Cont
210 return _c210 return _c
211 }211 }
212 212
213+// Compare provides a mock function for the type MergeRequestService
214+func (_mock *MergeRequestService) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error) {
215+ ret := _mock.Called(ctx, p, repo, target, source)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Compare")
219+ }
220+
221+ var r0 ports.CompareResult
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.CompareResult, error)); ok {
224+ return returnFunc(ctx, p, repo, target, source)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.CompareResult); ok {
227+ r0 = returnFunc(ctx, p, repo, target, source)
228+ } else {
229+ r0 = ret.Get(0).(ports.CompareResult)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
232+ r1 = returnFunc(ctx, p, repo, target, source)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// MergeRequestService_Compare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Compare'
240+type MergeRequestService_Compare_Call struct {
241+ *mock.Call
242+}
243+
244+// Compare is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - repo domain.RepoName
248+// - target domain.ObjectID
249+// - source domain.ObjectID
250+func (_e *MergeRequestService_Expecter) Compare(ctx any, p any, repo any, target any, source any) *MergeRequestService_Compare_Call {
251+ return &MergeRequestService_Compare_Call{Call: _e.mock.On("Compare", ctx, p, repo, target, source)}
252+}
253+
254+func (_c *MergeRequestService_Compare_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID)) *MergeRequestService_Compare_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 domain.Principal
261+ if args[1] != nil {
262+ arg1 = args[1].(domain.Principal)
263+ }
264+ var arg2 domain.RepoName
265+ if args[2] != nil {
266+ arg2 = args[2].(domain.RepoName)
267+ }
268+ var arg3 domain.ObjectID
269+ if args[3] != nil {
270+ arg3 = args[3].(domain.ObjectID)
271+ }
272+ var arg4 domain.ObjectID
273+ if args[4] != nil {
274+ arg4 = args[4].(domain.ObjectID)
275+ }
276+ run(
277+ arg0,
278+ arg1,
279+ arg2,
280+ arg3,
281+ arg4,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *MergeRequestService_Compare_Call) Return(compareResult ports.CompareResult, err error) *MergeRequestService_Compare_Call {
288+ _c.Call.Return(compareResult, err)
289+ return _c
290+}
291+
292+func (_c *MergeRequestService_Compare_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error)) *MergeRequestService_Compare_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
213 // CountOpen provides a mock function for the type MergeRequestService297 // CountOpen provides a mock function for the type MergeRequestService
214 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {298 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
215 ret := _mock.Called(ctx, p, name)299 ret := _mock.Called(ctx, p, name)
@@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte
1020 return _c1104 return _c
1021 }1105 }
1022 1106
1107+// PushSummary provides a mock function for the type MergeRequestService
1108+func (_mock *MergeRequestService) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error) {
1109+ ret := _mock.Called(ctx, p, repo, oldHead, newTip)
1110+
1111+ if len(ret) == 0 {
1112+ panic("no return value specified for PushSummary")
1113+ }
1114+
1115+ var r0 ports.PushSummary
1116+ var r1 error
1117+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.PushSummary, error)); ok {
1118+ return returnFunc(ctx, p, repo, oldHead, newTip)
1119+ }
1120+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.PushSummary); ok {
1121+ r0 = returnFunc(ctx, p, repo, oldHead, newTip)
1122+ } else {
1123+ r0 = ret.Get(0).(ports.PushSummary)
1124+ }
1125+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
1126+ r1 = returnFunc(ctx, p, repo, oldHead, newTip)
1127+ } else {
1128+ r1 = ret.Error(1)
1129+ }
1130+ return r0, r1
1131+}
1132+
1133+// MergeRequestService_PushSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSummary'
1134+type MergeRequestService_PushSummary_Call struct {
1135+ *mock.Call
1136+}
1137+
1138+// PushSummary is a helper method to define mock.On call
1139+// - ctx context.Context
1140+// - p domain.Principal
1141+// - repo domain.RepoName
1142+// - oldHead domain.ObjectID
1143+// - newTip domain.ObjectID
1144+func (_e *MergeRequestService_Expecter) PushSummary(ctx any, p any, repo any, oldHead any, newTip any) *MergeRequestService_PushSummary_Call {
1145+ return &MergeRequestService_PushSummary_Call{Call: _e.mock.On("PushSummary", ctx, p, repo, oldHead, newTip)}
1146+}
1147+
1148+func (_c *MergeRequestService_PushSummary_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID)) *MergeRequestService_PushSummary_Call {
1149+ _c.Call.Run(func(args mock.Arguments) {
1150+ var arg0 context.Context
1151+ if args[0] != nil {
1152+ arg0 = args[0].(context.Context)
1153+ }
1154+ var arg1 domain.Principal
1155+ if args[1] != nil {
1156+ arg1 = args[1].(domain.Principal)
1157+ }
1158+ var arg2 domain.RepoName
1159+ if args[2] != nil {
1160+ arg2 = args[2].(domain.RepoName)
1161+ }
1162+ var arg3 domain.ObjectID
1163+ if args[3] != nil {
1164+ arg3 = args[3].(domain.ObjectID)
1165+ }
1166+ var arg4 domain.ObjectID
1167+ if args[4] != nil {
1168+ arg4 = args[4].(domain.ObjectID)
1169+ }
1170+ run(
1171+ arg0,
1172+ arg1,
1173+ arg2,
1174+ arg3,
1175+ arg4,
1176+ )
1177+ })
1178+ return _c
1179+}
1180+
1181+func (_c *MergeRequestService_PushSummary_Call) Return(pushSummary ports.PushSummary, err error) *MergeRequestService_PushSummary_Call {
1182+ _c.Call.Return(pushSummary, err)
1183+ return _c
1184+}
1185+
1186+func (_c *MergeRequestService_PushSummary_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error)) *MergeRequestService_PushSummary_Call {
1187+ _c.Call.Return(run)
1188+ return _c
1189+}
1190+
1023 // ResolveComment provides a mock function for the type MergeRequestService1191 // ResolveComment provides a mock function for the type MergeRequestService
1024 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {1192 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
1025 ret := _mock.Called(ctx, p, name, number, commentID, resolved)1193 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
M internal/ports/mocks/OIDCKeyService.go +44 -0
@@ -48,6 +48,50 @@ func (_m *OIDCKeyService) EXPECT() *OIDCKeyService_Expecter {
4848 return &OIDCKeyService_Expecter{mock: &_m.Mock}
4949 }
5050
51+// Enabled provides a mock function for the type OIDCKeyService
52+func (_mock *OIDCKeyService) Enabled() bool {
53+ ret := _mock.Called()
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Enabled")
57+ }
58+
59+ var r0 bool
60+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
61+ r0 = returnFunc()
62+ } else {
63+ r0 = ret.Get(0).(bool)
64+ }
65+ return r0
66+}
67+
68+// OIDCKeyService_Enabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enabled'
69+type OIDCKeyService_Enabled_Call struct {
70+ *mock.Call
71+}
72+
73+// Enabled is a helper method to define mock.On call
74+func (_e *OIDCKeyService_Expecter) Enabled() *OIDCKeyService_Enabled_Call {
75+ return &OIDCKeyService_Enabled_Call{Call: _e.mock.On("Enabled")}
76+}
77+
78+func (_c *OIDCKeyService_Enabled_Call) Run(run func()) *OIDCKeyService_Enabled_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ run()
81+ })
82+ return _c
83+}
84+
85+func (_c *OIDCKeyService_Enabled_Call) Return(b bool) *OIDCKeyService_Enabled_Call {
86+ _c.Call.Return(b)
87+ return _c
88+}
89+
90+func (_c *OIDCKeyService_Enabled_Call) RunAndReturn(run func() bool) *OIDCKeyService_Enabled_Call {
91+ _c.Call.Return(run)
92+ return _c
93+}
94+
5195 // RotateKey provides a mock function for the type OIDCKeyService
5296 func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
5397 ret := _mock.Called(ctx, p)
@@ -48,6 +48,50 @@ func (_m *OIDCKeyService) EXPECT() *OIDCKeyService_Expecter {
48 return &OIDCKeyService_Expecter{mock: &_m.Mock}48 return &OIDCKeyService_Expecter{mock: &_m.Mock}
49 }49 }
50 50
51+// Enabled provides a mock function for the type OIDCKeyService
52+func (_mock *OIDCKeyService) Enabled() bool {
53+ ret := _mock.Called()
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Enabled")
57+ }
58+
59+ var r0 bool
60+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
61+ r0 = returnFunc()
62+ } else {
63+ r0 = ret.Get(0).(bool)
64+ }
65+ return r0
66+}
67+
68+// OIDCKeyService_Enabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enabled'
69+type OIDCKeyService_Enabled_Call struct {
70+ *mock.Call
71+}
72+
73+// Enabled is a helper method to define mock.On call
74+func (_e *OIDCKeyService_Expecter) Enabled() *OIDCKeyService_Enabled_Call {
75+ return &OIDCKeyService_Enabled_Call{Call: _e.mock.On("Enabled")}
76+}
77+
78+func (_c *OIDCKeyService_Enabled_Call) Run(run func()) *OIDCKeyService_Enabled_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ run()
81+ })
82+ return _c
83+}
84+
85+func (_c *OIDCKeyService_Enabled_Call) Return(b bool) *OIDCKeyService_Enabled_Call {
86+ _c.Call.Return(b)
87+ return _c
88+}
89+
90+func (_c *OIDCKeyService_Enabled_Call) RunAndReturn(run func() bool) *OIDCKeyService_Enabled_Call {
91+ _c.Call.Return(run)
92+ return _c
93+}
94+
51 // RotateKey provides a mock function for the type OIDCKeyService95 // RotateKey provides a mock function for the type OIDCKeyService
52 func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {96 func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
53 ret := _mock.Called(ctx, p)97 ret := _mock.Called(ctx, p)
A internal/ports/mocks/PushTreeReader.go +223 -0
@@ -0,0 +1,223 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewPushTreeReader creates a new instance of PushTreeReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewPushTreeReader(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *PushTreeReader {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &PushTreeReader{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// PushTreeReader is an autogenerated mock type for the PushTreeReader type
39+type PushTreeReader struct {
40+ mock.Mock
41+}
42+
43+type PushTreeReader_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *PushTreeReader) EXPECT() *PushTreeReader_Expecter {
48+ return &PushTreeReader_Expecter{mock: &_m.Mock}
49+}
50+
51+// FirstDifferentEntry provides a mock function for the type PushTreeReader
52+func (_mock *PushTreeReader) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error) {
53+ ret := _mock.Called(ctx, repo, objects, old, new)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for FirstDifferentEntry")
57+ }
58+
59+ var r0 string
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) (string, error)); ok {
62+ return returnFunc(ctx, repo, objects, old, new)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) string); ok {
65+ r0 = returnFunc(ctx, repo, objects, old, new)
66+ } else {
67+ r0 = ret.Get(0).(string)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) error); ok {
70+ r1 = returnFunc(ctx, repo, objects, old, new)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// PushTreeReader_FirstDifferentEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FirstDifferentEntry'
78+type PushTreeReader_FirstDifferentEntry_Call struct {
79+ *mock.Call
80+}
81+
82+// FirstDifferentEntry is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - objects ports.PushObjects
86+// - old domain.ObjectID
87+// - new domain.ObjectID
88+func (_e *PushTreeReader_Expecter) FirstDifferentEntry(ctx any, repo any, objects any, old any, new any) *PushTreeReader_FirstDifferentEntry_Call {
89+ return &PushTreeReader_FirstDifferentEntry_Call{Call: _e.mock.On("FirstDifferentEntry", ctx, repo, objects, old, new)}
90+}
91+
92+func (_c *PushTreeReader_FirstDifferentEntry_Call) Run(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID)) *PushTreeReader_FirstDifferentEntry_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 ports.PushObjects
103+ if args[2] != nil {
104+ arg2 = args[2].(ports.PushObjects)
105+ }
106+ var arg3 domain.ObjectID
107+ if args[3] != nil {
108+ arg3 = args[3].(domain.ObjectID)
109+ }
110+ var arg4 domain.ObjectID
111+ if args[4] != nil {
112+ arg4 = args[4].(domain.ObjectID)
113+ }
114+ run(
115+ arg0,
116+ arg1,
117+ arg2,
118+ arg3,
119+ arg4,
120+ )
121+ })
122+ return _c
123+}
124+
125+func (_c *PushTreeReader_FirstDifferentEntry_Call) Return(name string, err error) *PushTreeReader_FirstDifferentEntry_Call {
126+ _c.Call.Return(name, err)
127+ return _c
128+}
129+
130+func (_c *PushTreeReader_FirstDifferentEntry_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error)) *PushTreeReader_FirstDifferentEntry_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
135+// PushSubtrees provides a mock function for the type PushTreeReader
136+func (_mock *PushTreeReader) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
137+ ret := _mock.Called(ctx, repo, updates, objects, path, bounds)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for PushSubtrees")
141+ }
142+
143+ var r0 domain.PushSubtrees
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) (domain.PushSubtrees, error)); ok {
146+ return returnFunc(ctx, repo, updates, objects, path, bounds)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) domain.PushSubtrees); ok {
149+ r0 = returnFunc(ctx, repo, updates, objects, path, bounds)
150+ } else {
151+ r0 = ret.Get(0).(domain.PushSubtrees)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) error); ok {
154+ r1 = returnFunc(ctx, repo, updates, objects, path, bounds)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// PushTreeReader_PushSubtrees_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSubtrees'
162+type PushTreeReader_PushSubtrees_Call struct {
163+ *mock.Call
164+}
165+
166+// PushSubtrees is a helper method to define mock.On call
167+// - ctx context.Context
168+// - repo domain.RepoName
169+// - updates []domain.RefUpdate
170+// - objects ports.PushObjects
171+// - path string
172+// - bounds domain.PushWalkBounds
173+func (_e *PushTreeReader_Expecter) PushSubtrees(ctx any, repo any, updates any, objects any, path any, bounds any) *PushTreeReader_PushSubtrees_Call {
174+ return &PushTreeReader_PushSubtrees_Call{Call: _e.mock.On("PushSubtrees", ctx, repo, updates, objects, path, bounds)}
175+}
176+
177+func (_c *PushTreeReader_PushSubtrees_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds)) *PushTreeReader_PushSubtrees_Call {
178+ _c.Call.Run(func(args mock.Arguments) {
179+ var arg0 context.Context
180+ if args[0] != nil {
181+ arg0 = args[0].(context.Context)
182+ }
183+ var arg1 domain.RepoName
184+ if args[1] != nil {
185+ arg1 = args[1].(domain.RepoName)
186+ }
187+ var arg2 []domain.RefUpdate
188+ if args[2] != nil {
189+ arg2 = args[2].([]domain.RefUpdate)
190+ }
191+ var arg3 ports.PushObjects
192+ if args[3] != nil {
193+ arg3 = args[3].(ports.PushObjects)
194+ }
195+ var arg4 string
196+ if args[4] != nil {
197+ arg4 = args[4].(string)
198+ }
199+ var arg5 domain.PushWalkBounds
200+ if args[5] != nil {
201+ arg5 = args[5].(domain.PushWalkBounds)
202+ }
203+ run(
204+ arg0,
205+ arg1,
206+ arg2,
207+ arg3,
208+ arg4,
209+ arg5,
210+ )
211+ })
212+ return _c
213+}
214+
215+func (_c *PushTreeReader_PushSubtrees_Call) Return(pushSubtrees domain.PushSubtrees, err error) *PushTreeReader_PushSubtrees_Call {
216+ _c.Call.Return(pushSubtrees, err)
217+ return _c
218+}
219+
220+func (_c *PushTreeReader_PushSubtrees_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)) *PushTreeReader_PushSubtrees_Call {
221+ _c.Call.Return(run)
222+ return _c
223+}
@@ -0,0 +1,223 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewPushTreeReader creates a new instance of PushTreeReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewPushTreeReader(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *PushTreeReader {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &PushTreeReader{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// PushTreeReader is an autogenerated mock type for the PushTreeReader type
39+type PushTreeReader struct {
40+ mock.Mock
41+}
42+
43+type PushTreeReader_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *PushTreeReader) EXPECT() *PushTreeReader_Expecter {
48+ return &PushTreeReader_Expecter{mock: &_m.Mock}
49+}
50+
51+// FirstDifferentEntry provides a mock function for the type PushTreeReader
52+func (_mock *PushTreeReader) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error) {
53+ ret := _mock.Called(ctx, repo, objects, old, new)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for FirstDifferentEntry")
57+ }
58+
59+ var r0 string
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) (string, error)); ok {
62+ return returnFunc(ctx, repo, objects, old, new)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) string); ok {
65+ r0 = returnFunc(ctx, repo, objects, old, new)
66+ } else {
67+ r0 = ret.Get(0).(string)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) error); ok {
70+ r1 = returnFunc(ctx, repo, objects, old, new)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// PushTreeReader_FirstDifferentEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FirstDifferentEntry'
78+type PushTreeReader_FirstDifferentEntry_Call struct {
79+ *mock.Call
80+}
81+
82+// FirstDifferentEntry is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - objects ports.PushObjects
86+// - old domain.ObjectID
87+// - new domain.ObjectID
88+func (_e *PushTreeReader_Expecter) FirstDifferentEntry(ctx any, repo any, objects any, old any, new any) *PushTreeReader_FirstDifferentEntry_Call {
89+ return &PushTreeReader_FirstDifferentEntry_Call{Call: _e.mock.On("FirstDifferentEntry", ctx, repo, objects, old, new)}
90+}
91+
92+func (_c *PushTreeReader_FirstDifferentEntry_Call) Run(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID)) *PushTreeReader_FirstDifferentEntry_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 ports.PushObjects
103+ if args[2] != nil {
104+ arg2 = args[2].(ports.PushObjects)
105+ }
106+ var arg3 domain.ObjectID
107+ if args[3] != nil {
108+ arg3 = args[3].(domain.ObjectID)
109+ }
110+ var arg4 domain.ObjectID
111+ if args[4] != nil {
112+ arg4 = args[4].(domain.ObjectID)
113+ }
114+ run(
115+ arg0,
116+ arg1,
117+ arg2,
118+ arg3,
119+ arg4,
120+ )
121+ })
122+ return _c
123+}
124+
125+func (_c *PushTreeReader_FirstDifferentEntry_Call) Return(name string, err error) *PushTreeReader_FirstDifferentEntry_Call {
126+ _c.Call.Return(name, err)
127+ return _c
128+}
129+
130+func (_c *PushTreeReader_FirstDifferentEntry_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error)) *PushTreeReader_FirstDifferentEntry_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
135+// PushSubtrees provides a mock function for the type PushTreeReader
136+func (_mock *PushTreeReader) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
137+ ret := _mock.Called(ctx, repo, updates, objects, path, bounds)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for PushSubtrees")
141+ }
142+
143+ var r0 domain.PushSubtrees
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) (domain.PushSubtrees, error)); ok {
146+ return returnFunc(ctx, repo, updates, objects, path, bounds)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) domain.PushSubtrees); ok {
149+ r0 = returnFunc(ctx, repo, updates, objects, path, bounds)
150+ } else {
151+ r0 = ret.Get(0).(domain.PushSubtrees)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) error); ok {
154+ r1 = returnFunc(ctx, repo, updates, objects, path, bounds)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// PushTreeReader_PushSubtrees_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSubtrees'
162+type PushTreeReader_PushSubtrees_Call struct {
163+ *mock.Call
164+}
165+
166+// PushSubtrees is a helper method to define mock.On call
167+// - ctx context.Context
168+// - repo domain.RepoName
169+// - updates []domain.RefUpdate
170+// - objects ports.PushObjects
171+// - path string
172+// - bounds domain.PushWalkBounds
173+func (_e *PushTreeReader_Expecter) PushSubtrees(ctx any, repo any, updates any, objects any, path any, bounds any) *PushTreeReader_PushSubtrees_Call {
174+ return &PushTreeReader_PushSubtrees_Call{Call: _e.mock.On("PushSubtrees", ctx, repo, updates, objects, path, bounds)}
175+}
176+
177+func (_c *PushTreeReader_PushSubtrees_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds)) *PushTreeReader_PushSubtrees_Call {
178+ _c.Call.Run(func(args mock.Arguments) {
179+ var arg0 context.Context
180+ if args[0] != nil {
181+ arg0 = args[0].(context.Context)
182+ }
183+ var arg1 domain.RepoName
184+ if args[1] != nil {
185+ arg1 = args[1].(domain.RepoName)
186+ }
187+ var arg2 []domain.RefUpdate
188+ if args[2] != nil {
189+ arg2 = args[2].([]domain.RefUpdate)
190+ }
191+ var arg3 ports.PushObjects
192+ if args[3] != nil {
193+ arg3 = args[3].(ports.PushObjects)
194+ }
195+ var arg4 string
196+ if args[4] != nil {
197+ arg4 = args[4].(string)
198+ }
199+ var arg5 domain.PushWalkBounds
200+ if args[5] != nil {
201+ arg5 = args[5].(domain.PushWalkBounds)
202+ }
203+ run(
204+ arg0,
205+ arg1,
206+ arg2,
207+ arg3,
208+ arg4,
209+ arg5,
210+ )
211+ })
212+ return _c
213+}
214+
215+func (_c *PushTreeReader_PushSubtrees_Call) Return(pushSubtrees domain.PushSubtrees, err error) *PushTreeReader_PushSubtrees_Call {
216+ _c.Call.Return(pushSubtrees, err)
217+ return _c
218+}
219+
220+func (_c *PushTreeReader_PushSubtrees_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)) *PushTreeReader_PushSubtrees_Call {
221+ _c.Call.Return(run)
222+ return _c
223+}
D internal/ports/mocks/WorkflowGuard.go +0 -127
@@ -1,127 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package mocks
6-
7-import (
8- "context"
9-
10- "bnema.dev/ermite/internal/domain"
11- "bnema.dev/ermite/internal/ports"
12- mock "github.com/stretchr/testify/mock"
13-)
14-
15-// NewWorkflowGuard creates a new instance of WorkflowGuard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16-// The first argument is typically a *testing.T value.
17-func NewWorkflowGuard(t interface {
18- mock.TestingT
19- Cleanup(func())
20-}) *WorkflowGuard {
21- if helper, ok := t.(interface{ Helper() }); ok {
22- helper.Helper()
23- }
24-
25- mock := &WorkflowGuard{}
26- mock.Mock.Test(t)
27-
28- t.Cleanup(func() {
29- if helper, ok := t.(interface{ Helper() }); ok {
30- helper.Helper()
31- }
32- mock.AssertExpectations(t)
33- })
34-
35- return mock
36-}
37-
38-// WorkflowGuard is an autogenerated mock type for the WorkflowGuard type
39-type WorkflowGuard struct {
40- mock.Mock
41-}
42-
43-type WorkflowGuard_Expecter struct {
44- mock *mock.Mock
45-}
46-
47-func (_m *WorkflowGuard) EXPECT() *WorkflowGuard_Expecter {
48- return &WorkflowGuard_Expecter{mock: &_m.Mock}
49-}
50-
51-// ChangedWorkflow provides a mock function for the type WorkflowGuard
52-func (_mock *WorkflowGuard) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
53- ret := _mock.Called(ctx, repo, updates, objects)
54-
55- if len(ret) == 0 {
56- panic("no return value specified for ChangedWorkflow")
57- }
58-
59- var r0 string
60- var r1 error
61- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) (string, error)); ok {
62- return returnFunc(ctx, repo, updates, objects)
63- }
64- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) string); ok {
65- r0 = returnFunc(ctx, repo, updates, objects)
66- } else {
67- r0 = ret.Get(0).(string)
68- }
69- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) error); ok {
70- r1 = returnFunc(ctx, repo, updates, objects)
71- } else {
72- r1 = ret.Error(1)
73- }
74- return r0, r1
75-}
76-
77-// WorkflowGuard_ChangedWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChangedWorkflow'
78-type WorkflowGuard_ChangedWorkflow_Call struct {
79- *mock.Call
80-}
81-
82-// ChangedWorkflow is a helper method to define mock.On call
83-// - ctx context.Context
84-// - repo domain.RepoName
85-// - updates []domain.RefUpdate
86-// - objects ports.PushObjects
87-func (_e *WorkflowGuard_Expecter) ChangedWorkflow(ctx any, repo any, updates any, objects any) *WorkflowGuard_ChangedWorkflow_Call {
88- return &WorkflowGuard_ChangedWorkflow_Call{Call: _e.mock.On("ChangedWorkflow", ctx, repo, updates, objects)}
89-}
90-
91-func (_c *WorkflowGuard_ChangedWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects)) *WorkflowGuard_ChangedWorkflow_Call {
92- _c.Call.Run(func(args mock.Arguments) {
93- var arg0 context.Context
94- if args[0] != nil {
95- arg0 = args[0].(context.Context)
96- }
97- var arg1 domain.RepoName
98- if args[1] != nil {
99- arg1 = args[1].(domain.RepoName)
100- }
101- var arg2 []domain.RefUpdate
102- if args[2] != nil {
103- arg2 = args[2].([]domain.RefUpdate)
104- }
105- var arg3 ports.PushObjects
106- if args[3] != nil {
107- arg3 = args[3].(ports.PushObjects)
108- }
109- run(
110- arg0,
111- arg1,
112- arg2,
113- arg3,
114- )
115- })
116- return _c
117-}
118-
119-func (_c *WorkflowGuard_ChangedWorkflow_Call) Return(path string, err error) *WorkflowGuard_ChangedWorkflow_Call {
120- _c.Call.Return(path, err)
121- return _c
122-}
123-
124-func (_c *WorkflowGuard_ChangedWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error)) *WorkflowGuard_ChangedWorkflow_Call {
125- _c.Call.Return(run)
126- return _c
127-}
@@ -1,127 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package mocks
6-
7-import (
8- "context"
9-
10- "bnema.dev/ermite/internal/domain"
11- "bnema.dev/ermite/internal/ports"
12- mock "github.com/stretchr/testify/mock"
13-)
14-
15-// NewWorkflowGuard creates a new instance of WorkflowGuard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16-// The first argument is typically a *testing.T value.
17-func NewWorkflowGuard(t interface {
18- mock.TestingT
19- Cleanup(func())
20-}) *WorkflowGuard {
21- if helper, ok := t.(interface{ Helper() }); ok {
22- helper.Helper()
23- }
24-
25- mock := &WorkflowGuard{}
26- mock.Mock.Test(t)
27-
28- t.Cleanup(func() {
29- if helper, ok := t.(interface{ Helper() }); ok {
30- helper.Helper()
31- }
32- mock.AssertExpectations(t)
33- })
34-
35- return mock
36-}
37-
38-// WorkflowGuard is an autogenerated mock type for the WorkflowGuard type
39-type WorkflowGuard struct {
40- mock.Mock
41-}
42-
43-type WorkflowGuard_Expecter struct {
44- mock *mock.Mock
45-}
46-
47-func (_m *WorkflowGuard) EXPECT() *WorkflowGuard_Expecter {
48- return &WorkflowGuard_Expecter{mock: &_m.Mock}
49-}
50-
51-// ChangedWorkflow provides a mock function for the type WorkflowGuard
52-func (_mock *WorkflowGuard) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
53- ret := _mock.Called(ctx, repo, updates, objects)
54-
55- if len(ret) == 0 {
56- panic("no return value specified for ChangedWorkflow")
57- }
58-
59- var r0 string
60- var r1 error
61- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) (string, error)); ok {
62- return returnFunc(ctx, repo, updates, objects)
63- }
64- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) string); ok {
65- r0 = returnFunc(ctx, repo, updates, objects)
66- } else {
67- r0 = ret.Get(0).(string)
68- }
69- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) error); ok {
70- r1 = returnFunc(ctx, repo, updates, objects)
71- } else {
72- r1 = ret.Error(1)
73- }
74- return r0, r1
75-}
76-
77-// WorkflowGuard_ChangedWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChangedWorkflow'
78-type WorkflowGuard_ChangedWorkflow_Call struct {
79- *mock.Call
80-}
81-
82-// ChangedWorkflow is a helper method to define mock.On call
83-// - ctx context.Context
84-// - repo domain.RepoName
85-// - updates []domain.RefUpdate
86-// - objects ports.PushObjects
87-func (_e *WorkflowGuard_Expecter) ChangedWorkflow(ctx any, repo any, updates any, objects any) *WorkflowGuard_ChangedWorkflow_Call {
88- return &WorkflowGuard_ChangedWorkflow_Call{Call: _e.mock.On("ChangedWorkflow", ctx, repo, updates, objects)}
89-}
90-
91-func (_c *WorkflowGuard_ChangedWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects)) *WorkflowGuard_ChangedWorkflow_Call {
92- _c.Call.Run(func(args mock.Arguments) {
93- var arg0 context.Context
94- if args[0] != nil {
95- arg0 = args[0].(context.Context)
96- }
97- var arg1 domain.RepoName
98- if args[1] != nil {
99- arg1 = args[1].(domain.RepoName)
100- }
101- var arg2 []domain.RefUpdate
102- if args[2] != nil {
103- arg2 = args[2].([]domain.RefUpdate)
104- }
105- var arg3 ports.PushObjects
106- if args[3] != nil {
107- arg3 = args[3].(ports.PushObjects)
108- }
109- run(
110- arg0,
111- arg1,
112- arg2,
113- arg3,
114- )
115- })
116- return _c
117-}
118-
119-func (_c *WorkflowGuard_ChangedWorkflow_Call) Return(path string, err error) *WorkflowGuard_ChangedWorkflow_Call {
120- _c.Call.Return(path, err)
121- return _c
122-}
123-
124-func (_c *WorkflowGuard_ChangedWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error)) *WorkflowGuard_ChangedWorkflow_Call {
125- _c.Call.Return(run)
126- return _c
127-}
M internal/ports/oidc.go +2 -0
@@ -53,6 +53,8 @@ type OIDCRotation struct {
5353 // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC
5454 // implements it.
5555 type OIDCKeyService interface {
56+ // Enabled reports whether the server issues ID tokens.
57+ Enabled() bool
5658 // RotateKey replaces the signing key by a new one. It needs owner rights.
5759 RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)
5860 }
@@ -53,6 +53,8 @@ type OIDCRotation struct {
53 // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC53 // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC
54 // implements it.54 // implements it.
55 type OIDCKeyService interface {55 type OIDCKeyService interface {
56+ // Enabled reports whether the server issues ID tokens.
57+ Enabled() bool
56 // RotateKey replaces the signing key by a new one. It needs owner rights.58 // RotateKey replaces the signing key by a new one. It needs owner rights.
57 RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)59 RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)
58 }60 }
M internal/ports/services.go +4 -0
@@ -109,6 +109,10 @@ type EventService interface {
109109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests
110110 // implements it.
111111 type MergeRequestService interface {
112+ // Compare queries repository history; it stays here because this service owns the merge-git port.
113+ Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (CompareResult, error)
114+ // PushSummary queries repository history; it stays here because this service owns the merge-git port.
115+ PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (PushSummary, error)
112116 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
113117 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114118 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
@@ -109,6 +109,10 @@ type EventService interface {
109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests
110 // implements it.110 // implements it.
111 type MergeRequestService interface {111 type MergeRequestService interface {
112+ // Compare queries repository history; it stays here because this service owns the merge-git port.
113+ Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (CompareResult, error)
114+ // PushSummary queries repository history; it stays here because this service owns the merge-git port.
115+ PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (PushSummary, error)
112 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)116 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
113 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)117 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)118 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
M internal/usecase/ci.go +1 -1
@@ -106,7 +106,7 @@ type CIConfig struct {
106106 // read when an action of another repository is resolved for the run. Nil
107107 // reads nothing for a token (fail closed).
108108 Tokens ports.TokenStore
109- // OIDC signs the ID tokens of the jobs (CI-4); nil issues none: jobs get no
109+ // OIDC signs the ID tokens of the jobs (CI-4); nil or disabled issues none: jobs get no
110110 // token endpoint, and the provider is not served.
111111 OIDC *OIDC
112112 // Settings resolves the variables and secrets of a job (CI-3). Nil gives
@@ -106,7 +106,7 @@ type CIConfig struct {
106 // read when an action of another repository is resolved for the run. Nil106 // read when an action of another repository is resolved for the run. Nil
107 // reads nothing for a token (fail closed).107 // reads nothing for a token (fail closed).
108 Tokens ports.TokenStore108 Tokens ports.TokenStore
109- // OIDC signs the ID tokens of the jobs (CI-4); nil issues none: jobs get no109+ // OIDC signs the ID tokens of the jobs (CI-4); nil or disabled issues none: jobs get no
110 // token endpoint, and the provider is not served.110 // token endpoint, and the provider is not served.
111 OIDC *OIDC111 OIDC *OIDC
112 // Settings resolves the variables and secrets of a job (CI-3). Nil gives112 // Settings resolves the variables and secrets of a job (CI-3). Nil gives
A internal/usecase/ci_creation_policy_test.go +48 -0
@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestCreateRunWorkflowPolicy(t *testing.T) {
18+ repo, err := domain.ParseRepoName("project")
19+ require.NoError(t, err)
20+ failure := errors.New("state unavailable")
21+ for _, tc := range []struct {
22+ name string
23+ off []string
24+ stateErr, want error
25+ }{
26+ {name: "enabled"},
27+ {name: "other disabled", off: []string{"other.yml"}},
28+ {name: "disabled", off: []string{"ci.yml"}, want: domain.ErrWorkflowDisabled},
29+ {name: "state failure", stateErr: failure, want: failure},
30+ } {
31+ t.Run(tc.name, func(t *testing.T) {
32+ state := mocks.NewCIWorkflowStore(t)
33+ store := mocks.NewCIStore(t)
34+ state.EXPECT().DisabledWorkflows(context.Background(), repo).Return(tc.off, tc.stateErr).Once()
35+ c := &CI{cfg: CIConfig{Store: store, WorkflowState: state}}
36+ build := func(id, number int64) ports.CIRunDraft { return ports.CIRunDraft{} }
37+ if tc.want == nil {
38+ store.EXPECT().CreateRun(context.Background(), repo, "ci.yml", mock.Anything).Return(ports.CIRunDraft{}, nil).Once()
39+ }
40+ _, err := c.createRun(context.Background(), pushFacts{}, repo, "ci.yml", build)
41+ if tc.want == nil {
42+ require.NoError(t, err)
43+ } else {
44+ require.ErrorIs(t, err, tc.want)
45+ }
46+ })
47+ }
48+}
@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestCreateRunWorkflowPolicy(t *testing.T) {
18+ repo, err := domain.ParseRepoName("project")
19+ require.NoError(t, err)
20+ failure := errors.New("state unavailable")
21+ for _, tc := range []struct {
22+ name string
23+ off []string
24+ stateErr, want error
25+ }{
26+ {name: "enabled"},
27+ {name: "other disabled", off: []string{"other.yml"}},
28+ {name: "disabled", off: []string{"ci.yml"}, want: domain.ErrWorkflowDisabled},
29+ {name: "state failure", stateErr: failure, want: failure},
30+ } {
31+ t.Run(tc.name, func(t *testing.T) {
32+ state := mocks.NewCIWorkflowStore(t)
33+ store := mocks.NewCIStore(t)
34+ state.EXPECT().DisabledWorkflows(context.Background(), repo).Return(tc.off, tc.stateErr).Once()
35+ c := &CI{cfg: CIConfig{Store: store, WorkflowState: state}}
36+ build := func(id, number int64) ports.CIRunDraft { return ports.CIRunDraft{} }
37+ if tc.want == nil {
38+ store.EXPECT().CreateRun(context.Background(), repo, "ci.yml", mock.Anything).Return(ports.CIRunDraft{}, nil).Once()
39+ }
40+ _, err := c.createRun(context.Background(), pushFacts{}, repo, "ci.yml", build)
41+ if tc.want == nil {
42+ require.NoError(t, err)
43+ } else {
44+ require.ErrorIs(t, err, tc.want)
45+ }
46+ })
47+ }
48+}
M internal/usecase/ci_oidc.go +3 -9
@@ -31,7 +31,7 @@ var _ ports.OIDCService = (*CI)(nil)
3131 // server issues none. j must be assigned (its AssignedAt is set).
3232 func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
3333 c := rs.c
34- if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
34+ if !c.cfg.OIDC.Enabled() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
3535 return ""
3636 }
3737 now := c.now()
@@ -48,21 +48,15 @@ func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
4848 }
4949
5050 // OIDCEnabled reports whether the server issues ID tokens.
51-func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil }
51+func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC.Enabled() }
5252
5353 // OIDCDiscovery returns the OpenID Provider configuration.
5454 func (c *CI) OIDCDiscovery() ([]byte, error) {
55- if c.cfg.OIDC == nil {
56- return nil, domain.ErrNotFound
57- }
5855 return c.cfg.OIDC.Discovery()
5956 }
6057
6158 // OIDCKeys returns the JWKS.
6259 func (c *CI) OIDCKeys() ([]byte, error) {
63- if c.cfg.OIDC == nil {
64- return nil, domain.ErrNotFound
65- }
6660 return c.cfg.OIDC.JWKS()
6761 }
6862
@@ -73,7 +67,7 @@ func (c *CI) OIDCKeys() ([]byte, error) {
7367 // "https://github.com/<owner>".
7468 func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
7569 o := c.cfg.OIDC
76- if o == nil {
70+ if !o.Enabled() {
7771 return "", domain.ErrNotFound
7872 }
7973 if len(audience) > domain.MaxOIDCAudienceLen {
@@ -31,7 +31,7 @@ var _ ports.OIDCService = (*CI)(nil)
31 // server issues none. j must be assigned (its AssignedAt is set).31 // server issues none. j must be assigned (its AssignedAt is set).
32 func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {32 func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
33 c := rs.c33 c := rs.c
34- if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {34+ if !c.cfg.OIDC.Enabled() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
35 return ""35 return ""
36 }36 }
37 now := c.now()37 now := c.now()
@@ -48,21 +48,15 @@ func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
48 }48 }
49 49
50 // OIDCEnabled reports whether the server issues ID tokens.50 // OIDCEnabled reports whether the server issues ID tokens.
51-func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil }51+func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC.Enabled() }
52 52
53 // OIDCDiscovery returns the OpenID Provider configuration.53 // OIDCDiscovery returns the OpenID Provider configuration.
54 func (c *CI) OIDCDiscovery() ([]byte, error) {54 func (c *CI) OIDCDiscovery() ([]byte, error) {
55- if c.cfg.OIDC == nil {
56- return nil, domain.ErrNotFound
57- }
58 return c.cfg.OIDC.Discovery()55 return c.cfg.OIDC.Discovery()
59 }56 }
60 57
61 // OIDCKeys returns the JWKS.58 // OIDCKeys returns the JWKS.
62 func (c *CI) OIDCKeys() ([]byte, error) {59 func (c *CI) OIDCKeys() ([]byte, error) {
63- if c.cfg.OIDC == nil {
64- return nil, domain.ErrNotFound
65- }
66 return c.cfg.OIDC.JWKS()60 return c.cfg.OIDC.JWKS()
67 }61 }
68 62
@@ -73,7 +67,7 @@ func (c *CI) OIDCKeys() ([]byte, error) {
73 // "https://github.com/<owner>".67 // "https://github.com/<owner>".
74 func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {68 func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
75 o := c.cfg.OIDC69 o := c.cfg.OIDC
76- if o == nil {70+ if !o.Enabled() {
77 return "", domain.ErrNotFound71 return "", domain.ErrNotFound
78 }72 }
79 if len(audience) > domain.MaxOIDCAudienceLen {73 if len(audience) > domain.MaxOIDCAudienceLen {
M internal/usecase/ci_pull.go +9 -1
@@ -681,11 +681,19 @@ func (c *CI) whileOpen(ctx context.Context, repo domain.RepoName, number int64,
681681 // createRun stores a run. The run of an open merge request is stored under the
682682 // gate, so that one that is closed meanwhile gets none (errPullClosed).
683683 func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
684+ off, err := c.workflowDisabled(ctx, repo, wfPath)
685+ if err != nil {
686+ return ports.CIRunDraft{}, err
687+ }
688+ if off {
689+ return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
690+ }
691+
684692 if f.pull == nil || f.pull.action == domain.PullClosed {
685693 return c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
686694 }
687695 var d ports.CIRunDraft
688- err := c.whileOpen(ctx, repo, f.pull.mr.Number, func() error {
696+ err = c.whileOpen(ctx, repo, f.pull.mr.Number, func() error {
689697 var err error
690698 d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
691699 return err
@@ -681,11 +681,19 @@ func (c *CI) whileOpen(ctx context.Context, repo domain.RepoName, number int64,
681 // createRun stores a run. The run of an open merge request is stored under the681 // createRun stores a run. The run of an open merge request is stored under the
682 // gate, so that one that is closed meanwhile gets none (errPullClosed).682 // gate, so that one that is closed meanwhile gets none (errPullClosed).
683 func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {683 func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
684+ off, err := c.workflowDisabled(ctx, repo, wfPath)
685+ if err != nil {
686+ return ports.CIRunDraft{}, err
687+ }
688+ if off {
689+ return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
690+ }
691+
684 if f.pull == nil || f.pull.action == domain.PullClosed {692 if f.pull == nil || f.pull.action == domain.PullClosed {
685 return c.cfg.Store.CreateRun(ctx, repo, wfPath, build)693 return c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
686 }694 }
687 var d ports.CIRunDraft695 var d ports.CIRunDraft
688- err := c.whileOpen(ctx, repo, f.pull.mr.Number, func() error {696+ err = c.whileOpen(ctx, repo, f.pull.mr.Number, func() error {
689 var err error697 var err error
690 d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build)698 d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
691 return err699 return err
M internal/usecase/ci_runner.go +1 -1
@@ -281,7 +281,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, sec jobSecrets, settings po
281281 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
282282 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
283283 }
284- if sec.idToken != "" && c.cfg.OIDC != nil {
284+ if sec.idToken != "" && c.cfg.OIDC.Enabled() {
285285 out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken
286286 }
287287 return out
@@ -281,7 +281,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, sec jobSecrets, settings po
281 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),281 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
282 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),282 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
283 }283 }
284- if sec.idToken != "" && c.cfg.OIDC != nil {284+ if sec.idToken != "" && c.cfg.OIDC.Enabled() {
285 out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken285 out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken
286 }286 }
287 return out287 return out
M internal/usecase/events.go +2 -2
@@ -573,7 +573,7 @@ func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.Rep
573573 case err != nil:
574574 return fmt.Errorf("check access to %q: %w", name, err)
575575 }
576- if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
576+ if !repo.Settings.IssuesEnabled() {
577577 return nil
578578 }
579579 return send(out)
@@ -613,7 +613,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
613613 if !canRead(w.p, repo) {
614614 return nil
615615 }
616- if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff {
616+ if isIssueEvent(be.ev) && !repo.Settings.IssuesEnabled() {
617617 return nil // issues were turned off since: a replay must not show them
618618 }
619619 if withRepo {
@@ -573,7 +573,7 @@ func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.Rep
573 case err != nil:573 case err != nil:
574 return fmt.Errorf("check access to %q: %w", name, err)574 return fmt.Errorf("check access to %q: %w", name, err)
575 }575 }
576- if repo.Settings.Issues.OrDefault() == domain.IssuesOff {576+ if !repo.Settings.IssuesEnabled() {
577 return nil577 return nil
578 }578 }
579 return send(out)579 return send(out)
@@ -613,7 +613,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
613 if !canRead(w.p, repo) {613 if !canRead(w.p, repo) {
614 return nil614 return nil
615 }615 }
616- if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff {616+ if isIssueEvent(be.ev) && !repo.Settings.IssuesEnabled() {
617 return nil // issues were turned off since: a replay must not show them617 return nil // issues were turned off since: a replay must not show them
618 }618 }
619 if withRepo {619 if withRepo {
M internal/usecase/inbox.go +1 -1
@@ -74,7 +74,7 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
7474 return fmt.Errorf("read repository %q: %w", name, err)
7575 default:
7676 exists[name] = true
77- issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff
77+ issuesOn[name] = repo.Settings.IssuesEnabled()
7878 }
7979 return nil
8080 }
@@ -74,7 +74,7 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
74 return fmt.Errorf("read repository %q: %w", name, err)74 return fmt.Errorf("read repository %q: %w", name, err)
75 default:75 default:
76 exists[name] = true76 exists[name] = true
77- issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff77+ issuesOn[name] = repo.Settings.IssuesEnabled()
78 }78 }
79 return nil79 return nil
80 }80 }
M internal/usecase/issue_moderation.go +1 -1
@@ -71,7 +71,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo
7171 if err != nil {
7272 return domain.Issue{}, err
7373 }
74- if r.Settings.Issues.OrDefault() == domain.IssuesOff {
74+ if !r.Settings.IssuesEnabled() {
7575 return domain.Issue{}, domain.ErrNotFound
7676 }
7777 now := m.clock.Now().UTC()
@@ -71,7 +71,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo
71 if err != nil {71 if err != nil {
72 return domain.Issue{}, err72 return domain.Issue{}, err
73 }73 }
74- if r.Settings.Issues.OrDefault() == domain.IssuesOff {74+ if !r.Settings.IssuesEnabled() {
75 return domain.Issue{}, domain.ErrNotFound75 return domain.Issue{}, domain.ErrNotFound
76 }76 }
77 now := m.clock.Now().UTC()77 now := m.clock.Now().UTC()
M internal/usecase/issue_submissions.go +1 -1
@@ -162,7 +162,7 @@ func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) e
162162 }
163163 return err
164164 }
165- if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {
165+ if !canRead(domain.Anonymous(), r) || !r.Settings.AcceptsAnonymousIssues() {
166166 return domain.ErrNotFound
167167 }
168168 return nil
@@ -162,7 +162,7 @@ func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) e
162 }162 }
163 return err163 return err
164 }164 }
165- if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {165+ if !canRead(domain.Anonymous(), r) || !r.Settings.AcceptsAnonymousIssues() {
166 return domain.ErrNotFound166 return domain.ErrNotFound
167 }167 }
168 return nil168 return nil
M internal/usecase/issues.go +1 -1
@@ -59,7 +59,7 @@ func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo
5959 if err != nil {
6060 return domain.Repo{}, err
6161 }
62- if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
62+ if !repo.Settings.IssuesEnabled() {
6363 return domain.Repo{}, domain.ErrNotFound
6464 }
6565 return repo, nil
@@ -59,7 +59,7 @@ func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo
59 if err != nil {59 if err != nil {
60 return domain.Repo{}, err60 return domain.Repo{}, err
61 }61 }
62- if repo.Settings.Issues.OrDefault() == domain.IssuesOff {62+ if !repo.Settings.IssuesEnabled() {
63 return domain.Repo{}, domain.ErrNotFound63 return domain.Repo{}, domain.ErrNotFound
64 }64 }
65 return repo, nil65 return repo, nil
M internal/usecase/mergerequests.go +57 -8
@@ -21,7 +21,7 @@ import (
2121 )
2222
2323 // MaxMergeRequestCommits is the most commits GetMergeRequest returns.
24-const MaxMergeRequestCommits = 100
24+const MaxMergeRequestCommits = ports.MaxMergeRequestCommits
2525
2626 // maxWriteRetries bounds the retries of a write that loses a race with another
2727 // write of the same merge request. Each lost race means another writer won, so
@@ -546,6 +546,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
546546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
547547 return ports.MergeRequestDetail{}, err
548548 }
549+ d.HeadUnread = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID
549550
550551 exclude, base, err := m.rangeOf(ctx, mr)
551552 if err != nil {
@@ -574,6 +575,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
574575 return ports.MergeRequestDetail{}, err
575576 }
576577 verifyCommits(ctx, m.verifier, name, d.Commits)
578+ if !d.LastReadID.IsZero() {
579+ for i, c := range d.Commits {
580+ if c.ID == d.LastReadID {
581+ d.NewCommits = i
582+ break
583+ }
584+ }
585+ }
577586 }
578587 if opts.SinceLastRead && !d.LastReadID.IsZero() {
579588 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
@@ -993,8 +1002,7 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
9931002 }
9941003
9951004 func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
996- repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
997- if err != nil {
1005+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
9981006 return domain.MergeRequest{}, err
9991007 }
10001008 mode := params.Mode
@@ -1039,17 +1047,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10391047 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
10401048 }
10411049 // The settings are read again under the lock: a change applies at once.
1042- repo, err = m.meta.Get(ctx, name)
1050+ repo, err := m.meta.Get(ctx, name)
10431051 if err != nil {
10441052 if errors.Is(err, domain.ErrNotFound) {
10451053 return domain.MergeRequest{}, err // deleted meanwhile
10461054 }
10471055 return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
10481056 }
1049- settings := repo.Settings
1050- if settings.IsZero() {
1051- settings = domain.DefaultRepoSettings()
1052- }
1057+ settings := repo.EffectiveSettings()
10531058 if !settings.Allows(mode) {
10541059 return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
10551060 }
@@ -1438,3 +1443,47 @@ func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, num
14381443 }
14391444 return root, nil
14401445 }
1446+
1447+// Compare lists at most ports.MaxCompareCommits source commits absent from target. Unlike bounded
1448+// history intersection, ancestry remains accurate even for deep histories.
1449+func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) {
1450+ if _, err := m.readable(ctx, p, repo); err != nil {
1451+ return ports.CompareResult{}, err
1452+ }
1453+ if target == source {
1454+ return ports.CompareResult{Exact: true}, nil
1455+ }
1456+ ancestor, err := m.git.IsAncestor(ctx, repo, target, source)
1457+ if err != nil {
1458+ return ports.CompareResult{}, err
1459+ }
1460+ commits, more, err := m.git.ListRange(ctx, repo, target, source, ports.MaxCompareCommits)
1461+ if err != nil {
1462+ return ports.CompareResult{}, err
1463+ }
1464+ verifyCommits(ctx, m.verifier, repo, commits)
1465+ return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil
1466+}
1467+
1468+// PushSummary detects rewrites by ancestry, not log ordering, and counts at
1469+// most ports.MaxPushSummaryCommits new commits. More distinguishes a longer range from a rewrite.
1470+func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) {
1471+ if _, err := m.readable(ctx, p, repo); err != nil {
1472+ return ports.PushSummary{}, err
1473+ }
1474+ if oldHead == newTip {
1475+ return ports.PushSummary{}, nil
1476+ }
1477+ ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip)
1478+ if err != nil {
1479+ return ports.PushSummary{}, err
1480+ }
1481+ if !ancestor {
1482+ return ports.PushSummary{Rewritten: true}, nil
1483+ }
1484+ commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, ports.MaxPushSummaryCommits)
1485+ if err != nil {
1486+ return ports.PushSummary{}, err
1487+ }
1488+ return ports.PushSummary{Count: len(commits), More: more}, nil
1489+}
@@ -21,7 +21,7 @@ import (
21 )21 )
22 22
23 // MaxMergeRequestCommits is the most commits GetMergeRequest returns.23 // MaxMergeRequestCommits is the most commits GetMergeRequest returns.
24-const MaxMergeRequestCommits = 10024+const MaxMergeRequestCommits = ports.MaxMergeRequestCommits
25 25
26 // maxWriteRetries bounds the retries of a write that loses a race with another26 // maxWriteRetries bounds the retries of a write that loses a race with another
27 // write of the same merge request. Each lost race means another writer won, so27 // write of the same merge request. Each lost race means another writer won, so
@@ -546,6 +546,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
547 return ports.MergeRequestDetail{}, err547 return ports.MergeRequestDetail{}, err
548 }548 }
549+ d.HeadUnread = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID
549 550
550 exclude, base, err := m.rangeOf(ctx, mr)551 exclude, base, err := m.rangeOf(ctx, mr)
551 if err != nil {552 if err != nil {
@@ -574,6 +575,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
574 return ports.MergeRequestDetail{}, err575 return ports.MergeRequestDetail{}, err
575 }576 }
576 verifyCommits(ctx, m.verifier, name, d.Commits)577 verifyCommits(ctx, m.verifier, name, d.Commits)
578+ if !d.LastReadID.IsZero() {
579+ for i, c := range d.Commits {
580+ if c.ID == d.LastReadID {
581+ d.NewCommits = i
582+ break
583+ }
584+ }
585+ }
577 }586 }
578 if opts.SinceLastRead && !d.LastReadID.IsZero() {587 if opts.SinceLastRead && !d.LastReadID.IsZero() {
579 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {588 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
@@ -993,8 +1002,7 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
993 }1002 }
994 1003
995 func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {1004 func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
996- repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)1005+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
997- if err != nil {
998 return domain.MergeRequest{}, err1006 return domain.MergeRequest{}, err
999 }1007 }
1000 mode := params.Mode1008 mode := params.Mode
@@ -1039,17 +1047,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1039 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)1047 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
1040 }1048 }
1041 // The settings are read again under the lock: a change applies at once.1049 // The settings are read again under the lock: a change applies at once.
1042- repo, err = m.meta.Get(ctx, name)1050+ repo, err := m.meta.Get(ctx, name)
1043 if err != nil {1051 if err != nil {
1044 if errors.Is(err, domain.ErrNotFound) {1052 if errors.Is(err, domain.ErrNotFound) {
1045 return domain.MergeRequest{}, err // deleted meanwhile1053 return domain.MergeRequest{}, err // deleted meanwhile
1046 }1054 }
1047 return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)1055 return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
1048 }1056 }
1049- settings := repo.Settings1057+ settings := repo.EffectiveSettings()
1050- if settings.IsZero() {
1051- settings = domain.DefaultRepoSettings()
1052- }
1053 if !settings.Allows(mode) {1058 if !settings.Allows(mode) {
1054 return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)1059 return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
1055 }1060 }
@@ -1438,3 +1443,47 @@ func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, num
1438 }1443 }
1439 return root, nil1444 return root, nil
1440 }1445 }
1446+
1447+// Compare lists at most ports.MaxCompareCommits source commits absent from target. Unlike bounded
1448+// history intersection, ancestry remains accurate even for deep histories.
1449+func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) {
1450+ if _, err := m.readable(ctx, p, repo); err != nil {
1451+ return ports.CompareResult{}, err
1452+ }
1453+ if target == source {
1454+ return ports.CompareResult{Exact: true}, nil
1455+ }
1456+ ancestor, err := m.git.IsAncestor(ctx, repo, target, source)
1457+ if err != nil {
1458+ return ports.CompareResult{}, err
1459+ }
1460+ commits, more, err := m.git.ListRange(ctx, repo, target, source, ports.MaxCompareCommits)
1461+ if err != nil {
1462+ return ports.CompareResult{}, err
1463+ }
1464+ verifyCommits(ctx, m.verifier, repo, commits)
1465+ return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil
1466+}
1467+
1468+// PushSummary detects rewrites by ancestry, not log ordering, and counts at
1469+// most ports.MaxPushSummaryCommits new commits. More distinguishes a longer range from a rewrite.
1470+func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) {
1471+ if _, err := m.readable(ctx, p, repo); err != nil {
1472+ return ports.PushSummary{}, err
1473+ }
1474+ if oldHead == newTip {
1475+ return ports.PushSummary{}, nil
1476+ }
1477+ ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip)
1478+ if err != nil {
1479+ return ports.PushSummary{}, err
1480+ }
1481+ if !ancestor {
1482+ return ports.PushSummary{Rewritten: true}, nil
1483+ }
1484+ commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, ports.MaxPushSummaryCommits)
1485+ if err != nil {
1486+ return ports.PushSummary{}, err
1487+ }
1488+ return ports.PushSummary{Count: len(commits), More: more}, nil
1489+}
M internal/usecase/mergerequests_test.go +144 -0
@@ -233,3 +233,147 @@ func TestAddNoteValidation(t *testing.T) {
233233 })
234234 }
235235 }
236+
237+func TestCompare(t *testing.T) {
238+ for _, tc := range []struct {
239+ name string
240+ ancestor, more, zero bool
241+ ancestorErr, rangeErr error
242+ }{
243+ {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
244+ {name: "unrelated histories"}, {name: "zero target forwards ancestry error", zero: true, ancestorErr: domain.ErrInvalid},
245+ {name: "ancestor error", ancestorErr: domain.ErrInvalid},
246+ {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
247+ } {
248+ t.Run(tc.name, func(t *testing.T) {
249+ u := newMRUnit(t)
250+ old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
251+ if tc.zero {
252+ old = domain.ObjectID{}
253+ }
254+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
255+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
256+ commits := []domain.Commit{{ID: tip}}
257+ if tc.ancestorErr == nil {
258+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxCompareCommits).Return(commits, tc.more, tc.rangeErr)
259+ }
260+ got, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip)
261+ if tc.ancestorErr != nil {
262+ require.ErrorIs(t, err, tc.ancestorErr)
263+ return
264+ }
265+ if tc.rangeErr != nil {
266+ require.ErrorIs(t, err, tc.rangeErr)
267+ return
268+ }
269+ require.NoError(t, err)
270+ assert.Equal(t, ports.CompareResult{Ahead: commits, Behind: !tc.ancestor, Exact: !tc.more}, got)
271+ })
272+ }
273+}
274+
275+func TestPushSummary(t *testing.T) {
276+ for _, tc := range []struct {
277+ name string
278+ ancestor, more bool
279+ ancestorErr, rangeErr error
280+ }{
281+ {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
282+ {name: "unrelated histories"}, {name: "ancestor error", ancestorErr: domain.ErrInvalid},
283+ {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
284+ } {
285+ t.Run(tc.name, func(t *testing.T) {
286+ u := newMRUnit(t)
287+ old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
288+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
289+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
290+ if tc.ancestor && tc.ancestorErr == nil {
291+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxPushSummaryCommits).Return([]domain.Commit{{ID: tip}}, tc.more, tc.rangeErr)
292+ }
293+ got, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip)
294+ if tc.ancestorErr != nil {
295+ require.ErrorIs(t, err, tc.ancestorErr)
296+ return
297+ }
298+ if tc.rangeErr != nil {
299+ require.ErrorIs(t, err, tc.rangeErr)
300+ return
301+ }
302+ require.NoError(t, err)
303+ want := ports.PushSummary{Rewritten: !tc.ancestor}
304+ if tc.ancestor {
305+ want.Count, want.More = 1, tc.more
306+ }
307+ assert.Equal(t, want, got)
308+ })
309+ }
310+}
311+
312+func TestReadTrackingSharedPosition(t *testing.T) {
313+ for _, tc := range []struct {
314+ name string
315+ principal domain.Principal
316+ readIndex int
317+ }{
318+ {"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0},
319+ {"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1},
320+ } {
321+ t.Run(tc.name, func(t *testing.T) {
322+ u := newMRUnit(t)
323+ u.repo.Visibility = domain.VisibilityPublic
324+ head := someID(t)
325+ old := oidFrom(t, "2222222222222222222222222222222222222222")
326+ read := old
327+ if tc.readIndex == 0 {
328+ read = head
329+ }
330+ mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head}
331+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
332+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil)
333+ u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil)
334+ u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil)
335+ u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil)
336+ commits := []domain.Commit{{ID: head}}
337+ if tc.readIndex >= 0 {
338+ commits = append(commits, domain.Commit{ID: old})
339+ }
340+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil)
341+ detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{})
342+ require.NoError(t, err)
343+ expected := 0
344+ assert.Equal(t, read, detail.LastReadID)
345+ assert.Equal(t, read != head, detail.HeadUnread)
346+ if tc.readIndex > 0 {
347+ expected = tc.readIndex
348+ }
349+ assert.Equal(t, expected, detail.NewCommits)
350+ })
351+ }
352+}
353+
354+func TestHistoryAuthorizationAndEqualTips(t *testing.T) {
355+ for _, allowed := range []bool{false, true} {
356+ t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) {
357+ u := newMRUnit(t)
358+ if allowed {
359+ u.repo.Visibility = domain.VisibilityPublic
360+ }
361+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
362+ id := someID(t)
363+ comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
364+ if allowed {
365+ require.NoError(t, err)
366+ assert.True(t, comparison.Exact)
367+ } else {
368+ require.ErrorIs(t, err, domain.ErrNotFound)
369+ }
370+ summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
371+ if allowed {
372+ require.NoError(t, err)
373+ assert.Zero(t, summary)
374+ } else {
375+ require.ErrorIs(t, err, domain.ErrNotFound)
376+ }
377+ })
378+ }
379+}
@@ -233,3 +233,147 @@ func TestAddNoteValidation(t *testing.T) {
233 })233 })
234 }234 }
235 }235 }
236+
237+func TestCompare(t *testing.T) {
238+ for _, tc := range []struct {
239+ name string
240+ ancestor, more, zero bool
241+ ancestorErr, rangeErr error
242+ }{
243+ {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
244+ {name: "unrelated histories"}, {name: "zero target forwards ancestry error", zero: true, ancestorErr: domain.ErrInvalid},
245+ {name: "ancestor error", ancestorErr: domain.ErrInvalid},
246+ {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
247+ } {
248+ t.Run(tc.name, func(t *testing.T) {
249+ u := newMRUnit(t)
250+ old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
251+ if tc.zero {
252+ old = domain.ObjectID{}
253+ }
254+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
255+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
256+ commits := []domain.Commit{{ID: tip}}
257+ if tc.ancestorErr == nil {
258+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxCompareCommits).Return(commits, tc.more, tc.rangeErr)
259+ }
260+ got, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip)
261+ if tc.ancestorErr != nil {
262+ require.ErrorIs(t, err, tc.ancestorErr)
263+ return
264+ }
265+ if tc.rangeErr != nil {
266+ require.ErrorIs(t, err, tc.rangeErr)
267+ return
268+ }
269+ require.NoError(t, err)
270+ assert.Equal(t, ports.CompareResult{Ahead: commits, Behind: !tc.ancestor, Exact: !tc.more}, got)
271+ })
272+ }
273+}
274+
275+func TestPushSummary(t *testing.T) {
276+ for _, tc := range []struct {
277+ name string
278+ ancestor, more bool
279+ ancestorErr, rangeErr error
280+ }{
281+ {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
282+ {name: "unrelated histories"}, {name: "ancestor error", ancestorErr: domain.ErrInvalid},
283+ {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
284+ } {
285+ t.Run(tc.name, func(t *testing.T) {
286+ u := newMRUnit(t)
287+ old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
288+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
289+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
290+ if tc.ancestor && tc.ancestorErr == nil {
291+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxPushSummaryCommits).Return([]domain.Commit{{ID: tip}}, tc.more, tc.rangeErr)
292+ }
293+ got, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip)
294+ if tc.ancestorErr != nil {
295+ require.ErrorIs(t, err, tc.ancestorErr)
296+ return
297+ }
298+ if tc.rangeErr != nil {
299+ require.ErrorIs(t, err, tc.rangeErr)
300+ return
301+ }
302+ require.NoError(t, err)
303+ want := ports.PushSummary{Rewritten: !tc.ancestor}
304+ if tc.ancestor {
305+ want.Count, want.More = 1, tc.more
306+ }
307+ assert.Equal(t, want, got)
308+ })
309+ }
310+}
311+
312+func TestReadTrackingSharedPosition(t *testing.T) {
313+ for _, tc := range []struct {
314+ name string
315+ principal domain.Principal
316+ readIndex int
317+ }{
318+ {"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0},
319+ {"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1},
320+ } {
321+ t.Run(tc.name, func(t *testing.T) {
322+ u := newMRUnit(t)
323+ u.repo.Visibility = domain.VisibilityPublic
324+ head := someID(t)
325+ old := oidFrom(t, "2222222222222222222222222222222222222222")
326+ read := old
327+ if tc.readIndex == 0 {
328+ read = head
329+ }
330+ mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head}
331+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
332+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil)
333+ u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil)
334+ u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil)
335+ u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil)
336+ commits := []domain.Commit{{ID: head}}
337+ if tc.readIndex >= 0 {
338+ commits = append(commits, domain.Commit{ID: old})
339+ }
340+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil)
341+ detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{})
342+ require.NoError(t, err)
343+ expected := 0
344+ assert.Equal(t, read, detail.LastReadID)
345+ assert.Equal(t, read != head, detail.HeadUnread)
346+ if tc.readIndex > 0 {
347+ expected = tc.readIndex
348+ }
349+ assert.Equal(t, expected, detail.NewCommits)
350+ })
351+ }
352+}
353+
354+func TestHistoryAuthorizationAndEqualTips(t *testing.T) {
355+ for _, allowed := range []bool{false, true} {
356+ t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) {
357+ u := newMRUnit(t)
358+ if allowed {
359+ u.repo.Visibility = domain.VisibilityPublic
360+ }
361+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
362+ id := someID(t)
363+ comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
364+ if allowed {
365+ require.NoError(t, err)
366+ assert.True(t, comparison.Exact)
367+ } else {
368+ require.ErrorIs(t, err, domain.ErrNotFound)
369+ }
370+ summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
371+ if allowed {
372+ require.NoError(t, err)
373+ assert.Zero(t, summary)
374+ } else {
375+ require.ErrorIs(t, err, domain.ErrNotFound)
376+ }
377+ })
378+ }
379+}
M internal/usecase/oidc.go +27 -3
@@ -74,8 +74,8 @@ type oidcKey struct {
7474 var _ ports.OIDCKeyService = (*OIDC)(nil)
7575
7676 // NewOIDC loads the signing keys, generates the first one when there is none,
77-// and forgets the retired keys whose publication is over. It returns nil
78-// without error when cfg.Sealer is nil: the server issues no ID tokens. A
77+// and forgets the retired keys whose publication is over. With cfg.Sealer nil
78+// it returns a disabled service: no ID tokens are issued. A
7979 // stored key that does not open with the secrets key is an error: the server
8080 // must not start (the secrets key check already refuses another key, so this
8181 // is a damaged record).
@@ -83,7 +83,7 @@ func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
8383 log := zerowrap.FromCtx(ctx)
8484 if cfg.Sealer == nil {
8585 log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")
86- return nil, nil
86+ return &OIDC{}, nil
8787 }
8888 if cfg.NewKey == nil {
8989 cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }
@@ -180,6 +180,13 @@ func (o *OIDC) signing() (oidcKey, bool) {
180180 // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose
181181 // publication is over are deleted with it. Only the owner may rotate.
182182 func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
183+ if !o.Enabled() {
184+ if !p.HasOwnerRights() {
185+ return ports.OIDCRotation{}, domain.ErrForbidden
186+ }
187+ return ports.OIDCRotation{}, oidcDisabledError{}
188+ }
189+
183190 switch {
184191 case p.IsAnonymous():
185192 return ports.OIDCRotation{}, domain.ErrUnauthenticated
@@ -234,6 +241,9 @@ func publicJWK(kid string, pub *rsa.PublicKey) jwk {
234241
235242 // JWKS returns the JSON of the published keys.
236243 func (o *OIDC) JWKS() ([]byte, error) {
244+ if !o.Enabled() {
245+ return nil, domain.ErrNotFound
246+ }
237247 now := o.cfg.Clock.Now().UTC()
238248 o.mu.RLock()
239249 defer o.mu.RUnlock()
@@ -258,6 +268,9 @@ var oidcClaims = []string{
258268 // Discovery returns the JSON of the OpenID Provider configuration, in the
259269 // shape of GitHub's.
260270 func (o *OIDC) Discovery() ([]byte, error) {
271+ if !o.Enabled() {
272+ return nil, domain.ErrNotFound
273+ }
261274 return json.Marshal(ordered{
262275 {"issuer", o.issuer},
263276 {"jwks_uri", o.issuer + "/.well-known/jwks"},
@@ -303,3 +316,14 @@ func thumbprint(pub *rsa.PublicKey) string {
303316 }
304317
305318 func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
319+
320+// Enabled reports whether the provider issues ID tokens. A nil provider is disabled.
321+func (o *OIDC) Enabled() bool { return o != nil && o.cfg.Sealer != nil }
322+
323+// oidcDisabledError preserves the public disabled-provider message and precondition code.
324+type oidcDisabledError struct{}
325+
326+func (oidcDisabledError) Error() string {
327+ return "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY"
328+}
329+func (oidcDisabledError) Is(target error) bool { return target == domain.ErrPreconditionFailed }
@@ -74,8 +74,8 @@ type oidcKey struct {
74 var _ ports.OIDCKeyService = (*OIDC)(nil)74 var _ ports.OIDCKeyService = (*OIDC)(nil)
75 75
76 // NewOIDC loads the signing keys, generates the first one when there is none,76 // NewOIDC loads the signing keys, generates the first one when there is none,
77-// and forgets the retired keys whose publication is over. It returns nil77+// and forgets the retired keys whose publication is over. With cfg.Sealer nil
78-// without error when cfg.Sealer is nil: the server issues no ID tokens. A78+// it returns a disabled service: no ID tokens are issued. A
79 // stored key that does not open with the secrets key is an error: the server79 // stored key that does not open with the secrets key is an error: the server
80 // must not start (the secrets key check already refuses another key, so this80 // must not start (the secrets key check already refuses another key, so this
81 // is a damaged record).81 // is a damaged record).
@@ -83,7 +83,7 @@ func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
83 log := zerowrap.FromCtx(ctx)83 log := zerowrap.FromCtx(ctx)
84 if cfg.Sealer == nil {84 if cfg.Sealer == nil {
85 log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")85 log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")
86- return nil, nil86+ return &OIDC{}, nil
87 }87 }
88 if cfg.NewKey == nil {88 if cfg.NewKey == nil {
89 cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }89 cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }
@@ -180,6 +180,13 @@ func (o *OIDC) signing() (oidcKey, bool) {
180 // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose180 // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose
181 // publication is over are deleted with it. Only the owner may rotate.181 // publication is over are deleted with it. Only the owner may rotate.
182 func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {182 func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
183+ if !o.Enabled() {
184+ if !p.HasOwnerRights() {
185+ return ports.OIDCRotation{}, domain.ErrForbidden
186+ }
187+ return ports.OIDCRotation{}, oidcDisabledError{}
188+ }
189+
183 switch {190 switch {
184 case p.IsAnonymous():191 case p.IsAnonymous():
185 return ports.OIDCRotation{}, domain.ErrUnauthenticated192 return ports.OIDCRotation{}, domain.ErrUnauthenticated
@@ -234,6 +241,9 @@ func publicJWK(kid string, pub *rsa.PublicKey) jwk {
234 241
235 // JWKS returns the JSON of the published keys.242 // JWKS returns the JSON of the published keys.
236 func (o *OIDC) JWKS() ([]byte, error) {243 func (o *OIDC) JWKS() ([]byte, error) {
244+ if !o.Enabled() {
245+ return nil, domain.ErrNotFound
246+ }
237 now := o.cfg.Clock.Now().UTC()247 now := o.cfg.Clock.Now().UTC()
238 o.mu.RLock()248 o.mu.RLock()
239 defer o.mu.RUnlock()249 defer o.mu.RUnlock()
@@ -258,6 +268,9 @@ var oidcClaims = []string{
258 // Discovery returns the JSON of the OpenID Provider configuration, in the268 // Discovery returns the JSON of the OpenID Provider configuration, in the
259 // shape of GitHub's.269 // shape of GitHub's.
260 func (o *OIDC) Discovery() ([]byte, error) {270 func (o *OIDC) Discovery() ([]byte, error) {
271+ if !o.Enabled() {
272+ return nil, domain.ErrNotFound
273+ }
261 return json.Marshal(ordered{274 return json.Marshal(ordered{
262 {"issuer", o.issuer},275 {"issuer", o.issuer},
263 {"jwks_uri", o.issuer + "/.well-known/jwks"},276 {"jwks_uri", o.issuer + "/.well-known/jwks"},
@@ -303,3 +316,14 @@ func thumbprint(pub *rsa.PublicKey) string {
303 }316 }
304 317
305 func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }318 func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
319+
320+// Enabled reports whether the provider issues ID tokens. A nil provider is disabled.
321+func (o *OIDC) Enabled() bool { return o != nil && o.cfg.Sealer != nil }
322+
323+// oidcDisabledError preserves the public disabled-provider message and precondition code.
324+type oidcDisabledError struct{}
325+
326+func (oidcDisabledError) Error() string {
327+ return "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY"
328+}
329+func (oidcDisabledError) Is(target error) bool { return target == domain.ErrPreconditionFailed }
M internal/usecase/oidc_test.go +44 -1
@@ -123,7 +123,8 @@ func TestNoSecretsKeyNoOIDC(t *testing.T) {
123123 f := newOIDCFixture(t)
124124 o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})
125125 require.NoError(t, err)
126- assert.Nil(t, o)
126+ require.NotNil(t, o)
127+ assert.False(t, o.Enabled())
127128 keys, err := f.store.ListOIDCKeys(context.Background())
128129 require.NoError(t, err)
129130 assert.Empty(t, keys, "no key is generated")
@@ -338,3 +339,45 @@ func TestIDTokenClaimsPerEvent(t *testing.T) {
338339 })
339340 }
340341 }
342+
343+func TestDisabledOIDCRotationAuthorization(t *testing.T) {
344+ o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{})
345+ require.NoError(t, err)
346+ for _, tc := range []struct {
347+ name string
348+ p domain.Principal
349+ want error
350+ }{
351+ {"anonymous", domain.Anonymous(), domain.ErrForbidden},
352+ {"limited", domain.TokenPrincipal(domain.Token{ID: "limited"}), domain.ErrForbidden},
353+ {"root", domain.Root(), domain.ErrPreconditionFailed},
354+ {"owner", domain.Owner(), domain.ErrPreconditionFailed},
355+ } {
356+ t.Run(tc.name, func(t *testing.T) {
357+ _, err := o.RotateKey(context.Background(), tc.p)
358+ require.ErrorIs(t, err, tc.want)
359+ if tc.want == domain.ErrPreconditionFailed {
360+ assert.EqualError(t, err, "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY")
361+ }
362+ })
363+ }
364+ _, err = o.Discovery()
365+ assert.ErrorIs(t, err, domain.ErrNotFound)
366+ _, err = o.JWKS()
367+ assert.ErrorIs(t, err, domain.ErrNotFound)
368+}
369+
370+func TestDisabledOIDCPublicEndpoints(t *testing.T) {
371+ for _, provider := range []*usecase.OIDC{nil, {}} {
372+ _, err := provider.Discovery()
373+ require.ErrorIs(t, err, domain.ErrNotFound)
374+ _, err = provider.JWKS()
375+ require.ErrorIs(t, err, domain.ErrNotFound)
376+ ci := usecase.NewCI(context.Background(), usecase.CIConfig{Owner: "owner", Clock: fixedClock{}, OIDC: provider})
377+ _, err = ci.OIDCDiscovery()
378+ require.ErrorIs(t, err, domain.ErrNotFound)
379+ _, err = ci.OIDCKeys()
380+ require.ErrorIs(t, err, domain.ErrNotFound)
381+ ci.Close(context.Background())
382+ }
383+}
@@ -123,7 +123,8 @@ func TestNoSecretsKeyNoOIDC(t *testing.T) {
123 f := newOIDCFixture(t)123 f := newOIDCFixture(t)
124 o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})124 o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})
125 require.NoError(t, err)125 require.NoError(t, err)
126- assert.Nil(t, o)126+ require.NotNil(t, o)
127+ assert.False(t, o.Enabled())
127 keys, err := f.store.ListOIDCKeys(context.Background())128 keys, err := f.store.ListOIDCKeys(context.Background())
128 require.NoError(t, err)129 require.NoError(t, err)
129 assert.Empty(t, keys, "no key is generated")130 assert.Empty(t, keys, "no key is generated")
@@ -338,3 +339,45 @@ func TestIDTokenClaimsPerEvent(t *testing.T) {
338 })339 })
339 }340 }
340 }341 }
342+
343+func TestDisabledOIDCRotationAuthorization(t *testing.T) {
344+ o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{})
345+ require.NoError(t, err)
346+ for _, tc := range []struct {
347+ name string
348+ p domain.Principal
349+ want error
350+ }{
351+ {"anonymous", domain.Anonymous(), domain.ErrForbidden},
352+ {"limited", domain.TokenPrincipal(domain.Token{ID: "limited"}), domain.ErrForbidden},
353+ {"root", domain.Root(), domain.ErrPreconditionFailed},
354+ {"owner", domain.Owner(), domain.ErrPreconditionFailed},
355+ } {
356+ t.Run(tc.name, func(t *testing.T) {
357+ _, err := o.RotateKey(context.Background(), tc.p)
358+ require.ErrorIs(t, err, tc.want)
359+ if tc.want == domain.ErrPreconditionFailed {
360+ assert.EqualError(t, err, "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY")
361+ }
362+ })
363+ }
364+ _, err = o.Discovery()
365+ assert.ErrorIs(t, err, domain.ErrNotFound)
366+ _, err = o.JWKS()
367+ assert.ErrorIs(t, err, domain.ErrNotFound)
368+}
369+
370+func TestDisabledOIDCPublicEndpoints(t *testing.T) {
371+ for _, provider := range []*usecase.OIDC{nil, {}} {
372+ _, err := provider.Discovery()
373+ require.ErrorIs(t, err, domain.ErrNotFound)
374+ _, err = provider.JWKS()
375+ require.ErrorIs(t, err, domain.ErrNotFound)
376+ ci := usecase.NewCI(context.Background(), usecase.CIConfig{Owner: "owner", Clock: fixedClock{}, OIDC: provider})
377+ _, err = ci.OIDCDiscovery()
378+ require.ErrorIs(t, err, domain.ErrNotFound)
379+ _, err = ci.OIDCKeys()
380+ require.ErrorIs(t, err, domain.ErrNotFound)
381+ ci.Close(context.Background())
382+ }
383+}
M internal/usecase/pushes.go +30 -14
@@ -26,7 +26,7 @@ type Pushes struct {
2626 auth ports.Revalidator
2727 fresh ports.RepoFreshener // nil: the reader needs no telling
2828 ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused
29+ trees ports.PushTreeReader // nil: pushes by job tokens are refused
3030 tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
3131 mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3232 }
@@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes {
7373 return u
7474 }
7575
76-// WithWorkflowGuard lets Check tell whether a push changes workflow files, which
76+// WithPushTreeReader lets Check tell whether a push changes workflow files, which
7777 // a push by a job token may not do. Without it a push by a job token is
7878 // refused, as it cannot be judged. Call it before the first push; it returns u.
79-func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {
80- u.wf = g
79+func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes {
80+ u.trees = g
8181 return u
8282 }
8383
@@ -199,20 +199,36 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
199199 const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
200200
201201 func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
202- if u.wf == nil {
202+ if u.trees == nil {
203203 return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
204204 }
205- path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)
206- switch {
207- case errors.Is(err, domain.ErrInvalid):
208- // A push the guard cannot judge within its bounds: refused, with the reason.
205+ t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
206+ if err != nil {
207+ return workflowReadError(name, err)
208+ }
209+ c, found, err := domain.WorkflowChangeOf(updates, t)
210+ if err != nil {
209211 return fmt.Errorf("%w: %v", domain.ErrRejected, err)
210- case err != nil:
211- return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
212- case path != "":
213- return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
214212 }
215- return nil
213+ if !found {
214+ return nil
215+ }
216+ entry, err := u.trees.FirstDifferentEntry(ctx, name, objects, c.Old, c.New)
217+ if err != nil {
218+ return workflowReadError(name, err)
219+ }
220+ path := domain.WorkflowDir
221+ if entry != "" {
222+ path += "/" + entry
223+ }
224+ return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
225+}
226+
227+func workflowReadError(name domain.RepoName, err error) error {
228+ if errors.Is(err, domain.ErrInvalid) {
229+ return fmt.Errorf("%w: %v", domain.ErrRejected, err)
230+ }
231+ return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
216232 }
217233
218234 // Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
@@ -26,7 +26,7 @@ type Pushes struct {
26 auth ports.Revalidator26 auth ports.Revalidator
27 fresh ports.RepoFreshener // nil: the reader needs no telling27 fresh ports.RepoFreshener // nil: the reader needs no telling
28 ci ports.WorkflowTrigger // nil: no CI28 ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused29+ trees ports.PushTreeReader // nil: pushes by job tokens are refused
30 tips ports.CIRefTipStore // nil: who pushes a ref is not recorded30 tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
31 mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked31 mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
32 }32 }
@@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes {
73 return u73 return u
74 }74 }
75 75
76-// WithWorkflowGuard lets Check tell whether a push changes workflow files, which76+// WithPushTreeReader lets Check tell whether a push changes workflow files, which
77 // a push by a job token may not do. Without it a push by a job token is77 // a push by a job token may not do. Without it a push by a job token is
78 // refused, as it cannot be judged. Call it before the first push; it returns u.78 // refused, as it cannot be judged. Call it before the first push; it returns u.
79-func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {79+func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes {
80- u.wf = g80+ u.trees = g
81 return u81 return u
82 }82 }
83 83
@@ -199,20 +199,36 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
199 const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"199 const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
200 200
201 func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {201 func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
202- if u.wf == nil {202+ if u.trees == nil {
203 return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)203 return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
204 }204 }
205- path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)205+ t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
206- switch {206+ if err != nil {
207- case errors.Is(err, domain.ErrInvalid):207+ return workflowReadError(name, err)
208- // A push the guard cannot judge within its bounds: refused, with the reason.208+ }
209+ c, found, err := domain.WorkflowChangeOf(updates, t)
210+ if err != nil {
209 return fmt.Errorf("%w: %v", domain.ErrRejected, err)211 return fmt.Errorf("%w: %v", domain.ErrRejected, err)
210- case err != nil:
211- return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
212- case path != "":
213- return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
214 }212 }
215- return nil213+ if !found {
214+ return nil
215+ }
216+ entry, err := u.trees.FirstDifferentEntry(ctx, name, objects, c.Old, c.New)
217+ if err != nil {
218+ return workflowReadError(name, err)
219+ }
220+ path := domain.WorkflowDir
221+ if entry != "" {
222+ path += "/" + entry
223+ }
224+ return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
225+}
226+
227+func workflowReadError(name domain.RepoName, err error) error {
228+ if errors.Is(err, domain.ErrInvalid) {
229+ return fmt.Errorf("%w: %v", domain.ErrRejected, err)
230+ }
231+ return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
216 }232 }
217 233
218 // Record is the post-receive step of an accepted push: it sets pushed_at and active_at,234 // Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
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 3

Tags 44

↑↓ moveenter openesc close