Merge 05fc636732aa61f7a2796c5e0dba9d9adb8883ec into 453499267f41ae8b3e4bd97a7445ec1c9de27425
102 files · +2489 -1405
M docs/merge-requests.md +13 -0
| @@ -91,3 +91,16 @@ A root note can carry a verdict: `--approve` or `--request-changes`. The text ma | |||||
| 91 | - `mr show` lists the latest verdict of each author (`verdicts` in `--json`). | 91 | - `mr show` lists the latest verdict of each author (`verdicts` in `--json`). | ||
| 92 | - An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed. | 92 | - An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed. | ||
| 93 | - Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it. | 93 | - Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it. | ||
| 94 | + | ||||
| 95 | + | ## Shared read position and history queries | |||
| 96 | + | ||||
| 97 | + | The read position is shared, not per principal. `MergeRequestDetail.HeadUnread` | |||
| 98 | + | is a fact: the nonzero head differs from that position. The web presentation | |||
| 99 | + | still decides who sees the mark-read button. | |||
| 100 | + | ||||
| 101 | + | Repository comparisons return at most `ports.MaxCompareCommits` (500) commits. | |||
| 102 | + | Push summaries count at most `ports.MaxPushSummaryCommits` (100) commits and | |||
| 103 | + | report whether more exist; ancestry, rather than the bounded list, detects a | |||
| 104 | + | rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits` | |||
| 105 | + | (100) commits. These history queries remain on the merge-request service | |||
| 106 | + | because it owns the merge-git port. | |||
M go.mod +1 -1
| @@ -64,7 +64,7 @@ require ( | |||||
| 64 | github.com/bep/godartsass/v2 v2.5.0 // indirect | 64 | github.com/bep/godartsass/v2 v2.5.0 // indirect | ||
| 65 | github.com/bep/golibsass v1.2.0 // indirect | 65 | github.com/bep/golibsass v1.2.0 // indirect | ||
| 66 | github.com/bits-and-blooms/bitset v1.24.5 // indirect | 66 | github.com/bits-and-blooms/bitset v1.24.5 // indirect | ||
| 67 | - | github.com/bnema/hexcheck v0.3.1 // indirect | 67 | + | github.com/bnema/hexcheck v0.4.0 // indirect |
| 68 | github.com/brunoga/deep v1.3.1 // indirect | 68 | github.com/brunoga/deep v1.3.1 // indirect | ||
| 69 | github.com/bufbuild/buf v1.73.0 // indirect | 69 | github.com/bufbuild/buf v1.73.0 // indirect | ||
| 70 | github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect | 70 | github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect | ||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/cilogs/cilogs.go +2 -0
| @@ -244,6 +244,8 @@ func (s *Store) Count(_ context.Context, ref ports.CILogRef) (int64, error) { | |||||
| 244 | } | 244 | } | ||
| 245 | 245 | ||||
| 246 | // Read returns lines from line number from. | 246 | // Read returns lines from line number from. | ||
| 247 | + | // | |||
| 248 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads a bounded snapshot of stored log lines using sparse offsets and maps missing or out-of-range reads to port errors | |||
| 247 | func (s *Store) Read(_ context.Context, ref ports.CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) { | 249 | func (s *Store) Read(_ context.Context, ref ports.CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) { | ||
| 248 | if !validRef(ref) || from < 0 { | 250 | if !validRef(ref) || from < 0 { | ||
| 249 | return nil, domain.ErrNotFound | 251 | return nil, domain.ErrNotFound | ||
M internal/adapters/cisummaries/cisummaries.go +2 -0
| @@ -56,6 +56,8 @@ func (s *Store) path(ref ports.CILogRef, step int32) string { | |||||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | // Put stores the summary of a step, or removes it when markdown is empty. | 58 | // Put stores the summary of a step, or removes it when markdown is empty. | ||
| 59 | + | // | |||
| 60 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates storage coordinates and atomically writes a step summary, deleting its file when empty | |||
| 59 | func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error { | 61 | func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error { | ||
| 60 | if !validRef(ref) || step < 1 || step > domain.CIMaxSteps { | 62 | if !validRef(ref) || step < 1 || step > domain.CIMaxSteps { | ||
| 61 | return domain.ErrNotFound | 63 | return domain.ErrNotFound | ||
M internal/adapters/ermitegit/commitobjects.go +12 -11
| @@ -22,10 +22,10 @@ var _ ports.CommitObjectReader = (*Adapter)(nil) | |||||
| 22 | // always serves it, and the wrapped reader only the repositories the library | 22 | // always serves it, and the wrapped reader only the repositories the library | ||
| 23 | // cannot read. | 23 | // cannot read. | ||
| 24 | // | 24 | // | ||
| 25 | - | //hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain | 25 | + | //hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects |
| 26 | func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | 26 | func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | ||
| 27 | - | if len(ids) > ports.MaxCommitLimit { | 27 | + | if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil { |
| 28 | - | return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit) | 28 | + | return nil, err |
| 29 | } | 29 | } | ||
| 30 | cli := func() (map[domain.ObjectID][]byte, error) { | 30 | cli := func() (map[domain.ObjectID][]byte, error) { | ||
| 31 | r, ok := a.cli.(ports.CommitObjectReader) | 31 | r, ok := a.cli.(ports.CommitObjectReader) | ||
| @@ -36,12 +36,12 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i | |||||
| 36 | } | 36 | } | ||
| 37 | return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) { | 37 | return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) { | ||
| 38 | out := make(map[domain.ObjectID][]byte, len(ids)) | 38 | out := make(map[domain.ObjectID][]byte, len(ids)) | ||
| 39 | - | var total int64 | 39 | + | var budget domain.CommitObjectBudget |
| 40 | for _, id := range ids { | 40 | for _, id := range ids { | ||
| 41 | if err := ctx.Err(); err != nil { | 41 | if err := ctx.Err(); err != nil { | ||
| 42 | return nil, err | 42 | return nil, err | ||
| 43 | } | 43 | } | ||
| 44 | - | if _, done := out[id]; done { | 44 | + | if budget.Seen(id) { |
| 45 | continue | 45 | continue | ||
| 46 | } | 46 | } | ||
| 47 | gid, err := git.ParseID(id.String()) | 47 | gid, err := git.ParseID(id.String()) | ||
| @@ -55,12 +55,13 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i | |||||
| 55 | if err != nil { | 55 | if err != nil { | ||
| 56 | return nil, err | 56 | return nil, err | ||
| 57 | } | 57 | } | ||
| 58 | - | if typ != git.TypeCommit || size > domain.MaxCommitObjectBytes { | 58 | + | take, stop := budget.Admit(id, typ == git.TypeCommit, size) |
| 59 | - | continue | 59 | + | if stop { |
| 60 | - | } | |||
| 61 | - | if total+size > domain.MaxCommitObjectsBytes { | |||
| 62 | break | 60 | break | ||
| 63 | } | 61 | } | ||
| 62 | + | if !take { | |||
| 63 | + | continue | |||
| 64 | + | } | |||
| 64 | typ, data, err := r.ReadObject(gid, nil) | 65 | typ, data, err := r.ReadObject(gid, nil) | ||
| 65 | if errors.Is(err, git.ErrNotFound) { | 66 | if errors.Is(err, git.ErrNotFound) { | ||
| 66 | continue // removed meanwhile | 67 | continue // removed meanwhile | ||
| @@ -68,9 +69,9 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i | |||||
| 68 | if err != nil { | 69 | if err != nil { | ||
| 69 | return nil, err | 70 | return nil, err | ||
| 70 | } | 71 | } | ||
| 71 | - | if typ == git.TypeCommit && len(data) <= domain.MaxCommitObjectBytes && total+int64(len(data)) <= domain.MaxCommitObjectsBytes { | 72 | + | if take, _ := budget.Admit(id, typ == git.TypeCommit, int64(len(data))); take { |
| 72 | out[id] = data | 73 | out[id] = data | ||
| 73 | - | total += int64(len(data)) | 74 | + | budget.Charge(id, int64(len(data))) |
| 74 | } | 75 | } | ||
| 75 | } | 76 | } | ||
| 76 | return out, nil | 77 | return out, nil | ||
M internal/adapters/ermitegit/convert.go +25 -36
| @@ -27,8 +27,6 @@ func domainID(id git.ObjectID) (domain.ObjectID, error) { return domain.ParseObj | |||||
| 27 | // listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag | 27 | // listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag | ||
| 28 | // follows to its commit when it leads to one; HEAD takes the ids of its | 28 | // follows to its commit when it leads to one; HEAD takes the ids of its | ||
| 29 | // branch or tag, and only the name of an unborn branch. | 29 | // branch or tag, and only the name of an unborn branch. | ||
| 30 | - | // | |||
| 31 | - | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |||
| 32 | func listRefs(r *git.Repo) ([]domain.Ref, error) { | 30 | func listRefs(r *git.Repo) ([]domain.Ref, error) { | ||
| 33 | var refs []domain.Ref | 31 | var refs []domain.Ref | ||
| 34 | byName := make(map[string]domain.Ref) | 32 | byName := make(map[string]domain.Ref) | ||
| @@ -121,55 +119,48 @@ func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) { | |||||
| 121 | return commit, true, nil | 119 | return commit, true, nil | ||
| 122 | } | 120 | } | ||
| 123 | 121 | ||||
| 124 | - | // resolveRevision tries the candidates of gitcli in the same order, which | 122 | + | // resolveRevision performs the lookups ordered by the domain, rather than |
| 125 | - | // the port documents: HEAD is HEAD; a full object id is an object before it | 123 | + | // Repo.Resolve, whose Git-specific resolution order differs from the port. |
| 126 | - | // is a ref; then the branch, then the tag; then a full ref name and an | |||
| 127 | - | // abbreviated id as written. It does not use Repo.Resolve, whose order is | |||
| 128 | - | // git's own (tags before branches, no ref for a missing full id). | |||
| 129 | - | // | |||
| 130 | - | //hexcheck:ignore suspicious-business-logic-in-adapter tries the candidates of the port in its documented order, like gitcli; the rule is the port contract | |||
| 131 | func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) { | 124 | func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) { | ||
| 132 | - | s := rev.String() | 125 | + | for _, candidate := range rev.Candidates() { |
| 133 | - | if rev.IsHead() || s == "HEAD" { | 126 | + | c, ok, err := lookupRevisionCandidate(r, candidate) |
| 134 | - | if c, ok, err := peelRef(r, "HEAD"); err != nil || ok { | |||
| 135 | - | return resolved{commit: c}, err | |||
| 136 | - | } | |||
| 137 | - | return resolved{}, notFound(rev) | |||
| 138 | - | } | |||
| 139 | - | if rev.IsObjectID() { | |||
| 140 | - | id, err := git.ParseID(s) | |||
| 141 | if err != nil { | 127 | if err != nil { | ||
| 142 | return resolved{}, err | 128 | return resolved{}, err | ||
| 143 | } | 129 | } | ||
| 144 | - | if c, ok, err := peelID(r, id); err != nil || ok { | 130 | + | if ok { |
| 145 | - | return resolved{commit: c}, err | 131 | + | return resolved{commit: c, ref: candidate.ResolvedRef()}, nil |
| 146 | - | } | |||
| 147 | - | } | |||
| 148 | - | for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} { | |||
| 149 | - | if c, ok, err := peelRef(r, name); err != nil || ok { | |||
| 150 | - | return resolved{commit: c, ref: name}, err | |||
| 151 | } | 132 | } | ||
| 152 | } | 133 | } | ||
| 153 | - | switch { | 134 | + | return resolved{}, notFound(rev) |
| 154 | - | case len(s) > 5 && s[:5] == "refs/": | 135 | + | } |
| 155 | - | if c, ok, err := peelRef(r, s); err != nil || ok { | 136 | + | |
| 156 | - | return resolved{commit: c, ref: s}, err | 137 | + | // lookupRevisionCandidate translates one domain candidate to the library API. |
| 138 | + | func lookupRevisionCandidate(r *git.Repo, candidate domain.RevisionCandidate) (git.ObjectID, bool, error) { | |||
| 139 | + | switch candidate.Kind { | |||
| 140 | + | case domain.RevisionCandidateHead, domain.RevisionCandidateRef: | |||
| 141 | + | return peelRef(r, candidate.Name) | |||
| 142 | + | case domain.RevisionCandidateObject: | |||
| 143 | + | id, err := git.ParseID(candidate.Name) | |||
| 144 | + | if err != nil { | |||
| 145 | + | return git.ObjectID{}, false, err | |||
| 157 | } | 146 | } | ||
| 158 | - | case rev.IsAbbrevID(): | 147 | + | return peelID(r, id) |
| 159 | - | id, err := r.ResolveCommitPrefix(s) | 148 | + | case domain.RevisionCandidateAbbrevID: |
| 149 | + | id, err := r.ResolveCommitPrefix(candidate.Name) | |||
| 160 | if err == nil { | 150 | if err == nil { | ||
| 161 | // The prefix may name an annotated tag: git peels it. | 151 | // The prefix may name an annotated tag: git peels it. | ||
| 162 | id, err = r.PeelToCommit(id) | 152 | id, err = r.PeelToCommit(id) | ||
| 163 | } | 153 | } | ||
| 164 | switch { | 154 | switch { | ||
| 165 | case err == nil: | 155 | case err == nil: | ||
| 166 | - | return resolved{commit: id}, nil | 156 | + | return id, true, nil |
| 167 | case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType): | 157 | case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType): | ||
| 158 | + | return git.ObjectID{}, false, nil | |||
| 168 | default: | 159 | default: | ||
| 169 | - | return resolved{}, err | 160 | + | return git.ObjectID{}, false, err |
| 170 | } | 161 | } | ||
| 171 | } | 162 | } | ||
| 172 | - | return resolved{}, notFound(rev) | 163 | + | return git.ObjectID{}, false, fmt.Errorf("unknown revision candidate kind %d", candidate.Kind) |
| 173 | } | 164 | } | ||
| 174 | 165 | ||||
| 175 | func notFound(rev domain.Revision) error { | 166 | func notFound(rev domain.Revision) error { | ||
| @@ -477,8 +468,6 @@ func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, d | |||||
| 477 | // The ids, the names, the emails and the message share one backing string, cut | 468 | // The ids, the names, the emails and the message share one backing string, cut | ||
| 478 | // into substrings: one allocation for the text of a commit instead of one per | 469 | // into substrings: one allocation for the text of a commit instead of one per | ||
| 479 | // field. | 470 | // field. | ||
| 480 | - | // | |||
| 481 | - | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |||
| 482 | func convertCommit(c *git.Commit) (domain.Commit, error) { | 471 | func convertCommit(c *git.Commit) (domain.Commit, error) { | ||
| 483 | if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) { | 472 | if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) { | ||
| 484 | return domain.Commit{}, errFallback | 473 | return domain.Commit{}, errFallback | ||
M internal/adapters/ermitegit/ermitegit.go +1 -1
| @@ -291,7 +291,7 @@ func (a *Adapter) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain. | |||||
| 291 | 291 | ||||
| 292 | // ResolveRevision turns a revision into a commit. | 292 | // ResolveRevision turns a revision into a commit. | ||
| 293 | // | 293 | // | ||
| 294 | - | //hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports | 294 | + | //hexcheck:ignore suspicious-business-logic-in-adapter selects the configured CLI or library backend, with a separate route for abbreviated IDs; resolveRevision calls domain.Revision.Candidates for resolution order |
| 295 | func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | 295 | func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | ||
| 296 | cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) } | 296 | cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) } | ||
| 297 | route := a.routes.ResolveRevision | 297 | route := a.routes.ResolveRevision | ||
M internal/adapters/gitcli/archive.go +0 -2
| @@ -28,8 +28,6 @@ var _ ports.GitArchive = (*Git)(nil) | |||||
| 28 | // not by a gzip binary. A client that goes away ends ctx, which kills git. | 28 | // not by a gzip binary. A client that goes away ends ctx, which kills git. | ||
| 29 | // Nothing is written to w when the commit does not exist | 29 | // Nothing is written to w when the commit does not exist | ||
| 30 | // (domain.ErrNotFound). | 30 | // (domain.ErrNotFound). | ||
| 31 | - | // | |||
| 32 | - | //hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's | |||
| 33 | func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error { | 31 | func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error { | ||
| 34 | dir, err := g.repoDir(repo) | 32 | dir, err := g.repoDir(repo) | ||
| 35 | if err != nil { | 33 | if err != nil { | ||
M internal/adapters/gitcli/changed.go +0 -4
| @@ -21,8 +21,6 @@ const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" | |||||
| 21 | // renames not detected (a rename lists both paths, as GitHub's push filters | 21 | // renames not detected (a rename lists both paths, as GitHub's push filters | ||
| 22 | // see it), and stops reading after max paths: truncated tells there were more. | 22 | // see it), and stops reading after max paths: truncated tells there were more. | ||
| 23 | // A zero base diffs against the empty tree. | 23 | // A zero base diffs against the empty tree. | ||
| 24 | - | // | |||
| 25 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the path list git prints; the bound comes from the port | |||
| 26 | func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limit int) ([]string, bool, error) { | 24 | func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limit int) ([]string, bool, error) { | ||
| 27 | if limit < 1 || head.IsZero() { | 25 | if limit < 1 || head.IsZero() { | ||
| 28 | return nil, false, fmt.Errorf("changed paths: %w: limit %d", domain.ErrInvalid, limit) | 26 | return nil, false, fmt.Errorf("changed paths: %w: limit %d", domain.ErrInvalid, limit) | ||
| @@ -63,8 +61,6 @@ func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head | |||||
| 63 | // (refs/heads/*), newest first, at most limit. It runs | 61 | // (refs/heads/*), newest first, at most limit. It runs | ||
| 64 | // `git log --not --branches --not <head>`: tags do not count, so the commit of | 62 | // `git log --not --branches --not <head>`: tags do not count, so the commit of | ||
| 65 | // another tag that no branch has is listed. | 63 | // another tag that no branch has is listed. | ||
| 66 | - | // | |||
| 67 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |||
| 68 | func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) { | 64 | func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) { | ||
| 69 | if head.IsZero() { | 65 | if head.IsZero() { | ||
| 70 | return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid) | 66 | return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid) | ||
M internal/adapters/gitcli/commitobjects.go +11 -15
| @@ -19,15 +19,10 @@ var _ ports.CommitObjectReader = (*Git)(nil) | |||||
| 19 | // that would take the total over domain.MaxCommitObjectsBytes (known from | 19 | // that would take the total over domain.MaxCommitObjectsBytes (known from | ||
| 20 | // `info`, before any content is read, so the stream stays consistent). | 20 | // `info`, before any content is read, so the stream stays consistent). | ||
| 21 | // | 21 | // | ||
| 22 | - | //hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain | 22 | + | //hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects |
| 23 | func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | 23 | func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | ||
| 24 | - | if len(ids) > ports.MaxCommitLimit { | 24 | + | if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil { |
| 25 | - | return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit) | 25 | + | return nil, err |
| 26 | - | } | |||
| 27 | - | for _, id := range ids { | |||
| 28 | - | if id.IsZero() { | |||
| 29 | - | return nil, fmt.Errorf("%w: empty commit id", domain.ErrInvalidObjectID) | |||
| 30 | - | } | |||
| 31 | } | 26 | } | ||
| 32 | dir, err := g.repoDir(repo) | 27 | dir, err := g.repoDir(repo) | ||
| 33 | if err != nil { | 28 | if err != nil { | ||
| @@ -36,12 +31,12 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [ | |||||
| 36 | out := make(map[domain.ObjectID][]byte, len(ids)) | 31 | out := make(map[domain.ObjectID][]byte, len(ids)) | ||
| 37 | err = g.cat.do(ctx, dir, func(c *catConn) error { | 32 | err = g.cat.do(ctx, dir, func(c *catConn) error { | ||
| 38 | clear(out) // a retry on a fresh process starts over | 33 | clear(out) // a retry on a fresh process starts over | ||
| 39 | - | var total int64 | 34 | + | var budget domain.CommitObjectBudget |
| 40 | for _, id := range ids { | 35 | for _, id := range ids { | ||
| 41 | if err := ctx.Err(); err != nil { | 36 | if err := ctx.Err(); err != nil { | ||
| 42 | return err | 37 | return err | ||
| 43 | } | 38 | } | ||
| 44 | - | if _, done := out[id]; done { | 39 | + | if budget.Seen(id) { |
| 45 | continue | 40 | continue | ||
| 46 | } | 41 | } | ||
| 47 | h, err := c.info(id.String()) | 42 | h, err := c.info(id.String()) | ||
| @@ -51,18 +46,19 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [ | |||||
| 51 | if err != nil { | 46 | if err != nil { | ||
| 52 | return err | 47 | return err | ||
| 53 | } | 48 | } | ||
| 54 | - | if h.Type != "commit" || h.Size > domain.MaxCommitObjectBytes { | 49 | + | take, stop := budget.Admit(id, h.Type == "commit", h.Size) |
| 55 | - | continue | 50 | + | if stop { |
| 56 | - | } | |||
| 57 | - | if total+h.Size > domain.MaxCommitObjectsBytes { | |||
| 58 | break | 51 | break | ||
| 59 | } | 52 | } | ||
| 53 | + | if !take { | |||
| 54 | + | continue | |||
| 55 | + | } | |||
| 60 | _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes) | 56 | _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes) | ||
| 61 | if err != nil { | 57 | if err != nil { | ||
| 62 | return err | 58 | return err | ||
| 63 | } | 59 | } | ||
| 64 | out[id] = data | 60 | out[id] = data | ||
| 65 | - | total += int64(len(data)) | 61 | + | budget.Charge(id, int64(len(data))) |
| 66 | } | 62 | } | ||
| 67 | return nil | 63 | return nil | ||
| 68 | }) | 64 | }) | ||
M internal/adapters/gitcli/diff.go +15 -47
| @@ -32,8 +32,6 @@ const maxDiffLine = 64 << 10 | |||||
| 32 | // Statistics come from the line counts, binary files from their "-" counts. | 32 | // Statistics come from the line counts, binary files from their "-" counts. | ||
| 33 | // The patch is parsed as it streams: nothing beyond the limits is kept, and | 33 | // The patch is parsed as it streams: nothing beyond the limits is kept, and | ||
| 34 | // git is stopped once nothing more is needed. | 34 | // git is stopped once nothing more is needed. | ||
| 35 | - | // | |||
| 36 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git diff-tree output into the domain diff, applying the limits the port passes in | |||
| 37 | func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | 35 | func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | ||
| 38 | dir, err := g.repoDir(repo) | 36 | dir, err := g.repoDir(repo) | ||
| 39 | if err != nil { | 37 | if err != nil { | ||
| @@ -91,7 +89,7 @@ type rawEntry struct { | |||||
| 91 | 89 | ||||
| 92 | // readDiff parses the output of diff-tree --raw --numstat -p. | 90 | // readDiff parses the output of diff-tree --raw --numstat -p. | ||
| 93 | // | 91 | // | ||
| 94 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in | 92 | + | //hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process |
| 95 | func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) { | 93 | func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) { | ||
| 96 | entries, err := readRaw(r) | 94 | entries, err := readRaw(r) | ||
| 97 | if err != nil { | 95 | if err != nil { | ||
| @@ -107,23 +105,17 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 107 | stats.Deletions += entries[i].del | 105 | stats.Deletions += entries[i].del | ||
| 108 | } | 106 | } | ||
| 109 | var diff domain.Diff | 107 | var diff domain.Diff | ||
| 110 | - | if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles { | 108 | + | budget := domain.NewDiffBudget(limits) |
| 111 | - | entries = entries[:limits.MaxFiles] | 109 | + | kept, from, to, truncated := budget.Window(len(entries)) |
| 112 | - | diff.Truncated = true | 110 | + | entries = entries[:kept] |
| 113 | - | } | 111 | + | diff.Truncated = truncated |
| 114 | - | // Only the files of the window [from, to) get their hunks; the others are | |||
| 115 | - | // listed as they are, neither truncated nor counted in the budgets. | |||
| 116 | - | from, to := max(limits.From, 0), len(entries) | |||
| 117 | - | if limits.To > 0 { | |||
| 118 | - | to = min(limits.To, len(entries)) | |||
| 119 | - | } | |||
| 120 | 112 | ||||
| 121 | // The patch holds one section per file, two for a type change, in the | 113 | // The patch holds one section per file, two for a type change, in the | ||
| 122 | // order of the entries. Entries without lines to show (binary, pure | 114 | // order of the entries. Entries without lines to show (binary, pure | ||
| 123 | // rename, mode change) have a section without hunks. | 115 | // rename, mode change) have a section without hunks. | ||
| 124 | lastRead := -1 | 116 | lastRead := -1 | ||
| 125 | for i := from; i < to; i++ { | 117 | for i := from; i < to; i++ { | ||
| 126 | - | if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) { | 118 | + | if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) { |
| 127 | lastRead = i | 119 | lastRead = i | ||
| 128 | } | 120 | } | ||
| 129 | } | 121 | } | ||
| @@ -133,8 +125,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 133 | _, _ = r.Discard(1) | 125 | _, _ = r.Discard(1) | ||
| 134 | } | 126 | } | ||
| 135 | } | 127 | } | ||
| 136 | - | pr := &patchReader{lr: newLineReader(r, maxDiffLine), bytesLeft: limits.MaxTotalBytes} | 128 | + | pr := &patchReader{lr: newLineReader(r, maxDiffLine), budget: budget} |
| 137 | - | budget := limits.MaxTotalLines | |||
| 138 | diff.Files = make([]domain.FileDiff, 0, len(entries)) | 129 | diff.Files = make([]domain.FileDiff, 0, len(entries)) | ||
| 139 | for i := range entries { | 130 | for i := range entries { | ||
| 140 | e := &entries[i] | 131 | e := &entries[i] | ||
| @@ -144,21 +135,19 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 144 | } | 135 | } | ||
| 145 | inWindow := i >= from && i < to | 136 | inWindow := i >= from && i < to | ||
| 146 | switch { | 137 | switch { | ||
| 147 | - | case !inWindow, !changedLines(e): | 138 | + | case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del): |
| 148 | // Nothing to show. | 139 | // Nothing to show. | ||
| 149 | - | case !wantsHunks(e, limits, budget, pr.bytesLeft): | 140 | + | case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del): |
| 150 | e.file.Truncated = true | 141 | e.file.Truncated = true | ||
| 151 | default: | 142 | default: | ||
| 152 | - | bytesBefore := pr.bytesLeft | 143 | + | before := pr.budget |
| 153 | - | hunks, lines, truncated, err := pr.file(sections, min(limits.MaxFileLines, budget)) | 144 | + | hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines()) |
| 154 | if err != nil { | 145 | if err != nil { | ||
| 155 | return domain.Diff{}, domain.DiffStats{}, err | 146 | return domain.Diff{}, domain.DiffStats{}, err | ||
| 156 | } | 147 | } | ||
| 157 | e.file.Hunks, e.file.Truncated = hunks, truncated | 148 | e.file.Hunks, e.file.Truncated = hunks, truncated | ||
| 158 | if truncated { | 149 | if truncated { | ||
| 159 | - | pr.bytesLeft = bytesBefore // nothing of this file is kept | 150 | + | pr.budget = before // nothing of this file is kept |
| 160 | - | } else { | |||
| 161 | - | budget -= lines | |||
| 162 | } | 151 | } | ||
| 163 | sections = 0 | 152 | sections = 0 | ||
| 164 | } | 153 | } | ||
| @@ -177,20 +166,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 177 | return diff, stats, nil | 166 | return diff, stats, nil | ||
| 178 | } | 167 | } | ||
| 179 | 168 | ||||
| 180 | - | // changedLines reports whether the file has text lines in its patch. | |||
| 181 | - | func changedLines(e *rawEntry) bool { return !e.file.Binary && e.added+e.del > 0 } | |||
| 182 | - | ||||
| 183 | - | // wantsHunks reports whether the lines of e can still be shown: the changed | |||
| 184 | - | // lines alone, a lower bound of the hunk lines, must fit the per-file limit | |||
| 185 | - | // and what remains of the total, and some text bytes must remain. | |||
| 186 | - | func wantsHunks(e *rawEntry, limits domain.DiffLimits, linesLeft, bytesLeft int) bool { | |||
| 187 | - | n := e.added + e.del | |||
| 188 | - | return changedLines(e) && n <= limits.MaxFileLines && n <= linesLeft && bytesLeft > 0 | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | // readRaw reads the `--raw -z` records, which start with ':'. | 169 | // readRaw reads the `--raw -z` records, which start with ':'. | ||
| 192 | - | // | |||
| 193 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values | |||
| 194 | func readRaw(r *bufio.Reader) ([]rawEntry, error) { | 170 | func readRaw(r *bufio.Reader) ([]rawEntry, error) { | ||
| 195 | var entries []rawEntry | 171 | var entries []rawEntry | ||
| 196 | for { | 172 | for { | ||
| @@ -241,8 +217,6 @@ func unexpectedEOF(err error) error { | |||||
| 241 | 217 | ||||
| 242 | // parseRawMeta parses ":<old mode> <new mode> <old id> <new id> <status>" and | 218 | // parseRawMeta parses ":<old mode> <new mode> <old id> <new id> <status>" and | ||
| 243 | // returns the number of paths that follow. | 219 | // returns the number of paths that follow. | ||
| 244 | - | // | |||
| 245 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values | |||
| 246 | func parseRawMeta(meta string) (rawEntry, int, error) { | 220 | func parseRawMeta(meta string) (rawEntry, int, error) { | ||
| 247 | f := strings.Fields(strings.TrimPrefix(meta, ":")) | 221 | f := strings.Fields(strings.TrimPrefix(meta, ":")) | ||
| 248 | if len(f) != 5 || f[4] == "" { | 222 | if len(f) != 5 || f[4] == "" { | ||
| @@ -296,8 +270,6 @@ func parseRawMeta(meta string) (rawEntry, int, error) { | |||||
| 296 | 270 | ||||
| 297 | // readNumstat reads one `--numstat -z` record per entry. A binary file has | 271 | // readNumstat reads one `--numstat -z` record per entry. A binary file has | ||
| 298 | // "-" counts. | 272 | // "-" counts. | ||
| 299 | - | // | |||
| 300 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values | |||
| 301 | func readNumstat(r *bufio.Reader, entries []rawEntry) error { | 273 | func readNumstat(r *bufio.Reader, entries []rawEntry) error { | ||
| 302 | for i := range entries { | 274 | for i := range entries { | ||
| 303 | tok, err := readToken(r) | 275 | tok, err := readToken(r) | ||
| @@ -341,7 +313,7 @@ type patchReader struct { | |||||
| 341 | eof bool | 313 | eof bool | ||
| 342 | cut bool // the last line read was cut | 314 | cut bool // the last line read was cut | ||
| 343 | 315 | ||||
| 344 | - | bytesLeft int // text bytes still allowed in the diff | 316 | + | budget domain.DiffBudget |
| 345 | } | 317 | } | ||
| 346 | 318 | ||||
| 347 | const sectionMarker = "diff --git " | 319 | const sectionMarker = "diff --git " | ||
| @@ -434,8 +406,6 @@ func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, t | |||||
| 434 | 406 | ||||
| 435 | // section reads the hunks of the current section up to the next marker, which | 407 | // section reads the hunks of the current section up to the next marker, which | ||
| 436 | // stays pending. It gives up (truncated) after more than max lines. | 408 | // stays pending. It gives up (truncated) after more than max lines. | ||
| 437 | - | // | |||
| 438 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in | |||
| 439 | func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) { | 409 | func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) { | ||
| 440 | var cur *domain.Hunk | 410 | var cur *domain.Hunk | ||
| 441 | var oldN, newN int | 411 | var oldN, newN int | ||
| @@ -490,8 +460,8 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate | |||||
| 490 | continue | 460 | continue | ||
| 491 | } | 461 | } | ||
| 492 | lines++ | 462 | lines++ | ||
| 493 | - | p.bytesLeft -= len(text) | 463 | + | fits := p.budget.ChargeLine(len(text)) |
| 494 | - | if lines > max || p.bytesLeft < 0 { | 464 | + | if lines > max || !fits { |
| 495 | return nil, lines, true, nil | 465 | return nil, lines, true, nil | ||
| 496 | } | 466 | } | ||
| 497 | cur.Lines = append(cur.Lines, l) | 467 | cur.Lines = append(cur.Lines, l) | ||
| @@ -499,8 +469,6 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate | |||||
| 499 | } | 469 | } | ||
| 500 | 470 | ||||
| 501 | // parseHunkHeader parses "@@ -a[,b] +c[,d] @@[ header]". | 471 | // parseHunkHeader parses "@@ -a[,b] +c[,d] @@[ header]". | ||
| 502 | - | // | |||
| 503 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values | |||
| 504 | func parseHunkHeader(s string) (domain.Hunk, error) { | 472 | func parseHunkHeader(s string) (domain.Hunk, error) { | ||
| 505 | bad := func() (domain.Hunk, error) { | 473 | bad := func() (domain.Hunk, error) { | ||
| 506 | return domain.Hunk{}, fmt.Errorf("git diff-tree: unexpected hunk header %q", truncateText(s)) | 474 | return domain.Hunk{}, fmt.Errorf("git diff-tree: unexpected hunk header %q", truncateText(s)) | ||
M internal/adapters/gitcli/export_test.go +0 -6
| @@ -110,15 +110,9 @@ func (g *Git) CatFileWaiting() int { | |||||
| 110 | return n | 110 | return n | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | - | // MaxPushedCommits is the bound of the commits of a push, for the tests. | |||
| 114 | - | const MaxPushedCommits = maxPushedCommits | |||
| 115 | - | ||||
| 116 | // ParseRevList reads rev-list output with the bounds of the guard replaced, and | 113 | // ParseRevList reads rev-list output with the bounds of the guard replaced, and | ||
| 117 | // returns the number of commits kept. | 114 | // returns the number of commits kept. | ||
| 118 | func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) { | 115 | func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) { | ||
| 119 | c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs) | 116 | c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs) | ||
| 120 | return len(c), err | 117 | return len(c), err | ||
| 121 | } | 118 | } | ||
| 122 | - | ||||
| 123 | - | // MaxPushedObjects is the bound of the ids of the commits of a push. | |||
| 124 | - | const MaxPushedObjects = maxPushedObjects | |||
M internal/adapters/gitcli/merge.go +0 -16
| @@ -39,8 +39,6 @@ func parseID(out string) (domain.ObjectID, error) { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | // BranchHead resolves refs/heads/<branch>^{commit} with `cat-file`. | 41 | // BranchHead resolves refs/heads/<branch>^{commit} with `cat-file`. | ||
| 42 | - | // | |||
| 43 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps a branch name to a git object name | |||
| 44 | func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) { | 42 | func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) { | ||
| 45 | dir, err := g.repoDir(repo) | 43 | dir, err := g.repoDir(repo) | ||
| 46 | if err != nil { | 44 | if err != nil { | ||
| @@ -95,8 +93,6 @@ func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.Obj | |||||
| 95 | } | 93 | } | ||
| 96 | 94 | ||||
| 97 | // MergeBase runs `git merge-base`, which exits 1 when there is none. | 95 | // MergeBase runs `git merge-base`, which exits 1 when there is none. | ||
| 98 | - | // | |||
| 99 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the domain not found error | |||
| 100 | func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) { | 96 | func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) { | ||
| 101 | dir, err := g.repoDir(repo) | 97 | dir, err := g.repoDir(repo) | ||
| 102 | if err != nil { | 98 | if err != nil { | ||
| @@ -129,8 +125,6 @@ func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, de | |||||
| 129 | } | 125 | } | ||
| 130 | 126 | ||||
| 131 | // ListRange runs `git log <head> ^<exclude>`. | 127 | // ListRange runs `git log <head> ^<exclude>`. | ||
| 132 | - | // | |||
| 133 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |||
| 134 | func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) { | 128 | func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) { | ||
| 135 | var excluded []string | 129 | var excluded []string | ||
| 136 | if !exclude.IsZero() { | 130 | if !exclude.IsZero() { | ||
| @@ -142,8 +136,6 @@ func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head | |||||
| 142 | // logCommits runs `git log` for the commits reachable from head: revArgs go | 136 | // logCommits runs `git log` for the commits reachable from head: revArgs go | ||
| 143 | // before the head (options such as --not), excluded after it. Newest first, | 137 | // before the head (options such as --not), excluded after it. Newest first, | ||
| 144 | // at most limit; more says there were others. | 138 | // at most limit; more says there were others. | ||
| 145 | - | // | |||
| 146 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |||
| 147 | func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) { | 139 | func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) { | ||
| 148 | if limit < 1 || limit > ports.MaxCommitLimit { | 140 | if limit < 1 || limit > ports.MaxCommitLimit { | ||
| 149 | return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit) | 141 | return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit) | ||
| @@ -183,8 +175,6 @@ func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain. | |||||
| 183 | } | 175 | } | ||
| 184 | 176 | ||||
| 185 | // DiffCommits reads the diff between two commits like GetCommit does. | 177 | // DiffCommits reads the diff between two commits like GetCommit does. | ||
| 186 | - | // | |||
| 187 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads a git diff into the domain diff with the limits the port passes in | |||
| 188 | func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) { | 178 | func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) { | ||
| 189 | dir, err := g.repoDir(repo) | 179 | dir, err := g.repoDir(repo) | ||
| 190 | if err != nil { | 180 | if err != nil { | ||
| @@ -198,8 +188,6 @@ func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head | |||||
| 198 | } | 188 | } | ||
| 199 | 189 | ||||
| 200 | // CommitTree runs `git rev-parse <commit>^{tree}`. | 190 | // CommitTree runs `git rev-parse <commit>^{tree}`. | ||
| 201 | - | // | |||
| 202 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed | |||
| 203 | func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) { | 191 | func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) { | ||
| 204 | dir, err := g.repoDir(repo) | 192 | dir, err := g.repoDir(repo) | ||
| 205 | if err != nil { | 193 | if err != nil { | ||
| @@ -229,8 +217,6 @@ func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []by | |||||
| 229 | // UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which | 217 | // UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which | ||
| 230 | // takes the ref lock and refuses when the ref is not at old. A failure is | 218 | // takes the ref lock and refuses when the ref is not at old. A failure is | ||
| 231 | // told from a moved branch by reading the branch again. | 219 | // told from a moved branch by reading the branch again. | ||
| 232 | - | // | |||
| 233 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port | |||
| 234 | func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error { | 220 | func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error { | ||
| 235 | dir, err := g.repoDir(repo) | 221 | dir, err := g.repoDir(repo) | ||
| 236 | if err != nil { | 222 | if err != nil { | ||
| @@ -252,8 +238,6 @@ func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch str | |||||
| 252 | // DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes | 238 | // DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes | ||
| 253 | // the ref lock and refuses when the ref is not at old. A failure is told from | 239 | // the ref lock and refuses when the ref is not at old. A failure is told from | ||
| 254 | // a moved or deleted branch by reading the branch again. | 240 | // a moved or deleted branch by reading the branch again. | ||
| 255 | - | // | |||
| 256 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port | |||
| 257 | func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error { | 241 | func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error { | ||
| 258 | dir, err := g.repoDir(repo) | 242 | dir, err := g.repoDir(repo) | ||
| 259 | if err != nil { | 243 | if err != nil { | ||
M internal/adapters/gitcli/pull.go +0 -2
| @@ -18,8 +18,6 @@ var _ ports.PullGit = (*Git)(nil) | |||||
| 18 | // database and touches no ref or index: it prints the tree of the result and | 18 | // database and touches no ref or index: it prints the tree of the result and | ||
| 19 | // exits 0, or exits 1 when the merge conflicts. Commits with no common history | 19 | // exits 0, or exits 1 when the merge conflicts. Commits with no common history | ||
| 20 | // do not merge (GitHub makes no test merge for them either). | 20 | // do not merge (GitHub makes no test merge for them either). | ||
| 21 | - | // | |||
| 22 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the clean/conflict answer of the port | |||
| 23 | func (g *Git) TestMerge(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.ObjectID, bool, error) { | 21 | func (g *Git) TestMerge(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.ObjectID, bool, error) { | ||
| 24 | dir, err := g.repoDir(repo) | 22 | dir, err := g.repoDir(repo) | ||
| 25 | if err != nil { | 23 | if err != nil { | ||
A internal/adapters/gitcli/push_trees.go +400 -0
| @@ -0,0 +1,400 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package gitcli | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bufio" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | var _ ports.PushTreeReader = (*Git)(nil) | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | maxDiffPath = 4096 | |||
| 23 | + | maxRevListLine = 8192 | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // PushSubtrees peels push endpoints, walks new commits, and reads entries at path. | |||
| 27 | + | func (g *Git) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |||
| 28 | + | out := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Subtrees: map[string]domain.Subtree{}} | |||
| 29 | + | dir, err := g.repoDir(repo) | |||
| 30 | + | if err != nil { | |||
| 31 | + | return out, err | |||
| 32 | + | } | |||
| 33 | + | extra, err := quarantineEnv(dir, objects) | |||
| 34 | + | if err != nil { | |||
| 35 | + | return out, err | |||
| 36 | + | } | |||
| 37 | + | var kept []domain.RefUpdate | |||
| 38 | + | for _, up := range updates { | |||
| 39 | + | if up.New != domain.ZeroOID { | |||
| 40 | + | kept = append(kept, up) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | if len(kept) == 0 { | |||
| 44 | + | return out, nil | |||
| 45 | + | } | |||
| 46 | + | return g.readPushSubtrees(ctx, dir, extra, kept, path, bounds, out) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // readPushSubtrees batches the endpoint and walked-object cat-file queries. | |||
| 50 | + | func (g *Git) readPushSubtrees(ctx context.Context, dir string, extra []string, kept []domain.RefUpdate, path string, bounds domain.PushWalkBounds, out domain.PushSubtrees) (domain.PushSubtrees, error) { | |||
| 51 | + | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") | |||
| 52 | + | if err != nil { | |||
| 53 | + | return out, err | |||
| 54 | + | } | |||
| 55 | + | tips := pushCommitTips(kept, types, out.Ends) | |||
| 56 | + | if len(tips) != 0 { | |||
| 57 | + | out.Commits, err = g.newCommits(ctx, dir, extra, tips, bounds) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return out, err | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ids := commitAndParentIDs(out.Commits) | |||
| 63 | + | ws, err := g.subtreesAt(ctx, dir, extra, ids, path) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return out, err | |||
| 66 | + | } | |||
| 67 | + | for id, w := range ws { | |||
| 68 | + | out.Subtrees[id] = w | |||
| 69 | + | } | |||
| 70 | + | ids = pushEndSubtreeIDs(kept, out.Ends) | |||
| 71 | + | ws, err = g.subtreesAt(ctx, dir, extra, ids, path) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return out, err | |||
| 74 | + | } | |||
| 75 | + | for id, w := range ws { | |||
| 76 | + | out.Subtrees[id] = w | |||
| 77 | + | } | |||
| 78 | + | return out, nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // pushCommitTips translates peeled endpoint answers and deduplicates rev-list tips. | |||
| 82 | + | func pushCommitTips(kept []domain.RefUpdate, types []string, ends map[string]domain.PushObjectType) []string { | |||
| 83 | + | var tips []string | |||
| 84 | + | seen := map[string]bool{} | |||
| 85 | + | for i, up := range kept { | |||
| 86 | + | for j, id := range []string{up.Old, up.New} { | |||
| 87 | + | if id != domain.ZeroOID { | |||
| 88 | + | ends[id] = domain.PushObjectType(types[2*i+j]) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | if types[2*i+1] == "commit" && !seen[up.New] { | |||
| 92 | + | seen[up.New] = true | |||
| 93 | + | tips = append(tips, up.New) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | return tips | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // commitAndParentIDs deduplicates the object names for a cat-file batch. | |||
| 100 | + | func commitAndParentIDs(commits []domain.PushedCommit) []string { | |||
| 101 | + | var ids []string | |||
| 102 | + | asked := map[string]bool{} | |||
| 103 | + | for _, c := range commits { | |||
| 104 | + | for _, id := range append([]domain.ObjectID{c.ID}, c.Parents...) { | |||
| 105 | + | key := id.String() | |||
| 106 | + | if !asked[key] { | |||
| 107 | + | asked[key] = true | |||
| 108 | + | ids = append(ids, key) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return ids | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // pushEndSubtreeIDs omits blobs (not tree-ish) and includes HEAD only for creates. | |||
| 116 | + | func pushEndSubtreeIDs(kept []domain.RefUpdate, ends map[string]domain.PushObjectType) []string { | |||
| 117 | + | var ids []string | |||
| 118 | + | for _, up := range kept { | |||
| 119 | + | for _, id := range []string{up.Old, up.New} { | |||
| 120 | + | if id != domain.ZeroOID && ends[id] != domain.PushObjectBlob { | |||
| 121 | + | ids = append(ids, id) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | // HEAD is needed only for a new tree-ish ref. Keep it in the end batch. | |||
| 126 | + | for _, up := range kept { | |||
| 127 | + | if up.Old == domain.ZeroOID && ends[up.New] != domain.PushObjectBlob { | |||
| 128 | + | ids = append(ids, domain.HeadKey) | |||
| 129 | + | break | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return ids | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // newCommits lists the commits reachable from the tips and from no ref | |||
| 136 | + | // (`rev-list --parents <tips> --not --all`), at most bounds.Commits: more | |||
| 137 | + | // fails closed. | |||
| 138 | + | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string, bounds domain.PushWalkBounds) ([]domain.PushedCommit, error) { | |||
| 139 | + | if err := revListLimitsError(bounds); err != nil { | |||
| 140 | + | return nil, err | |||
| 141 | + | } | |||
| 142 | + | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(bounds.Commits+1)}, tips...), "--not", "--all")...) | |||
| 143 | + | var out []domain.PushedCommit | |||
| 144 | + | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |||
| 145 | + | var err error | |||
| 146 | + | out, err = parseRevList(r, bounds.Commits, bounds.IDs) | |||
| 147 | + | return err | |||
| 148 | + | }) | |||
| 149 | + | if err != nil { | |||
| 150 | + | return nil, fmt.Errorf("commits of a push: %w", err) | |||
| 151 | + | } | |||
| 152 | + | return out, nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // revListLimitsError checks the numeric range of rev-list and parser limits. | |||
| 156 | + | func revListLimitsError(bounds domain.PushWalkBounds) error { | |||
| 157 | + | if bounds.Commits < 0 || bounds.IDs < 0 || bounds.Commits == int(^uint(0)>>1) { | |||
| 158 | + | return fmt.Errorf("%w: invalid push walk bounds", domain.ErrInvalid) | |||
| 159 | + | } | |||
| 160 | + | return nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): | |||
| 164 | + | // at most maxCommits lines and maxIDs ids in all (a commit and each of its | |||
| 165 | + | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an | |||
| 166 | + | // id that is not one is an error, and it is raised as soon as the line that | |||
| 167 | + | // crosses it is read: nothing past it is read or kept. | |||
| 168 | + | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]domain.PushedCommit, error) { | |||
| 169 | + | var out []domain.PushedCommit | |||
| 170 | + | ids := 0 | |||
| 171 | + | for { | |||
| 172 | + | line, err := r.ReadSlice('\n') | |||
| 173 | + | switch { | |||
| 174 | + | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: | |||
| 175 | + | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) | |||
| 176 | + | case errors.Is(err, io.EOF) && len(line) == 0: | |||
| 177 | + | return out, nil | |||
| 178 | + | case err != nil && !errors.Is(err, io.EOF): | |||
| 179 | + | return nil, err | |||
| 180 | + | } | |||
| 181 | + | f := strings.Fields(string(line)) | |||
| 182 | + | if len(f) == 0 { | |||
| 183 | + | continue | |||
| 184 | + | } | |||
| 185 | + | if len(out) == maxCommits { | |||
| 186 | + | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) | |||
| 187 | + | } | |||
| 188 | + | if ids += len(f); ids > maxIDs { | |||
| 189 | + | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) | |||
| 190 | + | } | |||
| 191 | + | for _, id := range f { | |||
| 192 | + | if _, err := domain.ParseObjectID(id); err != nil { | |||
| 193 | + | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | id, _ := domain.ParseObjectID(f[0]) | |||
| 197 | + | c := domain.PushedCommit{ID: id} | |||
| 198 | + | for _, p := range f[1:] { | |||
| 199 | + | id, _ := domain.ParseObjectID(p) | |||
| 200 | + | c.Parents = append(c.Parents, id) | |||
| 201 | + | } | |||
| 202 | + | out = append(out, c) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // subtreesAt reads the mode, id and type of entries without traversing them. | |||
| 207 | + | func (g *Git) subtreesAt(ctx context.Context, dir string, extra []string, ids []string, path string) (map[string]domain.Subtree, error) { | |||
| 208 | + | out := make(map[string]domain.Subtree, len(ids)) | |||
| 209 | + | if len(ids) == 0 { | |||
| 210 | + | return out, nil | |||
| 211 | + | } | |||
| 212 | + | specs := make([]string, len(ids)) | |||
| 213 | + | for i, id := range ids { | |||
| 214 | + | specs[i] = id + ":" + path | |||
| 215 | + | } | |||
| 216 | + | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") | |||
| 217 | + | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { | |||
| 218 | + | for _, key := range ids { | |||
| 219 | + | line, err := r.ReadSlice('\n') | |||
| 220 | + | if err != nil { | |||
| 221 | + | return fmt.Errorf("read object answers: %w", err) | |||
| 222 | + | } | |||
| 223 | + | f := strings.Fields(string(line)) | |||
| 224 | + | if len(f) != 3 { | |||
| 225 | + | continue | |||
| 226 | + | } // missing entry | |||
| 227 | + | mode, err := strconv.ParseUint(f[0], 8, 32) | |||
| 228 | + | if err != nil { | |||
| 229 | + | continue | |||
| 230 | + | } | |||
| 231 | + | id, err := domain.ParseObjectID(f[1]) | |||
| 232 | + | if err != nil { | |||
| 233 | + | return err | |||
| 234 | + | } | |||
| 235 | + | kind := domain.EntryKindOf(uint32(mode)) | |||
| 236 | + | if kind == domain.EntryDirectory && (f[0] != "040000" || f[2] != "tree") { | |||
| 237 | + | kind = "" | |||
| 238 | + | } | |||
| 239 | + | out[key] = domain.Subtree{Kind: kind, ID: id} | |||
| 240 | + | } | |||
| 241 | + | return nil | |||
| 242 | + | }) | |||
| 243 | + | if err != nil { | |||
| 244 | + | return nil, fmt.Errorf("subtrees of a push: %w", err) | |||
| 245 | + | } | |||
| 246 | + | return out, nil | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // FirstDifferentEntry streams the first differing top-level entry of two trees. | |||
| 250 | + | func (g *Git) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, oldID, newID domain.ObjectID) (string, error) { | |||
| 251 | + | dir, err := g.repoDir(repo) | |||
| 252 | + | if err != nil { | |||
| 253 | + | return "", err | |||
| 254 | + | } | |||
| 255 | + | extra, err := quarantineEnv(dir, objects) | |||
| 256 | + | if err != nil { | |||
| 257 | + | return "", err | |||
| 258 | + | } | |||
| 259 | + | from := oldID.String() | |||
| 260 | + | if from == "" { | |||
| 261 | + | from = emptyTree | |||
| 262 | + | } | |||
| 263 | + | to := newID.String() | |||
| 264 | + | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) | |||
| 265 | + | var name string | |||
| 266 | + | err = g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |||
| 267 | + | n, found, err := firstPath(r) | |||
| 268 | + | name = n | |||
| 269 | + | if err != nil || !found { | |||
| 270 | + | return err // no name: git's exit status is checked, so a failure refuses | |||
| 271 | + | } | |||
| 272 | + | return errStop // the first name is all that is needed: stop git | |||
| 273 | + | }) | |||
| 274 | + | if err != nil { | |||
| 275 | + | return "", fmt.Errorf("different tree entries %s..%s: %w", from, to, err) | |||
| 276 | + | } | |||
| 277 | + | return name, nil | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // firstPath reads the first NUL-terminated path of r; found is false at the end | |||
| 281 | + | // of the output without one. A path longer than maxDiffPath is an error. | |||
| 282 | + | func firstPath(r *bufio.Reader) (path string, found bool, err error) { | |||
| 283 | + | var b []byte | |||
| 284 | + | for { | |||
| 285 | + | c, err := r.ReadByte() | |||
| 286 | + | switch { | |||
| 287 | + | case err != nil: | |||
| 288 | + | return "", false, nil // the end of the output; a failing exit status is reported by pipe | |||
| 289 | + | case c == 0: | |||
| 290 | + | return string(b), true, nil | |||
| 291 | + | case len(b) >= maxDiffPath: | |||
| 292 | + | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxDiffPath) | |||
| 293 | + | } | |||
| 294 | + | b = append(b, c) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the | |||
| 299 | + | // old (unless the ref is created) and the new end of each update and returns | |||
| 300 | + | // the answers in that order, two per update, "" for the old end of a created | |||
| 301 | + | // ref. With suffix "^{}" an object that is missing, malformed or neither a | |||
| 302 | + | // commit, a tree nor a blob is an error (the push is refused); with any other | |||
| 303 | + | // suffix an answer "<input> missing" is kept as the answer. | |||
| 304 | + | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { | |||
| 305 | + | var in strings.Builder | |||
| 306 | + | want := 0 | |||
| 307 | + | for _, up := range ups { | |||
| 308 | + | for _, oid := range []string{up.Old, up.New} { | |||
| 309 | + | if oid == domain.ZeroOID { | |||
| 310 | + | continue | |||
| 311 | + | } | |||
| 312 | + | if _, err := domain.ParseObjectID(oid); err != nil { | |||
| 313 | + | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) | |||
| 314 | + | } | |||
| 315 | + | in.WriteString(oid + suffix + "\n") | |||
| 316 | + | want++ | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | got := make([]string, 0, want) | |||
| 320 | + | args := readArgs(dir, "cat-file", "--batch-check="+format) | |||
| 321 | + | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { | |||
| 322 | + | for range want { | |||
| 323 | + | line, err := r.ReadSlice('\n') | |||
| 324 | + | if err != nil { | |||
| 325 | + | if errors.Is(err, bufio.ErrBufferFull) { | |||
| 326 | + | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) | |||
| 327 | + | } | |||
| 328 | + | return fmt.Errorf("read object answers: %w", err) | |||
| 329 | + | } | |||
| 330 | + | got = append(got, strings.TrimSuffix(string(line), "\n")) | |||
| 331 | + | } | |||
| 332 | + | return nil | |||
| 333 | + | }) | |||
| 334 | + | if err != nil { | |||
| 335 | + | return nil, fmt.Errorf("objects of a push: %w", err) | |||
| 336 | + | } | |||
| 337 | + | out := make([]string, 0, 2*len(ups)) | |||
| 338 | + | i := 0 | |||
| 339 | + | for _, up := range ups { | |||
| 340 | + | for _, oid := range []string{up.Old, up.New} { | |||
| 341 | + | if oid == domain.ZeroOID { | |||
| 342 | + | out = append(out, "") | |||
| 343 | + | continue | |||
| 344 | + | } | |||
| 345 | + | if suffix == "^{}" { | |||
| 346 | + | switch got[i] { | |||
| 347 | + | case "commit", "tree", "blob": | |||
| 348 | + | default: // "<input> missing", or a type that cannot be compared | |||
| 349 | + | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | out = append(out, got[i]) | |||
| 353 | + | i++ | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | return out, nil | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | // quarantineEnv returns the environment that makes git read the objects of a | |||
| 360 | + | // push in quarantine. Each directory must exist and, once symbolic links are | |||
| 361 | + | // resolved, be inside the objects directory of the repository dir (itself | |||
| 362 | + | // resolved): a hook request cannot send git to read anything else. The resolved | |||
| 363 | + | // paths are the ones given to git. | |||
| 364 | + | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { | |||
| 365 | + | if o.Dir == "" && o.Alternates == "" { | |||
| 366 | + | return nil, nil | |||
| 367 | + | } | |||
| 368 | + | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) | |||
| 369 | + | if err != nil { | |||
| 370 | + | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) | |||
| 371 | + | } | |||
| 372 | + | inside := func(p string) (string, bool) { | |||
| 373 | + | if !filepath.IsAbs(p) { | |||
| 374 | + | return "", false | |||
| 375 | + | } | |||
| 376 | + | real, err := filepath.EvalSymlinks(p) | |||
| 377 | + | if err != nil { | |||
| 378 | + | return "", false | |||
| 379 | + | } | |||
| 380 | + | rel, err := filepath.Rel(root, real) | |||
| 381 | + | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |||
| 382 | + | } | |||
| 383 | + | dir, ok := inside(o.Dir) | |||
| 384 | + | if o.Dir == "" || !ok { | |||
| 385 | + | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) | |||
| 386 | + | } | |||
| 387 | + | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} | |||
| 388 | + | if o.Alternates != "" { | |||
| 389 | + | var alts []string | |||
| 390 | + | for _, a := range filepath.SplitList(o.Alternates) { | |||
| 391 | + | real, ok := inside(a) | |||
| 392 | + | if !ok { | |||
| 393 | + | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) | |||
| 394 | + | } | |||
| 395 | + | alts = append(alts, real) | |||
| 396 | + | } | |||
| 397 | + | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) | |||
| 398 | + | } | |||
| 399 | + | return env, nil | |||
| 400 | + | } | |||
R internal/adapters/gitcli/workflow_guard_test.go → internal/adapters/gitcli/push_trees_test.go +158 -217 renamed, 53% similar
| @@ -51,7 +51,7 @@ func TestChangedWorkflow(t *testing.T) { | |||||
| 51 | return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"} | 51 | return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"} | ||
| 52 | } | 52 | } | ||
| 53 | changed := func(ups ...domain.RefUpdate) string { | 53 | changed := func(ups ...domain.RefUpdate) string { | ||
| 54 | - | p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | 54 | + | p, err := changedWorkflow(g, ctx, f.Name, ups, ports.PushObjects{}) |
| 55 | require.NoError(t, err) | 55 | require.NoError(t, err) | ||
| 56 | return p | 56 | return p | ||
| 57 | } | 57 | } | ||
| @@ -68,11 +68,11 @@ func TestChangedWorkflow(t *testing.T) { | |||||
| 68 | assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one") | 68 | assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one") | ||
| 69 | assert.Equal(t, "", changed(), "no update") | 69 | assert.Equal(t, "", changed(), "no update") | ||
| 70 | 70 | ||||
| 71 | - | _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) | 71 | + | _, err := changedWorkflow(g, ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) |
| 72 | assert.Error(t, err, "an object git does not have is a failure, not a pass") | 72 | assert.Error(t, err, "an object git does not have is a failure, not a pass") | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | - | func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { | 75 | + | func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) { |
| 76 | g, data := newGit(t) | 76 | g, data := newGit(t) | ||
| 77 | ctx := context.Background() | 77 | ctx := context.Background() | ||
| 78 | f := readertest.NewFixture(t, data, "proj") | 78 | f := readertest.NewFixture(t, data, "proj") | ||
| @@ -90,23 +90,23 @@ func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { | |||||
| 90 | "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"}, | 90 | "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"}, | ||
| 91 | "alternates without a dir": {Alternates: objects}, | 91 | "alternates without a dir": {Alternates: objects}, | ||
| 92 | } { | 92 | } { | ||
| 93 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, o) | 93 | + | _, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 94 | assert.ErrorIs(t, err, domain.ErrInvalid, name) | 94 | assert.ErrorIs(t, err, domain.ErrInvalid, name) | ||
| 95 | } | 95 | } | ||
| 96 | // An existing directory inside the repository is accepted as a location | 96 | // An existing directory inside the repository is accepted as a location | ||
| 97 | // (the object is missing: a refusal of the object, not of the path). | 97 | // (the object is missing: a refusal of the object, not of the path). | ||
| 98 | require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700)) | 98 | require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700)) | ||
| 99 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}) | 99 | + | _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 100 | require.Error(t, err) | 100 | require.Error(t, err) | ||
| 101 | assert.NotContains(t, err.Error(), "outside the repository") | 101 | assert.NotContains(t, err.Error(), "outside the repository") | ||
| 102 | // One that does not exist is refused. | 102 | // One that does not exist is refused. | ||
| 103 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}) | 103 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 104 | assert.ErrorIs(t, err, domain.ErrInvalid) | 104 | assert.ErrorIs(t, err, domain.ErrInvalid) | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | // A symbolic link in the way cannot take the objects of a push out of the | 107 | // A symbolic link in the way cannot take the objects of a push out of the | ||
| 108 | // repository, and a data directory reached through links still works. | 108 | // repository, and a data directory reached through links still works. | ||
| 109 | - | func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { | 109 | + | func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) { |
| 110 | g, data := newGit(t) | 110 | g, data := newGit(t) | ||
| 111 | ctx := context.Background() | 111 | ctx := context.Background() | ||
| 112 | f := readertest.NewFixture(t, data, "proj") | 112 | f := readertest.NewFixture(t, data, "proj") | ||
| @@ -119,20 +119,20 @@ func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { | |||||
| 119 | // Inside objects/, a link to elsewhere is outside once resolved. | 119 | // Inside objects/, a link to elsewhere is outside once resolved. | ||
| 120 | elsewhere := t.TempDir() | 120 | elsewhere := t.TempDir() | ||
| 121 | require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link"))) | 121 | require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link"))) | ||
| 122 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}) | 122 | + | _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 123 | assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/") | 123 | assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/") | ||
| 124 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}) | 124 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 125 | assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/") | 125 | assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/") | ||
| 126 | 126 | ||||
| 127 | // A link to somewhere inside objects/ is fine. | 127 | // A link to somewhere inside objects/ is fine. | ||
| 128 | require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700)) | 128 | require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700)) | ||
| 129 | require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link"))) | 129 | require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link"))) | ||
| 130 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}) | 130 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 131 | require.Error(t, err) | 131 | require.Error(t, err) | ||
| 132 | assert.NotContains(t, err.Error(), "outside the repository") | 132 | assert.NotContains(t, err.Error(), "outside the repository") | ||
| 133 | } | 133 | } | ||
| 134 | 134 | ||||
| 135 | - | func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { | 135 | + | func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) { |
| 136 | _, data := newGit(t) | 136 | _, data := newGit(t) | ||
| 137 | ctx := context.Background() | 137 | ctx := context.Background() | ||
| 138 | f := readertest.NewFixture(t, data, "proj") | 138 | f := readertest.NewFixture(t, data, "proj") | ||
| @@ -152,10 +152,10 @@ func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { | |||||
| 152 | q := filepath.Join(f.Bare, "objects", "incoming-9") | 152 | q := filepath.Join(f.Bare, "objects", "incoming-9") | ||
| 153 | require.NoError(t, os.Mkdir(q, 0o700)) | 153 | require.NoError(t, os.Mkdir(q, 0o700)) | ||
| 154 | for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} { | 154 | for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} { | ||
| 155 | - | p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, | 155 | + | p, err := g2.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, |
| 156 | - | ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}) | 156 | + | ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 157 | require.NoError(t, err, dir) | 157 | require.NoError(t, err, dir) | ||
| 158 | - | assert.Equal(t, ".github/workflows/ci.yml", p, dir) | 158 | + | assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, dir) |
| 159 | } | 159 | } | ||
| 160 | } | 160 | } | ||
| 161 | 161 | ||||
| @@ -182,17 +182,7 @@ func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...strin | |||||
| 182 | return strings.TrimSpace(string(out)) | 182 | return strings.TrimSpace(string(out)) | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | - | // withWorkflows is a commit whose .github/workflows is the tree given. | 185 | + | func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) { |
| 186 | - | func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string { | |||
| 187 | - | t.Helper() | |||
| 188 | - | gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows") | |||
| 189 | - | return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github")) | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | // The trees of a push are the pusher's: a workflows directory that fans out | |||
| 193 | - | // into ten million paths is answered with its first path, at once, by a git | |||
| 194 | - | // that is stopped, without the diff ever being held in memory. | |||
| 195 | - | func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||
| 196 | g, data := newGit(t) | 186 | g, data := newGit(t) | ||
| 197 | ctx := context.Background() | 187 | ctx := context.Background() | ||
| 198 | f := readertest.NewFixture(t, data, "proj") | 188 | f := readertest.NewFixture(t, data, "proj") | ||
| @@ -206,17 +196,17 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||||
| 206 | } | 196 | } | ||
| 207 | level = treeOf(t, f, entries...) | 197 | level = treeOf(t, f, entries...) | ||
| 208 | } | 198 | } | ||
| 209 | - | amp := withWorkflows(t, f, level) | 199 | + | amp := level |
| 210 | 200 | ||||
| 211 | var before, after runtime.MemStats | 201 | var before, after runtime.MemStats | ||
| 212 | runtime.GC() | 202 | runtime.GC() | ||
| 213 | runtime.ReadMemStats(&before) | 203 | runtime.ReadMemStats(&before) | ||
| 214 | start := time.Now() | 204 | start := time.Now() | ||
| 215 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{}) | 205 | + | p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) |
| 216 | took := time.Since(start) | 206 | took := time.Since(start) | ||
| 217 | runtime.ReadMemStats(&after) | 207 | runtime.ReadMemStats(&after) | ||
| 218 | require.NoError(t, err) | 208 | require.NoError(t, err) | ||
| 219 | - | assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p) | 209 | + | assert.True(t, strings.HasPrefix(p, "d"), p) |
| 220 | assert.Less(t, took, 2*time.Second, "answered from the first path") | 210 | assert.Less(t, took, 2*time.Second, "answered from the first path") | ||
| 221 | assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory") | 211 | assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory") | ||
| 222 | // And no git is left behind. | 212 | // And no git is left behind. | ||
| @@ -225,69 +215,50 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||||
| 225 | } | 215 | } | ||
| 226 | 216 | ||||
| 227 | // A path longer than the bound fails closed: the push is refused, not read on. | 217 | // A path longer than the bound fails closed: the push is refused, not read on. | ||
| 228 | - | func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) { | 218 | + | func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) { |
| 229 | g, data := newGit(t) | 219 | g, data := newGit(t) | ||
| 230 | ctx := context.Background() | 220 | ctx := context.Background() | ||
| 231 | f := readertest.NewFixture(t, data, "proj") | 221 | f := readertest.NewFixture(t, data, "proj") | ||
| 232 | t.Cleanup(func() { _ = g.Close(ctx) }) | 222 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 233 | level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf") | 223 | level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf") | ||
| 234 | level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes | 224 | level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes | ||
| 235 | - | _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{}) | 225 | + | _, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) |
| 236 | assert.ErrorIs(t, err, domain.ErrInvalid) | 226 | assert.ErrorIs(t, err, domain.ErrInvalid) | ||
| 237 | } | 227 | } | ||
| 238 | 228 | ||||
| 239 | // A tag may point to a blob, a tree or a commit; only the commits and trees can | 229 | // A tag may point to a blob, a tree or a commit; only the commits and trees can | ||
| 240 | // hold a workflow, and an object that is not there refuses the push. | 230 | // hold a workflow, and an object that is not there refuses the push. | ||
| 241 | - | func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) { | 231 | + | func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) { |
| 242 | g, data := newGit(t) | 232 | g, data := newGit(t) | ||
| 243 | ctx := context.Background() | 233 | ctx := context.Background() | ||
| 244 | f := readertest.NewFixture(t, data, "proj") | 234 | f := readertest.NewFixture(t, data, "proj") | ||
| 245 | t.Cleanup(func() { _ = g.Close(ctx) }) | 235 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 246 | f.Write("README", "r\n") | 236 | f.Write("README", "r\n") | ||
| 247 | - | plain := f.Commit("plain") | 237 | + | plain := oidOf(t, f.Commit("plain")).String() |
| 248 | - | f.Write(".github/workflows/ci.yml", "on: push\n") | |||
| 249 | - | wf := f.Commit("workflow") | |||
| 250 | - | f.Write(".github/workflows/ci.yml", "on: [push]\n") | |||
| 251 | - | f.Commit("the default branch moves on to other workflows") // HEAD is not wf | |||
| 252 | blob := f.WriteObject("blob", "hello\n") | 238 | blob := f.WriteObject("blob", "hello\n") | ||
| 253 | - | annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n") | 239 | + | tag := func(id, kind string) string { |
| 254 | - | annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n") | 240 | + | return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a <a@b> 1 +0000\n\nm\n") |
| 255 | - | rootTree := f.Git("rev-parse", wf+"^{tree}") | |||
| 256 | - | ref := func(old, new string) []domain.RefUpdate { | |||
| 257 | - | return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}} | |||
| 258 | - | } | |||
| 259 | - | changed := func(ups []domain.RefUpdate) (string, error) { | |||
| 260 | - | return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |||
| 261 | - | } | |||
| 262 | - | for name, ups := range map[string][]domain.RefUpdate{ | |||
| 263 | - | "create a lightweight tag on a blob": ref(domain.ZeroOID, blob), | |||
| 264 | - | "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob), | |||
| 265 | - | "a tag from a blob to another blob": ref(blob, annotatedBlob), | |||
| 266 | - | "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob), | |||
| 267 | - | } { | |||
| 268 | - | p, err := changed(ups) | |||
| 269 | - | require.NoError(t, err, name) | |||
| 270 | - | assert.Empty(t, p, name) | |||
| 271 | } | 241 | } | ||
| 272 | - | for name, ups := range map[string][]domain.RefUpdate{ | 242 | + | tree := f.Git("rev-parse", plain+"^{tree}") |
| 273 | - | "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()), | 243 | + | for _, r := range []struct { |
| 274 | - | "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()), | 244 | + | id string |
| 275 | - | "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF), | 245 | + | kind domain.PushObjectType |
| 276 | - | "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree), | 246 | + | }{{plain, domain.PushObjectCommit}, {tag(plain, "commit"), domain.PushObjectCommit}, {blob, domain.PushObjectBlob}, {tag(blob, "blob"), domain.PushObjectBlob}, {tree, domain.PushObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.PushObjectCommit}} { |
| 277 | - | "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF), | 247 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 278 | - | } { | 248 | + | require.NoError(t, err) |
| 279 | - | p, err := changed(ups) | 249 | + | assert.Equal(t, r.kind, got.Ends[r.id]) |
| 280 | - | require.NoError(t, err, name) | 250 | + | if r.kind == domain.PushObjectBlob { |
| 281 | - | assert.Equal(t, ".github/workflows/ci.yml", p, name) | 251 | + | assert.NotContains(t, got.Subtrees, r.id) |
| 252 | + | } | |||
| 282 | } | 253 | } | ||
| 283 | - | for name, ups := range map[string][]domain.RefUpdate{ | 254 | + | for _, r := range []domain.RefUpdate{{Old: domain.ZeroOID, New: strings.Repeat("1", 40)}, {Old: strings.Repeat("2", 40), New: plain}, {Old: domain.ZeroOID, New: "ZZZZ"}} { |
| 284 | - | "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"), | 255 | + | _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 285 | - | "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()), | 256 | + | assert.ErrorIs(t, err, domain.ErrInvalid) |
| 286 | - | "a malformed object": ref(domain.ZeroOID, "ZZZZ"), | |||
| 287 | - | } { | |||
| 288 | - | _, err := changed(ups) | |||
| 289 | - | assert.Error(t, err, name) | |||
| 290 | } | 257 | } | ||
| 258 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |||
| 259 | + | require.NoError(t, err) | |||
| 260 | + | assert.Empty(t, got.Ends) | |||
| 261 | + | assert.Empty(t, got.Commits) | |||
| 291 | } | 262 | } | ||
| 292 | 263 | ||||
| 293 | // rawTree writes a tree object by hand: entries are "mode name hexid", in the | 264 | // rawTree writes a tree object by hand: entries are "mode name hexid", in the | ||
| @@ -308,73 +279,35 @@ func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string { | |||||
| 308 | // mode 040000 below a directory .github of mode 040000. The same tree id as a | 279 | // mode 040000 below a directory .github of mode 040000. The same tree id as a | ||
| 309 | // gitlink, a symbolic link or a file is nothing, and turning it into a | 280 | // gitlink, a symbolic link or a file is nothing, and turning it into a | ||
| 310 | // directory activates it; the object id alone does not tell. | 281 | // directory activates it; the object id alone does not tell. | ||
| 311 | - | func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) { | 282 | + | func TestPushSubtreesEntryKinds(t *testing.T) { |
| 312 | g, data := newGit(t) | 283 | g, data := newGit(t) | ||
| 313 | ctx := context.Background() | 284 | ctx := context.Background() | ||
| 314 | f := readertest.NewFixture(t, data, "proj") | 285 | f := readertest.NewFixture(t, data, "proj") | ||
| 315 | t.Cleanup(func() { _ = g.Close(ctx) }) | 286 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 316 | - | f.Write("README", "r\n") | 287 | + | blob := f.WriteObject("blob", "x\n") |
| 317 | - | f.Commit("base") | 288 | + | tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob}) |
| 318 | - | yml := f.WriteObject("blob", "on: push\n") | 289 | + | for _, r := range []struct { |
| 319 | - | wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows | 290 | + | mode, id string |
| 320 | - | link := f.WriteObject("blob", ".github/other") | 291 | + | kind domain.EntryKind |
| 321 | - | file := f.WriteObject("blob", "not a directory\n") | 292 | + | }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} { |
| 322 | - | readme := f.WriteObject("blob", "r\n") | 293 | + | t.Run(r.mode, func(t *testing.T) { |
| 323 | - | 294 | + | gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id}) | |
| 324 | - | // A commit whose .github/workflows has the mode and id given. | 295 | + | root := rawTree(t, f, [3]string{"040000", ".github", gh}) |
| 325 | - | withWorkflows := func(mode, id string) string { | 296 | + | oid := commitOf(t, f, root) |
| 326 | - | gh := rawTree(t, f, [3]string{mode, "workflows", id}) | 297 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 327 | - | return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh})) | 298 | + | require.NoError(t, err) |
| 328 | - | } | 299 | + | assert.Equal(t, r.kind, got.Subtrees[oid].Kind) |
| 329 | - | // A commit whose .github has the mode and id given. | 300 | + | assert.Equal(t, r.id, got.Subtrees[oid].ID.String()) |
| 330 | - | withGithub := func(mode, id string) string { | 301 | + | assert.NotContains(t, got.Subtrees, domain.HeadKey) |
| 331 | - | return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id})) | 302 | + | if r.mode != "040000" { |
| 332 | - | } | 303 | + | root = rawTree(t, f, [3]string{r.mode, ".github", r.id}) |
| 333 | - | asDir := withWorkflows("040000", wfTree) | 304 | + | oid = commitOf(t, f, root) |
| 334 | - | asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule | 305 | + | got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 335 | - | asSymlink := withWorkflows("120000", link) | 306 | + | require.NoError(t, err) |
| 336 | - | asFile := withWorkflows("100644", file) | 307 | + | assert.NotContains(t, got.Subtrees, oid) |
| 337 | - | none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme})) | 308 | + | } |
| 338 | - | ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree}) | 309 | + | }) |
| 339 | - | githubDir := withGithub("040000", ghDir) | |||
| 340 | - | githubGitlink := withGithub("160000", ghDir) // the same id, a submodule | |||
| 341 | - | githubFile := withGithub("100644", file) | |||
| 342 | - | githubLink := withGithub("120000", link) | |||
| 343 | - | ||||
| 344 | - | for name, c := range map[string]struct { | |||
| 345 | - | old, new string | |||
| 346 | - | want string | |||
| 347 | - | }{ | |||
| 348 | - | "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"}, | |||
| 349 | - | "a directory to a gitlink removes it": {asDir, asGitlink, ""}, | |||
| 350 | - | "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"}, | |||
| 351 | - | "a directory to a symlink removes it": {asDir, asSymlink, ""}, | |||
| 352 | - | "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"}, | |||
| 353 | - | "a directory to a file removes it": {asDir, asFile, ""}, | |||
| 354 | - | "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"}, | |||
| 355 | - | "a directory to nothing": {asDir, none, ""}, | |||
| 356 | - | "the same gitlink": {asGitlink, asGitlink, ""}, | |||
| 357 | - | "gitlink to symlink": {asGitlink, asSymlink, ""}, | |||
| 358 | - | ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"}, | |||
| 359 | - | ".github a directory to a gitlink": {githubDir, githubGitlink, ""}, | |||
| 360 | - | ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"}, | |||
| 361 | - | ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"}, | |||
| 362 | - | ".github a directory to a symlink": {githubDir, githubLink, ""}, | |||
| 363 | - | "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""}, | |||
| 364 | - | "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""}, | |||
| 365 | - | "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""}, | |||
| 366 | - | "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"}, | |||
| 367 | - | } { | |||
| 368 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{}) | |||
| 369 | - | require.NoError(t, err, name) | |||
| 370 | - | assert.Equal(t, c.want, p, name) | |||
| 371 | } | 310 | } | ||
| 372 | - | ||||
| 373 | - | // A tag that peels to the commit is judged the same way. | |||
| 374 | - | tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n") | |||
| 375 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{}) | |||
| 376 | - | require.NoError(t, err) | |||
| 377 | - | assert.Equal(t, ".github/workflows/ci.yml", p) | |||
| 378 | } | 311 | } | ||
| 379 | 312 | ||||
| 380 | // A history for the rules on the commits a push brings: main holds workflows A, | 313 | // A history for the rules on the commits a push brings: main holds workflows A, | ||
| @@ -418,91 +351,37 @@ func (h *guardHistory) commit(workflows, _ string, parents ...string) string { | |||||
| 418 | return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...) | 351 | return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...) | ||
| 419 | } | 352 | } | ||
| 420 | 353 | ||||
| 421 | - | func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) { | 354 | + | func TestPushSubtreesCommitsAndParents(t *testing.T) { |
| 422 | - | g, data := newGit(t) | |||
| 423 | - | ctx := context.Background() | |||
| 424 | - | h := newGuardHistory(t, g, data) | |||
| 425 | - | t.Cleanup(func() { _ = g.Close(ctx) }) | |||
| 426 | - | f := h.f | |||
| 427 | - | run := func(ups ...domain.RefUpdate) (string, error) { | |||
| 428 | - | return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |||
| 429 | - | } | |||
| 430 | - | create := func(ref, to string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: to, Ref: ref} } | |||
| 431 | - | move := func(ref, from, to string) domain.RefUpdate { return domain.RefUpdate{Old: from, New: to, Ref: ref} } | |||
| 432 | - | const ci = ".github/workflows/ci.yml" | |||
| 433 | - | ||||
| 434 | - | allowed := func(name string, ups ...domain.RefUpdate) { | |||
| 435 | - | t.Helper() | |||
| 436 | - | p, err := run(ups...) | |||
| 437 | - | require.NoError(t, err, name) | |||
| 438 | - | assert.Empty(t, p, name) | |||
| 439 | - | } | |||
| 440 | - | refused := func(name string, ups ...domain.RefUpdate) { | |||
| 441 | - | t.Helper() | |||
| 442 | - | p, err := run(ups...) | |||
| 443 | - | require.NoError(t, err, name) | |||
| 444 | - | assert.Equal(t, ci, p, name) | |||
| 445 | - | } | |||
| 446 | - | ||||
| 447 | - | // The staging repro: a new branch off main with a commit that touches no workflow. | |||
| 448 | - | allowed("new branch off main with a plain commit", create("refs/heads/smoke", h.commit("A", "", h.m))) | |||
| 449 | - | allowed("a new branch at the head of main", create("refs/heads/same", h.m)) | |||
| 450 | - | allowed("a new tag at the head of main", create("refs/tags/v1", h.m)) | |||
| 451 | - | allowed("a new branch whose commits carry no workflows at all", create("refs/heads/bare", h.commit("", ""))) | |||
| 452 | - | allowed("a fast-forward with plain commits", move("refs/heads/main", h.m, h.commit("A", "", h.commit("A", "", h.m)))) | |||
| 453 | - | allowed("a commit that removes the workflows", move("refs/heads/main", h.m, h.commit("", "", h.m))) | |||
| 454 | - | ||||
| 455 | - | // A pushed commit that changes them is refused wherever it goes. | |||
| 456 | - | refused("new branch with a commit that changes workflows", create("refs/heads/w", h.commit("C", "", h.m))) | |||
| 457 | - | refused("a root commit that carries workflows", create("refs/heads/root", h.commit("C", ""))) | |||
| 458 | - | refused("an update with a commit that changes workflows", move("refs/heads/main", h.m, h.commit("C", "", h.m))) | |||
| 459 | - | n4 := h.commit("C", "", h.m) | |||
| 460 | - | n5 := h.commit("A", "", n4) // reverts the change of the commit before | |||
| 461 | - | refused("a fast-forward through a commit that changes them and a later one that reverts it", move("refs/heads/main", h.m, n5)) | |||
| 462 | - | refused("a new branch through the same", create("refs/heads/through", n5)) | |||
| 463 | - | n6 := h.commit("A", "", h.commit("C", "", h.o)) | |||
| 464 | - | refused("a commit that changes them below plain ones, on an old base", create("refs/heads/below", h.commit("C", "", n6))) | |||
| 465 | - | ||||
| 466 | - | // A ref cannot be moved onto existing commits with other workflows. | |||
| 467 | - | refused("a new branch at an old commit whose workflows differ from main", create("refs/heads/b", h.o)) | |||
| 468 | - | refused("a new tag at that commit", create("refs/tags/old", h.o)) | |||
| 469 | - | refused("an update moving back to an older commit with other workflows", move("refs/heads/main", h.m, h.o)) | |||
| 470 | - | allowed("an update to a commit with the same workflows", move("refs/heads/old", h.o, h.commit("B", "", h.o))) | |||
| 471 | - | allowed("an update that moves a ref to a commit without workflows (removes them)", move("refs/heads/old", h.o, h.commit("", "", h.o))) | |||
| 472 | - | ||||
| 473 | - | // Merges: a change only if it differs from every parent. | |||
| 474 | - | allowed("a merge of existing commits that keeps main's workflows", move("refs/heads/main", h.m, h.commit("A", "", h.m, h.o))) | |||
| 475 | - | allowed("a new branch with a merge keeping one parent's workflows", create("refs/heads/merge", h.commit("A", "", h.m, h.o))) | |||
| 476 | - | refused("a merge that differs from both parents", create("refs/heads/merge2", h.commit("D", "", h.m, h.o))) | |||
| 477 | - | refused("a merge of a pushed side that changes them (the side commit is itself new)", create("refs/heads/merge3", h.commit("A", "", h.m, h.commit("C", "", h.o)))) | |||
| 478 | - | // One that takes the old side's workflows is a change of the ref, not of a commit. | |||
| 479 | - | refused("a merge taking the other parent's workflows, onto main", move("refs/heads/main", h.m, h.commit("B", "", h.m, h.o))) | |||
| 480 | - | ||||
| 481 | - | // Several updates: the first refused refuses the push. | |||
| 482 | - | refused("a plain ref and a refused one", create("refs/heads/ok", h.commit("A", "", h.m)), create("refs/heads/w2", h.commit("C", "", h.m))) | |||
| 483 | - | allowed("deletes", move("refs/heads/old", h.o, domain.ZeroOID)) | |||
| 484 | - | } | |||
| 485 | - | ||||
| 486 | - | // With no default branch head, a new ref is compared with no workflows at all. | |||
| 487 | - | func TestChangedWorkflowOfANewRefWithoutADefaultBranchHead(t *testing.T) { | |||
| 488 | g, data := newGit(t) | 355 | g, data := newGit(t) | ||
| 489 | ctx := context.Background() | 356 | ctx := context.Background() | ||
| 490 | h := newGuardHistory(t, g, data) | 357 | h := newGuardHistory(t, g, data) | ||
| 491 | t.Cleanup(func() { _ = g.Close(ctx) }) | 358 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 492 | - | h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist | 359 | + | side := h.commit("B", "", h.o) |
| 493 | - | run := func(to string) string { | 360 | + | merge := h.commit("A", "", h.m, side) |
| 494 | - | p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{}) | 361 | + | got, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}, {Old: domain.ZeroOID, New: merge}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 495 | - | require.NoError(t, err) | 362 | + | require.NoError(t, err) |
| 496 | - | return p | 363 | + | want := h.f.Git("rev-list", "--parents", merge, "--not", "--all") |
| 364 | + | var lines []string | |||
| 365 | + | for _, c := range got.Commits { | |||
| 366 | + | line := c.ID.String() | |||
| 367 | + | for _, p := range c.Parents { | |||
| 368 | + | line += " " + p.String() | |||
| 369 | + | } | |||
| 370 | + | lines = append(lines, line) | |||
| 497 | } | 371 | } | ||
| 498 | - | assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows") | 372 | + | assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n")) |
| 499 | - | plain := h.commit("", "") | 373 | + | assert.Len(t, got.Commits, 2) |
| 500 | - | h.f.Git("update-ref", "refs/heads/plain", plain) | 374 | + | assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String()) |
| 501 | - | assert.Empty(t, run(plain), "an existing commit without") | 375 | + | assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String()) |
| 376 | + | // Any directory path is accepted; the reader has no workflow path policy. | |||
| 377 | + | other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds()) | |||
| 378 | + | require.NoError(t, err) | |||
| 379 | + | assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind) | |||
| 380 | + | assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates") | |||
| 502 | } | 381 | } | ||
| 503 | 382 | ||||
| 504 | // The commits of a push are bounded: more than the bound fails closed. | 383 | // The commits of a push are bounded: more than the bound fails closed. | ||
| 505 | - | func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) { | 384 | + | func TestPushSubtreesBoundsThePushedCommits(t *testing.T) { |
| 506 | g, data := newGit(t) | 385 | g, data := newGit(t) | ||
| 507 | ctx := context.Background() | 386 | ctx := context.Background() | ||
| 508 | h := newGuardHistory(t, g, data) | 387 | h := newGuardHistory(t, g, data) | ||
| @@ -525,10 +404,10 @@ func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) { | |||||
| 525 | } | 404 | } | ||
| 526 | // Plain commits carry the workflows of main (the fast-import tree has none, | 405 | // Plain commits carry the workflows of main (the fast-import tree has none, | ||
| 527 | // so the chain is "removing" them): allowed, whatever their number, up to the bound. | 406 | // so the chain is "removing" them): allowed, whatever their number, up to the bound. | ||
| 528 | - | p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits), Ref: "refs/heads/main"}}, ports.PushObjects{}) | 407 | + | p, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(20), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80}) |
| 529 | require.NoError(t, err) | 408 | require.NoError(t, err) | ||
| 530 | - | assert.Empty(t, p) | 409 | + | assert.Len(t, p.Commits, 20) |
| 531 | - | _, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{}) | 410 | + | _, err = g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(21), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80}) |
| 532 | assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push") | 411 | assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push") | ||
| 533 | } | 412 | } | ||
| 534 | 413 | ||||
| @@ -565,18 +444,18 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) { | |||||
| 565 | b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small | 444 | b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small | ||
| 566 | } | 445 | } | ||
| 567 | b.WriteString("\n") | 446 | b.WriteString("\n") | ||
| 568 | - | r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())} | 447 | + | r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())} |
| 569 | - | kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects) | 448 | + | kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) |
| 570 | require.ErrorIs(t, err, domain.ErrInvalid) | 449 | require.ErrorIs(t, err, domain.ErrInvalid) | ||
| 571 | assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length") | 450 | assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length") | ||
| 572 | assert.Zero(t, kept, "nothing is returned with the error") | 451 | assert.Zero(t, kept, "nothing is returned with the error") | ||
| 573 | - | total := gitcli.MaxPushedCommits * len(b.String()) | 452 | + | total := domain.MaxPushedCommits * len(b.String()) |
| 574 | assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total) | 453 | assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total) | ||
| 575 | assert.Less(t, r.read*20, total) | 454 | assert.Less(t, r.read*20, total) | ||
| 576 | 455 | ||||
| 577 | // Within the bounds the same lines are kept: 100 commits of 198 parents. | 456 | // Within the bounds the same lines are kept: 100 commits of 198 parents. | ||
| 578 | r = &countingReader{lines: 100, line: []byte(b.String())} | 457 | r = &countingReader{lines: 100, line: []byte(b.String())} | ||
| 579 | - | kept, err = gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects) | 458 | + | kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) |
| 580 | require.NoError(t, err) | 459 | require.NoError(t, err) | ||
| 581 | assert.Equal(t, 100, kept) | 460 | assert.Equal(t, 100, kept) | ||
| 582 | 461 | ||||
| @@ -590,3 +469,65 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) { | |||||
| 590 | _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000) | 469 | _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000) | ||
| 591 | assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound") | 470 | assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound") | ||
| 592 | } | 471 | } | ||
| 472 | + | ||||
| 473 | + | func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |||
| 474 | + | t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |||
| 475 | + | if err != nil { | |||
| 476 | + | return "", err | |||
| 477 | + | } | |||
| 478 | + | c, found, err := domain.WorkflowChangeOf(ups, t) | |||
| 479 | + | if err != nil || !found { | |||
| 480 | + | return "", err | |||
| 481 | + | } | |||
| 482 | + | entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New) | |||
| 483 | + | if err != nil { | |||
| 484 | + | return "", err | |||
| 485 | + | } | |||
| 486 | + | if entry == "" { | |||
| 487 | + | return domain.WorkflowDir, nil | |||
| 488 | + | } | |||
| 489 | + | return domain.WorkflowDir + "/" + entry, nil | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func TestFirstDifferentEntry(t *testing.T) { | |||
| 493 | + | g, data := newGit(t) | |||
| 494 | + | ctx := context.Background() | |||
| 495 | + | f := readertest.NewFixture(t, data, "proj") | |||
| 496 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |||
| 497 | + | tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml")) | |||
| 498 | + | entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree) | |||
| 499 | + | require.NoError(t, err) | |||
| 500 | + | assert.Equal(t, "ci.yml", entry) | |||
| 501 | + | entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree) | |||
| 502 | + | require.NoError(t, err) | |||
| 503 | + | assert.Empty(t, entry) | |||
| 504 | + | _, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree) | |||
| 505 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | func TestChangedWorkflowPushHistories(t *testing.T) { | |||
| 509 | + | g, data := newGit(t) | |||
| 510 | + | ctx := context.Background() | |||
| 511 | + | h := newGuardHistory(t, g, data) | |||
| 512 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |||
| 513 | + | run := func(old, tip, want string) { | |||
| 514 | + | t.Helper() | |||
| 515 | + | got, err := changedWorkflow(g, ctx, h.f.Name, []domain.RefUpdate{{Old: old, New: tip, Ref: "refs/heads/pushed"}}, ports.PushObjects{}) | |||
| 516 | + | require.NoError(t, err) | |||
| 517 | + | assert.Equal(t, want, got) | |||
| 518 | + | } | |||
| 519 | + | run(domain.ZeroOID, h.commit("A", "", h.m), "") | |||
| 520 | + | change := h.commit("C", "", h.m) | |||
| 521 | + | revert := h.commit("A", "", change) | |||
| 522 | + | run(h.m, revert, ".github/workflows/ci.yml") | |||
| 523 | + | run(domain.ZeroOID, h.commit("D", "", h.m, h.o), ".github/workflows/ci.yml") | |||
| 524 | + | h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") | |||
| 525 | + | run(domain.ZeroOID, h.m, ".github/workflows/ci.yml") | |||
| 526 | + | ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: h.m, Ref: "refs/tags/t"}} | |||
| 527 | + | trees, err := g.PushSubtrees(ctx, h.f.Name, ups, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |||
| 528 | + | require.NoError(t, err) | |||
| 529 | + | assert.NotContains(t, trees.Subtrees, domain.HeadKey) | |||
| 530 | + | plain := h.commit("", "") | |||
| 531 | + | h.f.Git("update-ref", "refs/heads/plain", plain) | |||
| 532 | + | run(domain.ZeroOID, plain, "") | |||
| 533 | + | } | |||
M internal/adapters/gitcli/read.go +31 -60
| @@ -35,8 +35,6 @@ func notFound(err error) bool { return errors.Is(err, errObjectMissing) } | |||||
| 35 | // ListRefs lists HEAD, the branches and the tags with `git for-each-ref`. | 35 | // ListRefs lists HEAD, the branches and the tags with `git for-each-ref`. | ||
| 36 | // An annotated tag is followed to its commit; a tag of a tag is followed with | 36 | // An annotated tag is followed to its commit; a tag of a tag is followed with | ||
| 37 | // one `cat-file` request each. | 37 | // one `cat-file` request each. | ||
| 38 | - | // | |||
| 39 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain refs | |||
| 40 | func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | 38 | func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | ||
| 41 | dir, err := g.repoDir(repo) | 39 | dir, err := g.repoDir(repo) | ||
| 42 | if err != nil { | 40 | if err != nil { | ||
| @@ -155,66 +153,45 @@ func readHead(dir string) (string, error) { | |||||
| 155 | return ref, nil | 153 | return ref, nil | ||
| 156 | } | 154 | } | ||
| 157 | 155 | ||||
| 158 | - | // revCandidate is one way to read a revision as a commit. | 156 | + | // resolve resolves the domain candidates on an open cat-file connection. |
| 159 | - | type revCandidate struct { | |||
| 160 | - | spec string // object expression for cat-file | |||
| 161 | - | ref string // full ref name it stands for, "" for ids and HEAD | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | // candidates lists, in order, how a revision is tried. A full object id is an | |||
| 165 | - | // object id first: a branch or a tag that happens to be named like a commit | |||
| 166 | - | // must not redirect a link to that commit; refs are tried only when no such | |||
| 167 | - | // object exists. Otherwise it is the branch, then the tag, then (for "HEAD", | |||
| 168 | - | // full ref names and abbreviations) as written. Other forms Git understands, | |||
| 169 | - | // such as "main~2" or "FETCH_HEAD", are not revisions of ermite. | |||
| 170 | - | func candidates(rev domain.Revision) []revCandidate { | |||
| 171 | - | s := rev.String() | |||
| 172 | - | switch { | |||
| 173 | - | case rev.IsHead() || s == "HEAD": | |||
| 174 | - | return []revCandidate{{spec: "HEAD^{commit}"}} | |||
| 175 | - | } | |||
| 176 | - | refs := []revCandidate{ | |||
| 177 | - | {spec: headsRefPrefix + s + "^{commit}", ref: headsRefPrefix + s}, | |||
| 178 | - | {spec: tagsRefPrefix + s + "^{commit}", ref: tagsRefPrefix + s}, | |||
| 179 | - | } | |||
| 180 | - | switch { | |||
| 181 | - | case rev.IsObjectID(): | |||
| 182 | - | return append([]revCandidate{{spec: s + "^{commit}"}}, refs...) | |||
| 183 | - | case strings.HasPrefix(s, "refs/"): | |||
| 184 | - | return append(refs, revCandidate{spec: s + "^{commit}", ref: s}) | |||
| 185 | - | case rev.IsAbbrevID(): | |||
| 186 | - | return append(refs, revCandidate{spec: s + "^{commit}"}) | |||
| 187 | - | } | |||
| 188 | - | return refs | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | // resolve resolves rev on an open cat-file connection. | |||
| 192 | - | // | |||
| 193 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps a revision to git object names, a git detail of the reader | |||
| 194 | func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) { | 157 | func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) { | ||
| 195 | - | for _, cand := range candidates(rev) { | 158 | + | for _, cand := range rev.Candidates() { |
| 196 | - | h, err := c.info(cand.spec) | 159 | + | id, ok, err := lookupRevisionCandidate(c, cand) |
| 197 | - | if notFound(err) { | |||
| 198 | - | continue | |||
| 199 | - | } | |||
| 200 | if err != nil { | 160 | if err != nil { | ||
| 201 | return domain.ResolvedRevision{}, err | 161 | return domain.ResolvedRevision{}, err | ||
| 202 | } | 162 | } | ||
| 203 | - | if h.Type != "commit" { | 163 | + | if !ok { |
| 204 | continue | 164 | continue | ||
| 205 | } | 165 | } | ||
| 206 | - | id, err := domain.ParseObjectID(h.ID) | 166 | + | return domain.ResolvedRevision{CommitID: id, Ref: cand.ResolvedRef()}, nil |
| 207 | - | if err != nil { | |||
| 208 | - | return domain.ResolvedRevision{}, c.fail(err) | |||
| 209 | - | } | |||
| 210 | - | return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil | |||
| 211 | } | 167 | } | ||
| 212 | - | return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound) | 168 | + | return domain.ResolvedRevision{}, revisionNotFound(rev) |
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func revisionNotFound(rev domain.Revision) error { | |||
| 172 | + | return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // lookupRevisionCandidate asks cat-file to peel a candidate to a commit. | |||
| 176 | + | func lookupRevisionCandidate(c *catConn, candidate domain.RevisionCandidate) (domain.ObjectID, bool, error) { | |||
| 177 | + | h, err := c.info(candidate.Name + "^{commit}") | |||
| 178 | + | if notFound(err) { | |||
| 179 | + | return domain.ObjectID{}, false, nil | |||
| 180 | + | } | |||
| 181 | + | if err != nil { | |||
| 182 | + | return domain.ObjectID{}, false, err | |||
| 183 | + | } | |||
| 184 | + | if h.Type != "commit" { | |||
| 185 | + | return domain.ObjectID{}, false, nil | |||
| 186 | + | } | |||
| 187 | + | id, err := domain.ParseObjectID(h.ID) | |||
| 188 | + | if err != nil { | |||
| 189 | + | return domain.ObjectID{}, false, c.fail(err) | |||
| 190 | + | } | |||
| 191 | + | return id, true, nil | |||
| 213 | } | 192 | } | ||
| 214 | 193 | ||||
| 215 | // ResolveRevision resolves a revision with `cat-file`. | 194 | // ResolveRevision resolves a revision with `cat-file`. | ||
| 216 | - | // | |||
| 217 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |||
| 218 | func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | 195 | func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | ||
| 219 | dir, err := g.repoDir(repo) | 196 | dir, err := g.repoDir(repo) | ||
| 220 | if err != nil { | 197 | if err != nil { | ||
| @@ -235,8 +212,6 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom | |||||
| 235 | // tag, full ref name, HEAD; a single segment may also be a commit id), longest | 212 | // tag, full ref name, HEAD; a single segment may also be a commit id), longest | ||
| 236 | // first, with up to one `cat-file` request per candidate on the repository's | 213 | // first, with up to one `cat-file` request per candidate on the repository's | ||
| 237 | // process. At most domain.MaxRefPathTries segments are tried. | 214 | // process. At most domain.MaxRefPathTries segments are tried. | ||
| 238 | - | // | |||
| 239 | - | //hexcheck:ignore suspicious-business-logic-in-adapter the split is domain.SplitRefPath; this only runs it on a cat-file connection | |||
| 240 | func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) { | 215 | func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) { | ||
| 241 | dir, err := g.repoDir(repo) | 216 | dir, err := g.repoDir(repo) | ||
| 242 | if err != nil { | 217 | if err != nil { | ||
| @@ -264,6 +239,8 @@ type object struct { | |||||
| 264 | // lookup resolves rev and reads the header of the object at path, in one | 239 | // lookup resolves rev and reads the header of the object at path, in one | ||
| 265 | // request series on the repository's process. A missing path is | 240 | // request series on the repository's process. A missing path is | ||
| 266 | // domain.ErrNotFound. | 241 | // domain.ErrNotFound. | ||
| 242 | + | // | |||
| 243 | + | //hexcheck:ignore suspicious-business-logic-in-adapter translates a root path to Git tree syntax and a non-root path to Git colon syntax, then reads the cat-file header | |||
| 267 | func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) { | 244 | func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) { | ||
| 268 | var obj object | 245 | var obj object | ||
| 269 | err := g.cat.do(ctx, dir, func(c *catConn) error { | 246 | err := g.cat.do(ctx, dir, func(c *catConn) error { | ||
| @@ -392,8 +369,6 @@ func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revi | |||||
| 392 | } | 369 | } | ||
| 393 | 370 | ||||
| 394 | // parseTreeEntry parses "<mode> <type> <id> <size>\t<name>" of ls-tree -l. | 371 | // parseTreeEntry parses "<mode> <type> <id> <size>\t<name>" of ls-tree -l. | ||
| 395 | - | // | |||
| 396 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |||
| 397 | func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) { | 372 | func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) { | ||
| 398 | meta, name, ok := strings.Cut(rec, "\t") | 373 | meta, name, ok := strings.Cut(rec, "\t") | ||
| 399 | f := strings.Fields(meta) | 374 | f := strings.Fields(meta) | ||
| @@ -487,8 +462,6 @@ feed: | |||||
| 487 | 462 | ||||
| 488 | // lastCommit returns the newest commit reachable from head that changed path; | 463 | // lastCommit returns the newest commit reachable from head that changed path; | ||
| 489 | // an empty path means head itself. | 464 | // an empty path means head itself. | ||
| 490 | - | // | |||
| 491 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |||
| 492 | func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) { | 465 | func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) { | ||
| 493 | var c domain.Commit | 466 | var c domain.Commit | ||
| 494 | found := false | 467 | found := false | ||
| @@ -520,8 +493,6 @@ const commitFormat = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B" | |||||
| 520 | 493 | ||||
| 521 | // readCommit reads one commit in commitFormat. It returns io.EOF at the end | 494 | // readCommit reads one commit in commitFormat. It returns io.EOF at the end | ||
| 522 | // of the output. | 495 | // of the output. | ||
| 523 | - | // | |||
| 524 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |||
| 525 | func readCommit(r *bufio.Reader) (domain.Commit, error) { | 496 | func readCommit(r *bufio.Reader) (domain.Commit, error) { | ||
| 526 | var f [9]string | 497 | var f [9]string | ||
| 527 | for i := range f { | 498 | for i := range f { | ||
M internal/adapters/gitcli/repos.go +1 -3
| @@ -102,7 +102,7 @@ func (g *Git) Discard(_ context.Context, id ports.StagedID) error { | |||||
| 102 | 102 | ||||
| 103 | // PurgeStaging removes everything under repos/.staging/. | 103 | // PurgeStaging removes everything under repos/.staging/. | ||
| 104 | // | 104 | // | ||
| 105 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | 105 | + | //hexcheck:ignore suspicious-business-logic-in-adapter removes staging leftovers from disk, tolerates a missing directory, and logs each removal |
| 106 | func (g *Git) PurgeStaging(ctx context.Context) error { | 106 | func (g *Git) PurgeStaging(ctx context.Context) error { | ||
| 107 | entries, err := os.ReadDir(g.stagingDir()) | 107 | entries, err := os.ReadDir(g.stagingDir()) | ||
| 108 | if err != nil { | 108 | if err != nil { | ||
| @@ -280,8 +280,6 @@ func (g *Git) DefaultBranch(ctx context.Context, name domain.RepoName) (string, | |||||
| 280 | // HasBranch checks refs/heads/<branch> with `git for-each-ref` after | 280 | // HasBranch checks refs/heads/<branch> with `git for-each-ref` after | ||
| 281 | // `git check-ref-format`, which rejects the glob characters for-each-ref | 281 | // `git check-ref-format`, which rejects the glob characters for-each-ref | ||
| 282 | // would otherwise interpret. | 282 | // would otherwise interpret. | ||
| 283 | - | // | |||
| 284 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |||
| 285 | func (g *Git) HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error) { | 283 | func (g *Git) HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error) { | ||
| 286 | dir, err := g.repoDir(name) | 284 | dir, err := g.repoDir(name) | ||
| 287 | if err != nil { | 285 | if err != nil { | ||
M internal/adapters/gitcli/stats.go +0 -6
| @@ -18,8 +18,6 @@ import ( | |||||
| 18 | 18 | ||||
| 19 | // CountCommits runs `git rev-list --count`, from the commit graph when the | 19 | // CountCommits runs `git rev-list --count`, from the commit graph when the | ||
| 20 | // repository has one. | 20 | // repository has one. | ||
| 21 | - | // | |||
| 22 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints | |||
| 23 | func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) { | 21 | func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) { | ||
| 24 | if head.IsZero() { | 22 | if head.IsZero() { | ||
| 25 | return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid) | 23 | return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid) | ||
| @@ -45,8 +43,6 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai | |||||
| 45 | // CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter | 43 | // CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter | ||
| 46 | // looks at every reachable commit: --since stops the walk at the first older | 44 | // looks at every reachable commit: --since stops the walk at the first older | ||
| 47 | // one, and misses newer commits behind it when dates are out of order. | 45 | // one, and misses newer commits behind it when dates are out of order. | ||
| 48 | - | // | |||
| 49 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the dates git prints; the bound comes from the port | |||
| 50 | func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) { | 46 | func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) { | ||
| 51 | if head.IsZero() { | 47 | if head.IsZero() { | ||
| 52 | return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid) | 48 | return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid) | ||
| @@ -91,8 +87,6 @@ func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain | |||||
| 91 | } | 87 | } | ||
| 92 | 88 | ||||
| 93 | // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags. | 89 | // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags. | ||
| 94 | - | // | |||
| 95 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints | |||
| 96 | func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | 90 | func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | ||
| 97 | dir, err := g.repoDir(repo) | 91 | dir, err := g.repoDir(repo) | ||
| 98 | if err != nil { | 92 | if err != nil { | ||
D internal/adapters/gitcli/workflow_guard.go +0 -438
| @@ -1,438 +0,0 @@ | |||||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | - | ||||
| 3 | - | package gitcli | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "bufio" | |||
| 7 | - | "context" | |||
| 8 | - | "errors" | |||
| 9 | - | "fmt" | |||
| 10 | - | "io" | |||
| 11 | - | "path/filepath" | |||
| 12 | - | "strconv" | |||
| 13 | - | "strings" | |||
| 14 | - | ||||
| 15 | - | "bnema.dev/ermite/internal/domain" | |||
| 16 | - | "bnema.dev/ermite/internal/ports" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | var _ ports.WorkflowGuard = (*Git)(nil) | |||
| 20 | - | ||||
| 21 | - | // workflowsDir is where workflow files live, as a literal pathspec directory. | |||
| 22 | - | const workflowsDir = ".github/workflows/" | |||
| 23 | - | ||||
| 24 | - | // maxWorkflowPath bounds the one path the guard reads from git: a longer one | |||
| 25 | - | // fails closed (the push is refused), whatever the trees hold. | |||
| 26 | - | const maxWorkflowPath = 4096 | |||
| 27 | - | ||||
| 28 | - | // Bounds of the walk over the commits a push brings: more than that, or a | |||
| 29 | - | // commit with more parents than a line of rev-list holds, fails closed (the | |||
| 30 | - | // push is refused), whatever the commits hold. maxPushedObjects counts every | |||
| 31 | - | // commit and every parent listed, repeats included (so it bounds the distinct | |||
| 32 | - | // ids too), and is enforced as the lines are read, before anything more is | |||
| 33 | - | // kept: a few thousand commits with two hundred parents each are refused after | |||
| 34 | - | // the first lines, not after they have been held. | |||
| 35 | - | const ( | |||
| 36 | - | maxPushedCommits = 10000 | |||
| 37 | - | maxPushedObjects = 4 * maxPushedCommits | |||
| 38 | - | maxRevListLine = 8192 | |||
| 39 | - | ) | |||
| 40 | - | ||||
| 41 | - | // ChangedWorkflow tells whether a push changes the workflows of a repository. | |||
| 42 | - | // It returns the path of the first change, "" for none. Deleting a ref changes | |||
| 43 | - | // nothing, and a workflows directory that disappears is not a change. | |||
| 44 | - | // | |||
| 45 | - | // Two things are judged, as GitHub does for the token of a job (it refuses a | |||
| 46 | - | // push when the commits pushed change workflow files, not when a new branch is | |||
| 47 | - | // cut from a commit that has them): | |||
| 48 | - | // | |||
| 49 | - | // 1. Every commit the push brings (`rev-list <new> --not --all`, so the commits | |||
| 50 | - | // no ref has yet) must leave .github/workflows as it found it: it is | |||
| 51 | - | // compared with each of its parents (a root commit with nothing) and is a | |||
| 52 | - | // change when it is present and differs from every one of them. A merge of | |||
| 53 | - | // existing commits is thus no change; a side that brings a change is itself | |||
| 54 | - | // a new commit, and judged as one. | |||
| 55 | - | // 2. The directory a ref ends with must not be one it did not have: for an | |||
| 56 | - | // update the final .github/workflows must be the old one or absent, for a | |||
| 57 | - | // new ref the one of the default branch (HEAD) or absent. This keeps a job | |||
| 58 | - | // token from moving a ref onto existing commits that carry other workflows | |||
| 59 | - | // (an older commit, a commit of a branch with different ones). | |||
| 60 | - | // | |||
| 61 | - | // Presence is the directory the workflow loader lists (CI.workflowFiles, on both | |||
| 62 | - | // git readers): .github and workflows both entries of mode 040000 and type | |||
| 63 | - | // tree. A path through a gitlink (160000), a symbolic link (120000, which the | |||
| 64 | - | // loader does not follow) or a file is no workflows, so the same tree id that is | |||
| 65 | - | // no workflows as a gitlink and becomes workflows as a directory is a change, | |||
| 66 | - | // which the object id alone would not tell. Mode and id of | |||
| 67 | - | // "<commit>:.github/workflows" are what is compared: equal means the same | |||
| 68 | - | // content, whatever it holds, so nothing is read. | |||
| 69 | - | // | |||
| 70 | - | // Nothing is walked recursively: the trees of a push are the pusher's, and a | |||
| 71 | - | // recursive diff of trees that share subtrees fans out into more paths than the | |||
| 72 | - | // push has bytes (git builds the whole diff before it prints a line, so reading | |||
| 73 | - | // it as a stream would not help). The shallow diff that names the path reads one | |||
| 74 | - | // directory, as large as its tree object, as a stream, up to its first name, and | |||
| 75 | - | // git is stopped and reaped then; a name longer than maxWorkflowPath fails | |||
| 76 | - | // closed. | |||
| 77 | - | // | |||
| 78 | - | // The ends of each update are peeled first (`cat-file --batch-check` on | |||
| 79 | - | // `<oid>^{}`): a commit or a tree is compared, a blob (a tag may point to one) | |||
| 80 | - | // holds no workflows; a missing or malformed object fails closed. Git reads the | |||
| 81 | - | // objects of a push still in quarantine through the environment of the hook | |||
| 82 | - | // (ports.PushObjects), accepted only inside the repository's objects directory. | |||
| 83 | - | // | |||
| 84 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the object ids and the path git prints for the compared trees; the rule that refuses is the use case's | |||
| 85 | - | func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |||
| 86 | - | dir, err := g.repoDir(repo) | |||
| 87 | - | if err != nil { | |||
| 88 | - | return "", err | |||
| 89 | - | } | |||
| 90 | - | extra, err := quarantineEnv(dir, objects) | |||
| 91 | - | if err != nil { | |||
| 92 | - | return "", err | |||
| 93 | - | } | |||
| 94 | - | kept := make([]domain.RefUpdate, 0, len(updates)) | |||
| 95 | - | for _, up := range updates { | |||
| 96 | - | if !up.IsDelete() { | |||
| 97 | - | kept = append(kept, up) | |||
| 98 | - | } | |||
| 99 | - | } | |||
| 100 | - | if len(kept) == 0 { | |||
| 101 | - | return "", nil | |||
| 102 | - | } | |||
| 103 | - | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") | |||
| 104 | - | if err != nil { | |||
| 105 | - | return "", err | |||
| 106 | - | } | |||
| 107 | - | if p, err := g.changedByNewCommits(ctx, dir, extra, kept, types); p != "" || err != nil { | |||
| 108 | - | return p, err | |||
| 109 | - | } | |||
| 110 | - | trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)") | |||
| 111 | - | if err != nil { | |||
| 112 | - | return "", err | |||
| 113 | - | } | |||
| 114 | - | headW, headRead := "", false | |||
| 115 | - | for i, up := range kept { | |||
| 116 | - | if types[2*i+1] == "blob" { | |||
| 117 | - | continue // a blob holds no workflow, whatever it replaces | |||
| 118 | - | } | |||
| 119 | - | base := workflowsOf(types[2*i], trees[2*i]) | |||
| 120 | - | if up.IsCreate() { | |||
| 121 | - | if !headRead { | |||
| 122 | - | w, err := g.workflowsAt(ctx, dir, extra, []string{"HEAD:" + strings.TrimSuffix(workflowsDir, "/")}) | |||
| 123 | - | if err != nil { | |||
| 124 | - | return "", err | |||
| 125 | - | } | |||
| 126 | - | headW, headRead = w["HEAD:"+strings.TrimSuffix(workflowsDir, "/")], true | |||
| 127 | - | } | |||
| 128 | - | base = headW // a new ref may end with the workflows of the default branch | |||
| 129 | - | } | |||
| 130 | - | newW := workflowsOf(types[2*i+1], trees[2*i+1]) | |||
| 131 | - | if base == newW || newW == "" { | |||
| 132 | - | continue // the same directory, or none left | |||
| 133 | - | } | |||
| 134 | - | return g.firstChanged(ctx, dir, extra, base, newW) | |||
| 135 | - | } | |||
| 136 | - | return "", nil | |||
| 137 | - | } | |||
| 138 | - | ||||
| 139 | - | // changedByNewCommits judges the commits the updates bring (rule 1 of | |||
| 140 | - | // ChangedWorkflow). | |||
| 141 | - | func (g *Git) changedByNewCommits(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, types []string) (string, error) { | |||
| 142 | - | var tips []string | |||
| 143 | - | seen := map[string]bool{} | |||
| 144 | - | for i, up := range ups { | |||
| 145 | - | if types[2*i+1] == "commit" && !seen[up.New] { | |||
| 146 | - | seen[up.New] = true | |||
| 147 | - | tips = append(tips, up.New) | |||
| 148 | - | } | |||
| 149 | - | } | |||
| 150 | - | if len(tips) == 0 { | |||
| 151 | - | return "", nil | |||
| 152 | - | } | |||
| 153 | - | commits, err := g.newCommits(ctx, dir, extra, tips) | |||
| 154 | - | if err != nil { | |||
| 155 | - | return "", err | |||
| 156 | - | } | |||
| 157 | - | const suffix = ":.github/workflows" | |||
| 158 | - | var specs []string | |||
| 159 | - | asked := map[string]bool{} | |||
| 160 | - | for _, c := range commits { | |||
| 161 | - | for _, id := range append([]string{c.id}, c.parents...) { | |||
| 162 | - | if !asked[id] { | |||
| 163 | - | asked[id] = true | |||
| 164 | - | specs = append(specs, id+suffix) | |||
| 165 | - | } | |||
| 166 | - | } | |||
| 167 | - | } | |||
| 168 | - | ws, err := g.workflowsAt(ctx, dir, extra, specs) | |||
| 169 | - | if err != nil { | |||
| 170 | - | return "", err | |||
| 171 | - | } | |||
| 172 | - | for _, c := range commits { | |||
| 173 | - | cur := ws[c.id+suffix] | |||
| 174 | - | if cur == "" { | |||
| 175 | - | continue // none: nothing new is active | |||
| 176 | - | } | |||
| 177 | - | first, same := "", false | |||
| 178 | - | for i, p := range c.parents { | |||
| 179 | - | if i == 0 { | |||
| 180 | - | first = ws[p+suffix] | |||
| 181 | - | } | |||
| 182 | - | if ws[p+suffix] == cur { | |||
| 183 | - | same = true | |||
| 184 | - | } | |||
| 185 | - | } | |||
| 186 | - | if !same { | |||
| 187 | - | return g.firstChanged(ctx, dir, extra, first, cur) | |||
| 188 | - | } | |||
| 189 | - | } | |||
| 190 | - | return "", nil | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | type pushedCommit struct { | |||
| 194 | - | id string | |||
| 195 | - | parents []string | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // newCommits lists the commits reachable from the tips and from no ref | |||
| 199 | - | // (`rev-list --parents <tips> --not --all`), at most maxPushedCommits: more | |||
| 200 | - | // fails closed. | |||
| 201 | - | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string) ([]pushedCommit, error) { | |||
| 202 | - | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(maxPushedCommits+1)}, tips...), "--not", "--all")...) | |||
| 203 | - | var out []pushedCommit | |||
| 204 | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |||
| 205 | - | var err error | |||
| 206 | - | out, err = parseRevList(r, maxPushedCommits, maxPushedObjects) | |||
| 207 | - | return err | |||
| 208 | - | }) | |||
| 209 | - | if err != nil { | |||
| 210 | - | return nil, fmt.Errorf("commits of a push: %w", err) | |||
| 211 | - | } | |||
| 212 | - | return out, nil | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): | |||
| 216 | - | // at most maxCommits lines and maxIDs ids in all (a commit and each of its | |||
| 217 | - | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an | |||
| 218 | - | // id that is not one is an error, and it is raised as soon as the line that | |||
| 219 | - | // crosses it is read: nothing past it is read or kept. | |||
| 220 | - | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]pushedCommit, error) { | |||
| 221 | - | var out []pushedCommit | |||
| 222 | - | ids := 0 | |||
| 223 | - | for { | |||
| 224 | - | line, err := r.ReadSlice('\n') | |||
| 225 | - | switch { | |||
| 226 | - | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: | |||
| 227 | - | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) | |||
| 228 | - | case errors.Is(err, io.EOF) && len(line) == 0: | |||
| 229 | - | return out, nil | |||
| 230 | - | case err != nil && !errors.Is(err, io.EOF): | |||
| 231 | - | return nil, err | |||
| 232 | - | } | |||
| 233 | - | f := strings.Fields(string(line)) | |||
| 234 | - | if len(f) == 0 { | |||
| 235 | - | continue | |||
| 236 | - | } | |||
| 237 | - | if len(out) == maxCommits { | |||
| 238 | - | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) | |||
| 239 | - | } | |||
| 240 | - | if ids += len(f); ids > maxIDs { | |||
| 241 | - | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) | |||
| 242 | - | } | |||
| 243 | - | for _, id := range f { | |||
| 244 | - | if _, err := domain.ParseObjectID(id); err != nil { | |||
| 245 | - | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) | |||
| 246 | - | } | |||
| 247 | - | } | |||
| 248 | - | out = append(out, pushedCommit{id: f[0], parents: f[1:]}) | |||
| 249 | - | } | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | // workflowsAt returns, for each spec ("<rev>:.github/workflows"), the id of the | |||
| 253 | - | // directory it names, or "" when there is none (see workflowsOf). | |||
| 254 | - | func (g *Git) workflowsAt(ctx context.Context, dir string, extra []string, specs []string) (map[string]string, error) { | |||
| 255 | - | out := make(map[string]string, len(specs)) | |||
| 256 | - | if len(specs) == 0 { | |||
| 257 | - | return out, nil | |||
| 258 | - | } | |||
| 259 | - | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") | |||
| 260 | - | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { | |||
| 261 | - | for _, spec := range specs { | |||
| 262 | - | line, err := r.ReadSlice('\n') | |||
| 263 | - | if err != nil { | |||
| 264 | - | return fmt.Errorf("read object answers: %w", err) | |||
| 265 | - | } | |||
| 266 | - | out[spec] = workflowsOf("", strings.TrimSuffix(string(line), "\n")) | |||
| 267 | - | } | |||
| 268 | - | return nil | |||
| 269 | - | }) | |||
| 270 | - | if err != nil { | |||
| 271 | - | return nil, fmt.Errorf("workflows of a push: %w", err) | |||
| 272 | - | } | |||
| 273 | - | return out, nil | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | - | // workflowsOf is the id of the tree that is the .github/workflows of a commit | |||
| 277 | - | // or a tree, "" when there is none: the answer for "<it>:.github/workflows" must | |||
| 278 | - | // be a tree of mode 040000, and the object peeled to a tree-ish, not a blob. | |||
| 279 | - | func workflowsOf(objType, answer string) string { | |||
| 280 | - | if objType == "blob" { | |||
| 281 | - | return "" | |||
| 282 | - | } | |||
| 283 | - | mode, rest, _ := strings.Cut(answer, " ") | |||
| 284 | - | id, typ, _ := strings.Cut(rest, " ") | |||
| 285 | - | if mode != "040000" || typ != "tree" { | |||
| 286 | - | return "" | |||
| 287 | - | } | |||
| 288 | - | return id | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | // firstChanged names the first top-level entry of .github/workflows that | |||
| 292 | - | // differs between two trees (oldID may be "": none, the empty tree). | |||
| 293 | - | func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) { | |||
| 294 | - | from := oldID | |||
| 295 | - | if from == "" { | |||
| 296 | - | from = emptyTree | |||
| 297 | - | } | |||
| 298 | - | to := newID | |||
| 299 | - | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) | |||
| 300 | - | var name string | |||
| 301 | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |||
| 302 | - | n, found, err := firstPath(r) | |||
| 303 | - | name = n | |||
| 304 | - | if err != nil || !found { | |||
| 305 | - | return err // no name: git's exit status is checked, so a failure refuses | |||
| 306 | - | } | |||
| 307 | - | return errStop // the first name is all that is needed: stop git | |||
| 308 | - | }) | |||
| 309 | - | if err != nil { | |||
| 310 | - | return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err) | |||
| 311 | - | } | |||
| 312 | - | if name == "" { | |||
| 313 | - | return strings.TrimSuffix(workflowsDir, "/"), nil | |||
| 314 | - | } | |||
| 315 | - | return workflowsDir + name, nil | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | // firstPath reads the first NUL-terminated path of r; found is false at the end | |||
| 319 | - | // of the output without one. A path longer than maxWorkflowPath is an error. | |||
| 320 | - | func firstPath(r *bufio.Reader) (path string, found bool, err error) { | |||
| 321 | - | var b []byte | |||
| 322 | - | for { | |||
| 323 | - | c, err := r.ReadByte() | |||
| 324 | - | switch { | |||
| 325 | - | case err != nil: | |||
| 326 | - | return "", false, nil // the end of the output; a failing exit status is reported by pipe | |||
| 327 | - | case c == 0: | |||
| 328 | - | return string(b), true, nil | |||
| 329 | - | case len(b) >= maxWorkflowPath: | |||
| 330 | - | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath) | |||
| 331 | - | } | |||
| 332 | - | b = append(b, c) | |||
| 333 | - | } | |||
| 334 | - | } | |||
| 335 | - | ||||
| 336 | - | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the | |||
| 337 | - | // old (unless the ref is created) and the new end of each update and returns | |||
| 338 | - | // the answers in that order, two per update, "" for the old end of a created | |||
| 339 | - | // ref. With suffix "^{}" an object that is missing, malformed or neither a | |||
| 340 | - | // commit, a tree nor a blob is an error (the push is refused); with any other | |||
| 341 | - | // suffix an answer "<input> missing" is kept as the answer. | |||
| 342 | - | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { | |||
| 343 | - | var in strings.Builder | |||
| 344 | - | want := 0 | |||
| 345 | - | for _, up := range ups { | |||
| 346 | - | for _, oid := range []string{up.Old, up.New} { | |||
| 347 | - | if oid == domain.ZeroOID { | |||
| 348 | - | continue | |||
| 349 | - | } | |||
| 350 | - | if _, err := domain.ParseObjectID(oid); err != nil { | |||
| 351 | - | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) | |||
| 352 | - | } | |||
| 353 | - | in.WriteString(oid + suffix + "\n") | |||
| 354 | - | want++ | |||
| 355 | - | } | |||
| 356 | - | } | |||
| 357 | - | got := make([]string, 0, want) | |||
| 358 | - | args := readArgs(dir, "cat-file", "--batch-check="+format) | |||
| 359 | - | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { | |||
| 360 | - | for range want { | |||
| 361 | - | line, err := r.ReadSlice('\n') | |||
| 362 | - | if err != nil { | |||
| 363 | - | if errors.Is(err, bufio.ErrBufferFull) { | |||
| 364 | - | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) | |||
| 365 | - | } | |||
| 366 | - | return fmt.Errorf("read object answers: %w", err) | |||
| 367 | - | } | |||
| 368 | - | got = append(got, strings.TrimSuffix(string(line), "\n")) | |||
| 369 | - | } | |||
| 370 | - | return nil | |||
| 371 | - | }) | |||
| 372 | - | if err != nil { | |||
| 373 | - | return nil, fmt.Errorf("objects of a push: %w", err) | |||
| 374 | - | } | |||
| 375 | - | out := make([]string, 0, 2*len(ups)) | |||
| 376 | - | i := 0 | |||
| 377 | - | for _, up := range ups { | |||
| 378 | - | for _, oid := range []string{up.Old, up.New} { | |||
| 379 | - | if oid == domain.ZeroOID { | |||
| 380 | - | out = append(out, "") | |||
| 381 | - | continue | |||
| 382 | - | } | |||
| 383 | - | if suffix == "^{}" { | |||
| 384 | - | switch got[i] { | |||
| 385 | - | case "commit", "tree", "blob": | |||
| 386 | - | default: // "<input> missing", or a type that cannot be compared | |||
| 387 | - | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) | |||
| 388 | - | } | |||
| 389 | - | } | |||
| 390 | - | out = append(out, got[i]) | |||
| 391 | - | i++ | |||
| 392 | - | } | |||
| 393 | - | } | |||
| 394 | - | return out, nil | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | // quarantineEnv returns the environment that makes git read the objects of a | |||
| 398 | - | // push in quarantine. Each directory must exist and, once symbolic links are | |||
| 399 | - | // resolved, be inside the objects directory of the repository dir (itself | |||
| 400 | - | // resolved): a hook request cannot send git to read anything else. The resolved | |||
| 401 | - | // paths are the ones given to git. | |||
| 402 | - | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { | |||
| 403 | - | if o.Dir == "" && o.Alternates == "" { | |||
| 404 | - | return nil, nil | |||
| 405 | - | } | |||
| 406 | - | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) | |||
| 407 | - | if err != nil { | |||
| 408 | - | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) | |||
| 409 | - | } | |||
| 410 | - | inside := func(p string) (string, bool) { | |||
| 411 | - | if !filepath.IsAbs(p) { | |||
| 412 | - | return "", false | |||
| 413 | - | } | |||
| 414 | - | real, err := filepath.EvalSymlinks(p) | |||
| 415 | - | if err != nil { | |||
| 416 | - | return "", false | |||
| 417 | - | } | |||
| 418 | - | rel, err := filepath.Rel(root, real) | |||
| 419 | - | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |||
| 420 | - | } | |||
| 421 | - | dir, ok := inside(o.Dir) | |||
| 422 | - | if o.Dir == "" || !ok { | |||
| 423 | - | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) | |||
| 424 | - | } | |||
| 425 | - | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} | |||
| 426 | - | if o.Alternates != "" { | |||
| 427 | - | var alts []string | |||
| 428 | - | for _, a := range filepath.SplitList(o.Alternates) { | |||
| 429 | - | real, ok := inside(a) | |||
| 430 | - | if !ok { | |||
| 431 | - | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) | |||
| 432 | - | } | |||
| 433 | - | alts = append(alts, real) | |||
| 434 | - | } | |||
| 435 | - | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) | |||
| 436 | - | } | |||
| 437 | - | return env, nil | |||
| 438 | - | } | |||
M internal/adapters/githttp/handler.go +4 -4
| @@ -174,6 +174,8 @@ var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceRecei | |||||
| 174 | // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of | 174 | // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of | ||
| 175 | // the forge. info/refs without a service is the dumb protocol, which ermite | 175 | // the forge. info/refs without a service is the dumb protocol, which ermite | ||
| 176 | // does not serve. | 176 | // does not serve. | ||
| 177 | + | // | |||
| 178 | + | //hexcheck:ignore suspicious-business-logic-in-adapter decodes smart-HTTP repository routes and owner aliases, validating methods and service query parameters | |||
| 177 | func (h *Handler) parseRoute(r *http.Request) (route, int) { | 179 | func (h *Handler) parseRoute(r *http.Request) (route, int) { | ||
| 178 | p := strings.TrimPrefix(r.URL.Path, "/") | 180 | p := strings.TrimPrefix(r.URL.Path, "/") | ||
| 179 | var head, rest string | 181 | var head, rest string | ||
| @@ -224,8 +226,6 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) { | |||||
| 224 | } | 226 | } | ||
| 225 | 227 | ||||
| 226 | // ServeHTTP implements http.Handler. | 228 | // ServeHTTP implements http.Handler. | ||
| 227 | - | // | |||
| 228 | - | //hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases' | |||
| 229 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 229 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 230 | var rt route | 230 | var rt route | ||
| 231 | var name domain.RepoName | 231 | var name domain.RepoName | ||
| @@ -328,6 +328,8 @@ func credentials(r *http.Request) (secret string, present bool) { | |||||
| 328 | // denied answers an authorization failure. A caller without valid | 328 | // denied answers an authorization failure. A caller without valid | ||
| 329 | // credentials is asked for them wherever the repository does not show itself, | 329 | // credentials is asked for them wherever the repository does not show itself, | ||
| 330 | // which keeps a private repository and a missing one indistinguishable. | 330 | // which keeps a private repository and a missing one indistinguishable. | ||
| 331 | + | // | |||
| 332 | + | //hexcheck:ignore suspicious-business-logic-in-adapter implements the smart-HTTP protocol visibility/auth challenge policy (401 vs 404) for hidden or missing repositories | |||
| 331 | func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) { | 333 | func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) { | ||
| 332 | switch { | 334 | switch { | ||
| 333 | case errors.Is(err, domain.ErrUnauthenticated): | 335 | case errors.Is(err, domain.ErrUnauthenticated): | ||
| @@ -418,8 +420,6 @@ func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route, | |||||
| 418 | } | 420 | } | ||
| 419 | 421 | ||||
| 420 | // serveBackend runs git http-backend for an authorized request. | 422 | // serveBackend runs git http-backend for an authorized request. | ||
| 421 | - | // | |||
| 422 | - | //hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here | |||
| 423 | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) { | 423 | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) { | ||
| 424 | ctx := r.Context() | 424 | ctx := r.Context() | ||
| 425 | logger := zerowrap.FromCtx(ctx) | 425 | logger := zerowrap.FromCtx(ctx) | ||
M internal/adapters/githttp/hooks.go +0 -2
| @@ -175,8 +175,6 @@ func (s *HookServer) Close(ctx context.Context) { | |||||
| 175 | } | 175 | } | ||
| 176 | 176 | ||||
| 177 | // handle serves one connection: one request line, one reply line. | 177 | // handle serves one connection: one request line, one reply line. | ||
| 178 | - | // | |||
| 179 | - | //hexcheck:ignore suspicious-business-logic-in-adapter hook protocol dispatch: the decisions are the use cases' | |||
| 180 | func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | 178 | func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | ||
| 181 | log := zerowrap.FromCtx(ctx) | 179 | log := zerowrap.FromCtx(ctx) | ||
| 182 | _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) | 180 | _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) | ||
M internal/adapters/kvstore/ci.go +11 -18
| @@ -310,6 +310,9 @@ func decodeRun(item kv.Item) (domain.CIRun, error) { | |||||
| 310 | }, nil | 310 | }, nil | ||
| 311 | } | 311 | } | ||
| 312 | 312 | ||||
| 313 | + | // encodeJob serializes a job for storage. | |||
| 314 | + | // | |||
| 315 | + | //hexcheck:ignore suspicious-business-logic-in-adapter encodes a job as protobuf, rejecting an empty repository and omitting an absent token hash | |||
| 313 | func encodeJob(j domain.CIJob) ([]byte, error) { | 316 | func encodeJob(j domain.CIJob) ([]byte, error) { | ||
| 314 | if j.Repo.IsZero() { | 317 | if j.Repo.IsZero() { | ||
| 315 | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | 318 | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | ||
| @@ -346,7 +349,6 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||||
| 346 | return mustMarshal(pb), nil | 349 | return mustMarshal(pb), nil | ||
| 347 | } | 350 | } | ||
| 348 | 351 | ||||
| 349 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain job field by field: no rule is decided here | |||
| 350 | func decodeJob(item kv.Item) (domain.CIJob, error) { | 352 | func decodeJob(item kv.Item) (domain.CIJob, error) { | ||
| 351 | var pb storagepb.CIJob | 353 | var pb storagepb.CIJob | ||
| 352 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | 354 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | ||
| @@ -530,8 +532,6 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu | |||||
| 530 | } | 532 | } | ||
| 531 | 533 | ||||
| 532 | // Apply writes the records in one batch. | 534 | // Apply writes the records in one batch. | ||
| 533 | - | // | |||
| 534 | - | //hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived indexes under version preconditions: a storage concern, no policy | |||
| 535 | func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) { | 535 | func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) { | ||
| 536 | var b kv.Batch | 536 | var b kv.Batch | ||
| 537 | guarded, err := c.stageRecords(ctx, &b, w) | 537 | guarded, err := c.stageRecords(ctx, &b, w) | ||
| @@ -576,18 +576,14 @@ func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) { | |||||
| 576 | 576 | ||||
| 577 | const createRunTries = 20 | 577 | const createRunTries = 20 | ||
| 578 | 578 | ||||
| 579 | - | // CreateRun takes the next run id and number and stores the draft. | 579 | + | // CreateRun takes the next run id and number and stores the draft. The batch |
| 580 | + | // requires the workflow's disabled key absent, so a disable that races the | |||
| 581 | + | // caller's check fails the batch and is reported as domain.ErrWorkflowDisabled. | |||
| 580 | // | 582 | // | ||
| 581 | - | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy | 583 | + | //hexcheck:ignore suspicious-business-logic-in-adapter atomic precondition only: maps a conflict on the disabled-workflow key to its domain error; the usecase decides |
| 582 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | 584 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | ||
| 583 | offKey := workflowOffKey(repo, workflowPath) | 585 | offKey := workflowOffKey(repo, workflowPath) | ||
| 584 | for range createRunTries { | 586 | for range createRunTries { | ||
| 585 | - | // A workflow the repository disabled gets no run, whatever started it. | |||
| 586 | - | // The key is also required absent by the batch below, so a disable that | |||
| 587 | - | // lands after this read fails the batch, and the next try sees it. | |||
| 588 | - | if _, off := c.db.Get(ctx, offKey); off { | |||
| 589 | - | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |||
| 590 | - | } | |||
| 591 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | 587 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | ||
| 592 | if err != nil { | 588 | if err != nil { | ||
| 593 | return ports.CIRunDraft{}, err | 589 | return ports.CIRunDraft{}, err | ||
| @@ -620,6 +616,10 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||||
| 620 | } | 616 | } | ||
| 621 | v, err := c.db.Apply(ctx, &b) | 617 | v, err := c.db.Apply(ctx, &b) | ||
| 622 | if errors.Is(err, kv.ErrConflict) { | 618 | if errors.Is(err, kv.ErrConflict) { | ||
| 619 | + | // Translate a failed disabled-workflow precondition; other conflicts retry. | |||
| 620 | + | if _, off := c.db.Get(ctx, offKey); off { | |||
| 621 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |||
| 622 | + | } | |||
| 623 | continue | 623 | continue | ||
| 624 | } | 624 | } | ||
| 625 | if err != nil { | 625 | if err != nil { | ||
| @@ -669,8 +669,6 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, | |||||
| 669 | } | 669 | } | ||
| 670 | 670 | ||||
| 671 | // ListRuns returns the runs of the repository, newest first. | 671 | // ListRuns returns the runs of the repository, newest first. | ||
| 672 | - | // | |||
| 673 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only: reads a key range and reverses it | |||
| 674 | func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) { | 672 | func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) { | ||
| 675 | start := []byte(runsPrefix(repo)) | 673 | start := []byte(runsPrefix(repo)) | ||
| 676 | end := []byte(runPrefix + repo.String() + "0") // '/' + 1 | 674 | end := []byte(runPrefix + repo.String() + "0") // '/' + 1 | ||
| @@ -906,8 +904,6 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by | |||||
| 906 | const sweepChunk = 500 | 904 | const sweepChunk = 500 | ||
| 907 | 905 | ||||
| 908 | // Sweep removes the runs completed before the time. | 906 | // Sweep removes the runs completed before the time. | ||
| 909 | - | // | |||
| 910 | - | //hexcheck:ignore suspicious-business-logic-in-adapter chunked delete of expired records: a storage concern, the retention is decided by the caller | |||
| 911 | func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) { | 907 | func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) { | ||
| 912 | snap := c.db.Snapshot(ctx) | 908 | snap := c.db.Snapshot(ctx) | ||
| 913 | var ids []int64 | 909 | var ids []int64 | ||
| @@ -955,8 +951,6 @@ func runnerNameKey(n string) []byte { return []byte(runnerName + stri | |||||
| 955 | func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) } | 951 | func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) } | ||
| 956 | 952 | ||||
| 957 | // CreateRunner stores the runner, its name index and its token index. | 953 | // CreateRunner stores the runner, its name index and its token index. | ||
| 958 | - | // | |||
| 959 | - | //hexcheck:ignore suspicious-business-logic-in-adapter replaces a runner of the same name in one batch: a storage concern, the rule is decided by the caller | |||
| 960 | func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) { | 954 | func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) { | ||
| 961 | for range createRunTries { | 955 | for range createRunTries { | ||
| 962 | var b kv.Batch | 956 | var b kv.Batch | ||
| @@ -1115,7 +1109,6 @@ func encodeCall(c *domain.CICall) *storagepb.CICall { | |||||
| 1115 | } | 1109 | } | ||
| 1116 | } | 1110 | } | ||
| 1117 | 1111 | ||||
| 1118 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored call to the domain call field by field: no rule is decided here | |||
| 1119 | func decodeCall(pb *storagepb.CICall) *domain.CICall { | 1112 | func decodeCall(pb *storagepb.CICall) *domain.CICall { | ||
| 1120 | if pb == nil { | 1113 | if pb == nil { | ||
| 1121 | return nil | 1114 | return nil | ||
M internal/adapters/kvstore/ci_artifact.go +3 -5
| @@ -79,6 +79,9 @@ var artifactStates = []struct { | |||||
| 79 | {domain.CIArtifactFinal, storagepb.CIArtifactState_CI_ARTIFACT_STATE_FINAL}, | 79 | {domain.CIArtifactFinal, storagepb.CIArtifactState_CI_ARTIFACT_STATE_FINAL}, | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | // encodeArtifact serializes an artifact for storage. | |||
| 83 | + | // | |||
| 84 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates artifact storage identifiers, UTF-8 and enum encoding before protobuf serialization | |||
| 82 | func encodeArtifact(a domain.CIArtifact) ([]byte, error) { | 85 | func encodeArtifact(a domain.CIArtifact) ([]byte, error) { | ||
| 83 | if a.Repo.IsZero() || a.ID < 1 || a.RunID < 1 { | 86 | if a.Repo.IsZero() || a.ID < 1 || a.RunID < 1 { | ||
| 84 | return nil, fmt.Errorf("%w: artifact without id, run or repository", domain.ErrInvalid) | 87 | return nil, fmt.Errorf("%w: artifact without id, run or repository", domain.ErrInvalid) | ||
| @@ -106,7 +109,6 @@ func encodeArtifact(a domain.CIArtifact) ([]byte, error) { | |||||
| 106 | return mustMarshal(pb), nil | 109 | return mustMarshal(pb), nil | ||
| 107 | } | 110 | } | ||
| 108 | 111 | ||||
| 109 | - | //hexcheck:ignore suspicious-business-logic-in-adapter decodes a record into the domain type: a storage concern, no policy | |||
| 110 | func decodeArtifact(item kv.Item) (domain.CIArtifact, error) { | 112 | func decodeArtifact(item kv.Item) (domain.CIArtifact, error) { | ||
| 111 | var pb storagepb.CIArtifact | 113 | var pb storagepb.CIArtifact | ||
| 112 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | 114 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | ||
| @@ -141,8 +143,6 @@ func decodeArtifact(item kv.Item) (domain.CIArtifact, error) { | |||||
| 141 | const artifactIDTries = 1000 | 143 | const artifactIDTries = 1000 | ||
| 142 | 144 | ||||
| 143 | // NextArtifactID takes the next id in its own batch. | 145 | // NextArtifactID takes the next id in its own batch. | ||
| 144 | - | // | |||
| 145 | - | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |||
| 146 | func (c *CIArtifacts) NextArtifactID(ctx context.Context) (int64, error) { | 146 | func (c *CIArtifacts) NextArtifactID(ctx context.Context) (int64, error) { | ||
| 147 | ci := &CI{db: c.db} | 147 | ci := &CI{db: c.db} | ||
| 148 | for range artifactIDTries { | 148 | for range artifactIDTries { | ||
| @@ -274,8 +274,6 @@ func (c *CIArtifacts) BlockArtifactIDs(ctx context.Context) ([]int64, error) { | |||||
| 274 | // --- writes --- | 274 | // --- writes --- | ||
| 275 | 275 | ||||
| 276 | // ApplyArtifacts writes w in one batch. | 276 | // ApplyArtifacts writes w in one batch. | ||
| 277 | - | // | |||
| 278 | - | //hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived keys under version preconditions: a storage concern, no policy | |||
| 279 | func (c *CIArtifacts) ApplyArtifacts(ctx context.Context, w ports.CIArtifactWrite) error { | 277 | func (c *CIArtifacts) ApplyArtifacts(ctx context.Context, w ports.CIArtifactWrite) error { | ||
| 280 | var b kv.Batch | 278 | var b kv.Batch | ||
| 281 | guard := false | 279 | guard := false | ||
M internal/adapters/kvstore/ci_concurrency.go +0 -4
| @@ -103,7 +103,6 @@ func encodeConcurrencyRecord(r domain.CIConcurrencyRecord) []byte { | |||||
| 103 | return mustMarshal(pb) | 103 | return mustMarshal(pb) | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain record field by field: no rule is decided here | |||
| 107 | func decodeConcurrencyRecord(val []byte) (domain.CIConcurrencyRecord, error) { | 106 | func decodeConcurrencyRecord(val []byte) (domain.CIConcurrencyRecord, error) { | ||
| 108 | var pb storagepb.CIConcurrencyRecord | 107 | var pb storagepb.CIConcurrencyRecord | ||
| 109 | if err := proto.Unmarshal(val, &pb); err != nil { | 108 | if err := proto.Unmarshal(val, &pb); err != nil { | ||
| @@ -146,7 +145,6 @@ func encodeConcurrencyMark(m domain.CIConcurrencyMark) []byte { | |||||
| 146 | return mustMarshal(pb) | 145 | return mustMarshal(pb) | ||
| 147 | } | 146 | } | ||
| 148 | 147 | ||||
| 149 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored mark to the domain mark field by field: no rule is decided here | |||
| 150 | func decodeConcurrencyMark(val []byte) (domain.CIConcurrencyMark, error) { | 148 | func decodeConcurrencyMark(val []byte) (domain.CIConcurrencyMark, error) { | ||
| 151 | var pb storagepb.CIConcurrencyMark | 149 | var pb storagepb.CIConcurrencyMark | ||
| 152 | if err := proto.Unmarshal(val, &pb); err != nil { | 150 | if err := proto.Unmarshal(val, &pb); err != nil { | ||
| @@ -257,8 +255,6 @@ func (c *CI) UpdateConcurrency(ctx context.Context, repo domain.RepoName, group | |||||
| 257 | } | 255 | } | ||
| 258 | 256 | ||||
| 259 | // ListConcurrency returns every stored group with its marks. | 257 | // ListConcurrency returns every stored group with its marks. | ||
| 260 | - | // | |||
| 261 | - | //hexcheck:ignore suspicious-business-logic-in-adapter lists and decodes records and marks: no rule is decided here | |||
| 262 | func (c *CI) ListConcurrency(ctx context.Context) ([]ports.CIConcurrencyGroup, error) { | 258 | func (c *CI) ListConcurrency(ctx context.Context) ([]ports.CIConcurrencyGroup, error) { | ||
| 263 | type id struct{ repo, group string } | 259 | type id struct{ repo, group string } | ||
| 264 | snap := c.db.Snapshot(ctx) | 260 | snap := c.db.Snapshot(ctx) | ||
M internal/adapters/kvstore/ci_settings.go +6 -4
| @@ -85,6 +85,8 @@ func settingsKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) | |||||
| 85 | 85 | ||||
| 86 | // BindSecretsKey stores the key check when there is none and compares it | 86 | // BindSecretsKey stores the key check when there is none and compares it | ||
| 87 | // otherwise. | 87 | // otherwise. | ||
| 88 | + | // | |||
| 89 | + | //hexcheck:ignore suspicious-business-logic-in-adapter initializes or compares the persisted encryption-key check with compare-and-swap retries | |||
| 88 | func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error { | 90 | func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error { | ||
| 89 | key := []byte(secretsKeyCheck) | 91 | key := []byte(secretsKeyCheck) | ||
| 90 | for range updateAttempts { | 92 | for range updateAttempts { | ||
| @@ -111,6 +113,8 @@ func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error { | |||||
| 111 | 113 | ||||
| 112 | // stage makes the batch depend on the scope: the repository key is guarded | 114 | // stage makes the batch depend on the scope: the repository key is guarded | ||
| 113 | // and, for an environment, its record is required at the version read. | 115 | // and, for an environment, its record is required at the version read. | ||
| 116 | + | // | |||
| 117 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates a settings scope and adds repository and environment existence/version preconditions to a storage batch | |||
| 114 | func (s *Settings) stage(ctx context.Context, b *kv.Batch, scope domain.SettingsScope) error { | 118 | func (s *Settings) stage(ctx context.Context, b *kv.Batch, scope domain.SettingsScope) error { | ||
| 115 | if !scope.Valid() { | 119 | if !scope.Valid() { | ||
| 116 | return fmt.Errorf("%w: scope", domain.ErrInvalid) | 120 | return fmt.Errorf("%w: scope", domain.ErrInvalid) | ||
| @@ -203,6 +207,8 @@ func (s *Settings) GetSealedSecret(ctx context.Context, scope domain.SettingsSco | |||||
| 203 | } | 207 | } | ||
| 204 | 208 | ||||
| 205 | // existsIn is exists on a snapshot, so a listing and its check agree. | 209 | // existsIn is exists on a snapshot, so a listing and its check agree. | ||
| 210 | + | // | |||
| 211 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates a settings scope and checks repository and environment keys in the same snapshot as a listing | |||
| 206 | func (s *Settings) existsIn(ctx context.Context, snap *kv.Snapshot, scope domain.SettingsScope) error { | 212 | func (s *Settings) existsIn(ctx context.Context, snap *kv.Snapshot, scope domain.SettingsScope) error { | ||
| 207 | if !scope.Valid() { | 213 | if !scope.Valid() { | ||
| 208 | return fmt.Errorf("%w: scope", domain.ErrInvalid) | 214 | return fmt.Errorf("%w: scope", domain.ErrInvalid) | ||
| @@ -309,8 +315,6 @@ func decodeEnvironment(val []byte) (domain.Environment, error) { | |||||
| 309 | } | 315 | } | ||
| 310 | 316 | ||||
| 311 | // CreateEnvironment implements ports.CISettingsStore. | 317 | // CreateEnvironment implements ports.CISettingsStore. | ||
| 312 | - | // | |||
| 313 | - | //hexcheck:ignore suspicious-business-logic-in-adapter create-if-absent under a version precondition: a storage concern, no policy | |||
| 314 | func (s *Settings) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) { | 318 | func (s *Settings) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) { | ||
| 315 | key := envKey(repo, name) | 319 | key := envKey(repo, name) | ||
| 316 | for range updateAttempts { | 320 | for range updateAttempts { | ||
| @@ -353,8 +357,6 @@ func (s *Settings) ListEnvironments(ctx context.Context, repo domain.RepoName) ( | |||||
| 353 | } | 357 | } | ||
| 354 | 358 | ||||
| 355 | // DeleteEnvironment implements ports.CISettingsStore. | 359 | // DeleteEnvironment implements ports.CISettingsStore. | ||
| 356 | - | // | |||
| 357 | - | //hexcheck:ignore suspicious-business-logic-in-adapter removes the keys of an environment in one batch: a storage concern, no policy | |||
| 358 | func (s *Settings) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error { | 360 | func (s *Settings) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error { | ||
| 359 | key := envKey(repo, name) | 361 | key := envKey(repo, name) | ||
| 360 | scope := domain.EnvironmentScope(repo, name) | 362 | scope := domain.EnvironmentScope(repo, name) | ||
M internal/adapters/kvstore/issues.go +0 -8
| @@ -84,8 +84,6 @@ func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][ | |||||
| 84 | // Create takes the next number of the repository from mrseq/<repo>, the | 84 | // Create takes the next number of the repository from mrseq/<repo>, the | ||
| 85 | // sequence of the merge requests, and stores the issue under it; see | 85 | // sequence of the merge requests, and stores the issue under it; see | ||
| 86 | // createNumbered. A missing repository gives domain.ErrNotFound. | 86 | // createNumbered. A missing repository gives domain.ErrNotFound. | ||
| 87 | - | // | |||
| 88 | - | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |||
| 89 | func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) { | 87 | func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) { | ||
| 90 | err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error { | 88 | err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error { | ||
| 91 | issue.Number = n | 89 | issue.Number = n | ||
| @@ -118,8 +116,6 @@ func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (d | |||||
| 118 | 116 | ||||
| 119 | // List returns a page of the issues of the repository, highest number first. The | 117 | // List returns a page of the issues of the repository, highest number first. The | ||
| 120 | // page token is the last number of the previous page. | 118 | // page token is the last number of the previous page. | ||
| 121 | - | // | |||
| 122 | - | //hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy | |||
| 123 | func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) { | 119 | func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) { | ||
| 124 | before := int64(0) // 0: from the highest | 120 | before := int64(0) // 0: from the highest | ||
| 125 | if params.PageToken != "" { | 121 | if params.PageToken != "" { | ||
| @@ -225,8 +221,6 @@ func (i *Issues) ListOpenAll(ctx context.Context) ([]domain.Issue, error) { | |||||
| 225 | // Update replaces the issue with a version precondition and moves its index key | 221 | // Update replaces the issue with a version precondition and moves its index key | ||
| 226 | // in the same batch. The repository must exist (see guardRepoKey), so that a | 222 | // in the same batch. The repository must exist (see guardRepoKey), so that a | ||
| 227 | // concurrent delete of the repository leaves no index key behind. | 223 | // concurrent delete of the repository leaves no index key behind. | ||
| 228 | - | // | |||
| 229 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |||
| 230 | func (i *Issues) Update(ctx context.Context, issue domain.Issue) error { | 224 | func (i *Issues) Update(ctx context.Context, issue domain.Issue) error { | ||
| 231 | val, err := encodeIssue(issue) | 225 | val, err := encodeIssue(issue) | ||
| 232 | if err != nil { | 226 | if err != nil { | ||
| @@ -330,7 +324,6 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||||
| 330 | }), nil | 324 | }), nil | ||
| 331 | } | 325 | } | ||
| 332 | 326 | ||||
| 333 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 334 | func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | 327 | func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | ||
| 335 | var pb storagepb.Issue | 328 | var pb storagepb.Issue | ||
| 336 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | 329 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | ||
| @@ -373,7 +366,6 @@ func encodeIssueComment(c domain.IssueComment) ([]byte, error) { | |||||
| 373 | }), nil | 366 | }), nil | ||
| 374 | } | 367 | } | ||
| 375 | 368 | ||||
| 376 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 377 | func decodeIssueComment(item kv.Item) (domain.IssueComment, error) { | 369 | func decodeIssueComment(item kv.Item) (domain.IssueComment, error) { | ||
| 378 | var pb storagepb.IssueComment | 370 | var pb storagepb.IssueComment | ||
| 379 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | 371 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | ||
M internal/adapters/kvstore/issues_pending.go +0 -5
| @@ -94,8 +94,6 @@ func (s *PendingIssues) Get(ctx context.Context, repo domain.RepoName, id domain | |||||
| 94 | // creation time then repository then id. There are at most | 94 | // creation time then repository then id. There are at most | ||
| 95 | // domain.MaxPendingIssues of them (a little more under a race), so they are | 95 | // domain.MaxPendingIssues of them (a little more under a race), so they are | ||
| 96 | // read and sorted. The page token is the position of the last item returned. | 96 | // read and sorted. The page token is the position of the last item returned. | ||
| 97 | - | // | |||
| 98 | - | //hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy | |||
| 99 | func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) { | 97 | func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) { | ||
| 100 | prefix := pendingPrefix | 98 | prefix := pendingPrefix | ||
| 101 | if !repo.IsZero() { | 99 | if !repo.IsZero() { | ||
| @@ -216,8 +214,6 @@ func (s *PendingIssues) Count(ctx context.Context) (int, error) { | |||||
| 216 | // Approve numbers the pending issue from mrseq/<repo> (see createNumbered), | 214 | // Approve numbers the pending issue from mrseq/<repo> (see createNumbered), | ||
| 217 | // writes the open issue and its index key and deletes the pending record, in | 215 | // writes the open issue and its index key and deletes the pending record, in | ||
| 218 | // one batch that requires the pending record to be unchanged. | 216 | // one batch that requires the pending record to be unchanged. | ||
| 219 | - | // | |||
| 220 | - | //hexcheck:ignore suspicious-business-logic-in-adapter only sets the number under a version precondition; the issue itself is built by domain.ApprovedIssue | |||
| 221 | func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) { | 217 | func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) { | ||
| 222 | var number int64 | 218 | var number int64 | ||
| 223 | err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error { | 219 | err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error { | ||
| @@ -291,7 +287,6 @@ func encodePending(p domain.PendingIssue) ([]byte, error) { | |||||
| 291 | }), nil | 287 | }), nil | ||
| 292 | } | 288 | } | ||
| 293 | 289 | ||||
| 294 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 295 | func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) { | 290 | func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) { | ||
| 296 | var pb storagepb.PendingIssue | 291 | var pb storagepb.PendingIssue | ||
| 297 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | 292 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | ||
M internal/adapters/kvstore/mergerequests.go +3 -9
| @@ -96,6 +96,8 @@ func indexKey(prefix string, repo domain.RepoName, branch string, n int64) []byt | |||||
| 96 | // when old is not known, as for a new request or a comment: the keys of mr are | 96 | // when old is not known, as for a new request or a comment: the keys of mr are | ||
| 97 | // then put or deleted, whatever they were). Deleting a key that is not there is | 97 | // then put or deleted, whatever they were). Deleting a key that is not there is | ||
| 98 | // not an error. | 98 | // not an error. | ||
| 99 | + | // | |||
| 100 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maintains source and target indexes to match the stored merge request open state and branch names | |||
| 99 | func indexOpen(b *kv.Batch, old *domain.MergeRequest, mr domain.MergeRequest) { | 101 | func indexOpen(b *kv.Batch, old *domain.MergeRequest, mr domain.MergeRequest) { | ||
| 100 | if old != nil && old.IsOpen() { | 102 | if old != nil && old.IsOpen() { | ||
| 101 | if !mr.IsOpen() || old.Source != mr.Source { | 103 | if !mr.IsOpen() || old.Source != mr.Source { | ||
| @@ -204,8 +206,6 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain. | |||||
| 204 | // rewrites it (see guardRepo), so that a repository deleted meanwhile is not | 206 | // rewrites it (see guardRepo), so that a repository deleted meanwhile is not | ||
| 205 | // left with a stray merge request (a push recording pushed_at makes it retry). A missing repository gives | 207 | // left with a stray merge request (a push recording pushed_at makes it retry). A missing repository gives | ||
| 206 | // domain.ErrNotFound. | 208 | // domain.ErrNotFound. | ||
| 207 | - | // | |||
| 208 | - | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |||
| 209 | func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) { | 209 | func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) { | ||
| 210 | err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error { | 210 | err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error { | ||
| 211 | mr.Number = n | 211 | mr.Number = n | ||
| @@ -451,7 +451,7 @@ func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (in | |||||
| 451 | 451 | ||||
| 452 | // Update replaces the merge request with a version precondition. | 452 | // Update replaces the merge request with a version precondition. | ||
| 453 | // | 453 | // | ||
| 454 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | 454 | + | //hexcheck:ignore suspicious-business-logic-in-adapter replaces a versioned record and maintains open-request indexes and the derived mergeability record |
| 455 | func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) error { | 455 | func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) error { | ||
| 456 | val, err := encodeMergeRequest(mr) | 456 | val, err := encodeMergeRequest(mr) | ||
| 457 | if err != nil { | 457 | if err != nil { | ||
| @@ -564,8 +564,6 @@ func (m *MergeRequests) GetRead(ctx context.Context, repo domain.RepoName, numbe | |||||
| 564 | 564 | ||||
| 565 | // SetRead writes mrread/<repo>/<number>. The repository must exist (see | 565 | // SetRead writes mrread/<repo>/<number>. The repository must exist (see | ||
| 566 | // guardRepo): domain.ErrNotFound otherwise. | 566 | // guardRepo): domain.ErrNotFound otherwise. | ||
| 567 | - | // | |||
| 568 | - | //hexcheck:ignore suspicious-business-logic-in-adapter retries a write that lost a race with the repository key: a storage concern, no policy | |||
| 569 | func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error { | 567 | func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error { | ||
| 570 | if head.IsZero() { | 568 | if head.IsZero() { | ||
| 571 | return fmt.Errorf("%w: empty read position", domain.ErrInvalidObjectID) | 569 | return fmt.Errorf("%w: empty read position", domain.ErrInvalidObjectID) | ||
| @@ -748,7 +746,6 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | |||||
| 748 | }), nil | 746 | }), nil | ||
| 749 | } | 747 | } | ||
| 750 | 748 | ||||
| 751 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 752 | func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest, error) { | 749 | func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest, error) { | ||
| 753 | var pb storagepb.MergeRequest | 750 | var pb storagepb.MergeRequest | ||
| 754 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | 751 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | ||
| @@ -815,7 +812,6 @@ func encodeComment(c domain.Comment) ([]byte, error) { | |||||
| 815 | }), nil | 812 | }), nil | ||
| 816 | } | 813 | } | ||
| 817 | 814 | ||||
| 818 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 819 | func decodeComment(item kv.Item) (domain.Comment, error) { | 815 | func decodeComment(item kv.Item) (domain.Comment, error) { | ||
| 820 | var pb storagepb.Comment | 816 | var pb storagepb.Comment | ||
| 821 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | 817 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | ||
| @@ -931,8 +927,6 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | |||||
| 931 | 927 | ||||
| 932 | // decodeSettings returns the defaults for a repository stored before merge | 928 | // decodeSettings returns the defaults for a repository stored before merge | ||
| 933 | // requests existed, and for settings left with no mode that this build knows. | 929 | // requests existed, and for settings left with no mode that this build knows. | ||
| 934 | - | // | |||
| 935 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding with the documented defaults | |||
| 936 | func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | 930 | func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | ||
| 937 | if pb == nil { | 931 | if pb == nil { | ||
| 938 | return domain.DefaultRepoSettings() | 932 | return domain.DefaultRepoSettings() | ||
M internal/adapters/kvstore/passkeys.go +0 -2
| @@ -134,8 +134,6 @@ func (s *Passkeys) Create(ctx context.Context, p domain.Passkey) error { | |||||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | // Update replaces a stored passkey, or returns domain.ErrNotFound. | 136 | // Update replaces a stored passkey, or returns domain.ErrNotFound. | ||
| 137 | - | // | |||
| 138 | - | //hexcheck:ignore suspicious-business-logic-in-adapter version-checked replace, not a policy | |||
| 139 | func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error { | 137 | func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error { | ||
| 140 | val, err := encodePasskey(p) | 138 | val, err := encodePasskey(p) | ||
| 141 | if err != nil { | 139 | if err != nil { | ||
M internal/adapters/kvstore/repos.go +2 -10
| @@ -67,8 +67,6 @@ func (s *Store) List(ctx context.Context) ([]domain.Repo, error) { | |||||
| 67 | const repoMutationTries = 8 | 67 | const repoMutationTries = 8 | ||
| 68 | 68 | ||||
| 69 | // Mutate applies a retry-safe mutation under the version it read. | 69 | // Mutate applies a retry-safe mutation under the version it read. | ||
| 70 | - | // | |||
| 71 | - | //hexcheck:ignore suspicious-business-logic-in-adapter optimistic concurrency is a storage concern | |||
| 72 | func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { | 70 | func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { | ||
| 73 | key := repoKey(name) | 71 | key := repoKey(name) | ||
| 74 | for range repoMutationTries { | 72 | for range repoMutationTries { | ||
| @@ -158,8 +156,6 @@ const deleteTries = 32 | |||||
| 158 | // got in after the listing makes the batch fail and the keys are listed again, | 156 | // got in after the listing makes the batch fail and the keys are listed again, | ||
| 159 | // and a write after the batch finds no repository: no key outlives the | 157 | // and a write after the batch finds no repository: no key outlives the | ||
| 160 | // repository to attach itself to a repository created again. | 158 | // repository to attach itself to a repository created again. | ||
| 161 | - | // | |||
| 162 | - | //hexcheck:ignore suspicious-business-logic-in-adapter chunked delete under a version precondition: a storage concern, no policy | |||
| 163 | func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | 159 | func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | ||
| 164 | for range deleteTries { | 160 | for range deleteTries { | ||
| 165 | snap := s.db.Snapshot(ctx) | 161 | snap := s.db.Snapshot(ctx) | ||
| @@ -211,12 +207,8 @@ func encodeRepo(r domain.Repo) ([]byte, error) { | |||||
| 211 | if err != nil { | 207 | if err != nil { | ||
| 212 | return nil, err | 208 | return nil, err | ||
| 213 | } | 209 | } | ||
| 214 | - | settings := r.Settings | 210 | + | // Store defaults explicitly so future default changes do not alter stored repositories. |
| 215 | - | if settings.IsZero() { | 211 | + | settings := r.EffectiveSettings() |
| 216 | - | // Settings are always stored explicitly, so that a later change of | |||
| 217 | - | // the defaults never alters a stored repository. | |||
| 218 | - | settings = domain.DefaultRepoSettings() | |||
| 219 | - | } | |||
| 220 | pbSettings, err := encodeSettings(settings) | 212 | pbSettings, err := encodeSettings(settings) | ||
| 221 | if err != nil { | 213 | if err != nil { | ||
| 222 | return nil, err | 214 | return nil, err | ||
M internal/adapters/kvstore/store.go +0 -2
| @@ -186,8 +186,6 @@ var preIssueScopes = []domain.Scope{ | |||||
| 186 | // for, so it gets each of them if missing (this is no escalation). Every other | 186 | // for, so it gets each of them if missing (this is no escalation). Every other | ||
| 187 | // token is left as it is. The batch requires each token to be at the version it | 187 | // token is left as it is. The batch requires each token to be at the version it | ||
| 188 | // was read at. | 188 | // was read at. | ||
| 189 | - | // | |||
| 190 | - | //hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy | |||
| 191 | func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error { | 189 | func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error { | ||
| 192 | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) { | 190 | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) { | ||
| 193 | pb := &storagepb.Token{} | 191 | pb := &storagepb.Token{} | ||
M internal/adapters/kvstore/tokens.go +0 -6
| @@ -153,8 +153,6 @@ func (s *Tokens) Revoke(ctx context.Context, id string) error { | |||||
| 153 | 153 | ||||
| 154 | // Touch sets last_used_at when the given time is later than the stored one, | 154 | // Touch sets last_used_at when the given time is later than the stored one, | ||
| 155 | // so that racing writers never move it back. | 155 | // so that racing writers never move it back. | ||
| 156 | - | // | |||
| 157 | - | //hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy | |||
| 158 | func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | 156 | func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | ||
| 159 | if !validTokenID(id) { | 157 | if !validTokenID(id) { | ||
| 160 | return domain.ErrNotFound | 158 | return domain.ErrNotFound | ||
| @@ -194,8 +192,6 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | |||||
| 194 | // encodeToken refuses what the use case already refused (scopes, branch | 192 | // encodeToken refuses what the use case already refused (scopes, branch | ||
| 195 | // prefixes): a malformed prefix must never reach the store, where it would | 193 | // prefixes): a malformed prefix must never reach the store, where it would | ||
| 196 | // limit a token wrongly or not at all. | 194 | // limit a token wrongly or not at all. | ||
| 197 | - | // | |||
| 198 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy | |||
| 199 | func encodeToken(t domain.Token) ([]byte, error) { | 195 | func encodeToken(t domain.Token) ([]byte, error) { | ||
| 200 | if !validTokenID(t.ID) { | 196 | if !validTokenID(t.ID) { | ||
| 201 | return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID) | 197 | return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID) | ||
| @@ -237,8 +233,6 @@ func encodeToken(t domain.Token) ([]byte, error) { | |||||
| 237 | 233 | ||||
| 238 | // decodeToken drops scopes it does not know (written by a newer ermite): a | 234 | // decodeToken drops scopes it does not know (written by a newer ermite): a | ||
| 239 | // token never gains rights by being read by an older build. | 235 | // token never gains rights by being read by an older build. | ||
| 240 | - | // | |||
| 241 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |||
| 242 | func decodeToken(val []byte) (domain.Token, error) { | 236 | func decodeToken(val []byte) (domain.Token, error) { | ||
| 243 | var pb storagepb.Token | 237 | var pb storagepb.Token | ||
| 244 | if err := proto.Unmarshal(val, &pb); err != nil { | 238 | if err := proto.Unmarshal(val, &pb); err != nil { | ||
M internal/adapters/rpc/api.go +2 -4
| @@ -87,9 +87,7 @@ type Config struct { | |||||
| 87 | Secrets ports.SecretService | 87 | Secrets ports.SecretService | ||
| 88 | Variables ports.VariableService | 88 | Variables ports.VariableService | ||
| 89 | Environments ports.EnvironmentService | 89 | Environments ports.EnvironmentService | ||
| 90 | - | // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server | 90 | + | // OIDCKeys manages ID-token signing keys, including when OIDC is disabled. |
| 91 | - | // issues none (GetVersion then does not list "ci.oidc"). Same rule as | |||
| 92 | - | // Sessions about typed nil pointers. | |||
| 93 | OIDCKeys ports.OIDCKeyService | 91 | OIDCKeys ports.OIDCKeyService | ||
| 94 | // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService, | 92 | // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService, | ||
| 95 | // and GetVersion lists "ci.artifacts", when both are set and Runner is. | 93 | // and GetVersion lists "ci.artifacts", when both are set and Runner is. | ||
| @@ -138,7 +136,7 @@ func New(cfg Config) *API { | |||||
| 138 | if artifacts { | 136 | if artifacts { | ||
| 139 | features = append(features, FeatureCIArtifacts) | 137 | features = append(features, FeatureCIArtifacts) | ||
| 140 | } | 138 | } | ||
| 141 | - | if cfg.OIDCKeys != nil { | 139 | + | if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() { |
| 142 | features = append(features, FeatureCIOIDC) | 140 | features = append(features, FeatureCIOIDC) | ||
| 143 | } | 141 | } | ||
| 144 | if cfg.TrustedKeys != nil { | 142 | if cfg.TrustedKeys != nil { | ||
M internal/adapters/rpc/convert.go +1 -4
| @@ -58,10 +58,7 @@ func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) { | |||||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | func repoToPB(v ports.RepoView) *pb.Repo { | 60 | func repoToPB(v ports.RepoView) *pb.Repo { | ||
| 61 | - | settings := v.Settings | 61 | + | settings := v.EffectiveSettings() |
| 62 | - | if settings.IsZero() { | |||
| 63 | - | settings = domain.DefaultRepoSettings() | |||
| 64 | - | } | |||
| 65 | return &pb.Repo{ | 62 | return &pb.Repo{ | ||
| 66 | Name: v.Name.String(), | 63 | Name: v.Name.String(), | ||
| 67 | Description: v.Description, | 64 | Description: v.Description, | ||
M internal/adapters/rpc/git.go +0 -1
| @@ -187,7 +187,6 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC | |||||
| 187 | return connect.NewResponse(commitDetailToPB(detail)), nil | 187 | return connect.NewResponse(commitDetailToPB(detail)), nil | ||
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |||
| 191 | func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) { | 190 | func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) { | ||
| 192 | name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision()) | 191 | name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision()) | ||
| 193 | if err != nil { | 192 | if err != nil { | ||
M internal/adapters/rpc/mergerequest.go +0 -2
| @@ -212,8 +212,6 @@ func commentID(s string) int64 { | |||||
| 212 | } | 212 | } | ||
| 213 | 213 | ||||
| 214 | // headID parses a commit id of a request; the empty string is the zero id. | 214 | // headID parses a commit id of a request; the empty string is the zero id. | ||
| 215 | - | // | |||
| 216 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses a request field into the domain id and names the field in the error; no rule is decided here | |||
| 217 | func headID(s string) (domain.ObjectID, error) { | 215 | func headID(s string) (domain.ObjectID, error) { | ||
| 218 | if s == "" { | 216 | if s == "" { | ||
| 219 | return domain.ObjectID{}, nil | 217 | return domain.ObjectID{}, nil | ||
M internal/adapters/rpc/repo.go +0 -2
| @@ -83,8 +83,6 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up | |||||
| 83 | // "settings.allowed_merge_modes" and "settings.failing_checks_block", and | 83 | // "settings.allowed_merge_modes" and "settings.failing_checks_block", and | ||
| 84 | // nothing else; the optional issues field stands outside the mask, and alone | 84 | // nothing else; the optional issues field stands outside the mask, and alone | ||
| 85 | // it is a valid update. | 85 | // it is a valid update. | ||
| 86 | - | // | |||
| 87 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here | |||
| 88 | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | 86 | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | ||
| 89 | var params ports.UpdateRepoParams | 87 | var params ports.UpdateRepoParams | ||
| 90 | paths := msg.GetUpdateMask().GetPaths() | 88 | paths := msg.GetUpdateMask().GetPaths() | ||
M internal/adapters/rpc/server.go +5 -8
| @@ -10,7 +10,6 @@ import ( | |||||
| 10 | "connectrpc.com/connect" | 10 | "connectrpc.com/connect" | ||
| 11 | 11 | ||||
| 12 | pb "bnema.dev/ermite-api/gen/ermite" | 12 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 13 | - | "bnema.dev/ermite/internal/domain" | |||
| 14 | "bnema.dev/ermite/internal/ports" | 13 | "bnema.dev/ermite/internal/ports" | ||
| 15 | ) | 14 | ) | ||
| 16 | 15 | ||||
| @@ -19,7 +18,7 @@ type serverService struct { | |||||
| 19 | version string | 18 | version string | ||
| 20 | features []string | 19 | features []string | ||
| 21 | key ports.SigningKeyService | 20 | key ports.SigningKeyService | ||
| 22 | - | // oidc rotates the key of the ID tokens; nil when the server issues none. | 21 | + | // oidc manages key rotation, including the disabled-provider response. |
| 23 | oidc ports.OIDCKeyService | 22 | oidc ports.OIDCKeyService | ||
| 24 | } | 23 | } | ||
| 25 | 24 | ||||
| @@ -41,15 +40,13 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi | |||||
| 41 | } | 40 | } | ||
| 42 | 41 | ||||
| 43 | // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the | 42 | // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the | ||
| 44 | - | // owner may; failed_precondition when the server issues no ID tokens. | 43 | + | // owner may; failed_precondition when the server issues no ID tokens. An API |
| 44 | + | // built without an OIDC key service answers unimplemented. | |||
| 45 | func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) { | 45 | func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) { | ||
| 46 | - | p := caller(ctx) | |||
| 47 | if s.oidc == nil { | 46 | if s.oidc == nil { | ||
| 48 | - | if !p.HasOwnerRights() { | 47 | + | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("this API serves no OIDC key service")) |
| 49 | - | return nil, connectError(ctx, domain.ErrForbidden) | |||
| 50 | - | } | |||
| 51 | - | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY")) | |||
| 52 | } | 48 | } | ||
| 49 | + | p := caller(ctx) | |||
| 53 | r, err := s.oidc.RotateKey(ctx, p) | 50 | r, err := s.oidc.RotateKey(ctx, p) | ||
| 54 | if err != nil { | 51 | if err != nil { | ||
| 55 | return nil, connectError(ctx, err) | 52 | return nil, connectError(ctx, err) | ||
M internal/adapters/rpc/server_test.go +11 -0
| @@ -49,6 +49,17 @@ func TestServerServiceGetSigningKey(t *testing.T) { | |||||
| 49 | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | 49 | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | // An API built without an OIDC key service answers unimplemented instead of | |||
| 53 | + | // panicking, for the owner and for anyone else. | |||
| 54 | + | func TestRotateOIDCKeyWithoutService(t *testing.T) { | |||
| 55 | + | t.Parallel() | |||
| 56 | + | for _, p := range []domain.Principal{domain.Owner(), {}} { | |||
| 57 | + | ctx := domain.ContextWithPrincipal(context.Background(), p) | |||
| 58 | + | _, err := (&serverService{}).RotateOIDCKey(ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{})) | |||
| 59 | + | assert.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err)) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 52 | // GetVersion lists "passkeys" only when the API is built with passkeys. | 63 | // GetVersion lists "passkeys" only when the API is built with passkeys. | ||
| 53 | func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) { | 64 | func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) { | ||
| 54 | t.Parallel() | 65 | t.Parallel() | ||
M internal/adapters/sigverify/gpg.go +0 -2
| @@ -41,8 +41,6 @@ var _ ports.SignatureVerifier = GPG{} | |||||
| 41 | 41 | ||||
| 42 | // Verify implements ports.SignatureVerifier. signedAt is ignored: see the time | 42 | // Verify implements ports.SignatureVerifier. signedAt is ignored: see the time | ||
| 43 | // policy of GPG. | 43 | // policy of GPG. | ||
| 44 | - | // | |||
| 45 | - | //hexcheck:ignore suspicious-business-logic-in-adapter this is the OpenPGP protocol check the port delegates to the adapter; the trust and email rules stay in domain | |||
| 46 | func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) { | 44 | func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) { | ||
| 47 | v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature} | 45 | v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature} | ||
| 48 | defer func() { // a parser bug must not take the server down | 46 | defer func() { // a parser bug must not take the server down | ||
M internal/adapters/sigverify/ssh.go +0 -2
| @@ -49,8 +49,6 @@ type sshSigBlob struct { | |||||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | // Verify implements ports.SignatureVerifier. | 51 | // Verify implements ports.SignatureVerifier. | ||
| 52 | - | // | |||
| 53 | - | //hexcheck:ignore suspicious-business-logic-in-adapter this is the SSHSIG protocol check the port delegates to the adapter; the trust and email rules stay in domain | |||
| 54 | func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) { | 52 | func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) { | ||
| 55 | v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature} | 53 | v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature} | ||
| 56 | defer func() { // a parser bug must not take the server down | 54 | defer func() { // a parser bug must not take the server down | ||
M internal/adapters/sigverify/verifier.go +0 -2
| @@ -42,8 +42,6 @@ func (Verifier) Verify(payload, signature []byte, committerEmail string, signedA | |||||
| 42 | // trusted completes the verification of a signature whose cryptography is | 42 | // trusted completes the verification of a signature whose cryptography is | ||
| 43 | // valid and whose key is trusted: verified when the key may sign for the | 43 | // valid and whose key is trusted: verified when the key may sign for the | ||
| 44 | // committer email, email_mismatch otherwise. | 44 | // committer email, email_mismatch otherwise. | ||
| 45 | - | // | |||
| 46 | - | //hexcheck:ignore suspicious-business-logic-in-adapter the email rule is domain.TrustedKey.TrustsEmail; this only maps it to the port's status | |||
| 47 | func trusted(v domain.CommitVerification, key domain.TrustedKey, committerEmail string) domain.CommitVerification { | 45 | func trusted(v domain.CommitVerification, key domain.TrustedKey, committerEmail string) domain.CommitVerification { | ||
| 48 | v.KeyID, v.KeyTitle = key.ID, key.Title | 46 | v.KeyID, v.KeyTitle = key.ID, key.Title | ||
| 49 | if key.TrustsEmail(committerEmail) { | 47 | if key.TrustsEmail(committerEmail) { | ||
M internal/adapters/web/actions_artifacts.go +0 -4
| @@ -53,8 +53,6 @@ type ArtifactsView struct { | |||||
| 53 | // runArtifacts is the artifacts section of a run, nil when there is nothing to | 53 | // runArtifacts is the artifacts section of a run, nil when there is nothing to | ||
| 54 | // show: no artifacts on this server, a visitor who cannot list them, no | 54 | // show: no artifacts on this server, a visitor who cannot list them, no | ||
| 55 | // artifact, or a failed read, which is logged; the run page never fails for it. | 55 | // artifact, or a failed read, which is logged; the run page never fails for it. | ||
| 56 | - | // | |||
| 57 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the artifacts section from the use case results; which errors hide it is presentation, no rule is decided here | |||
| 58 | func (s *Server) runArtifacts(ctx context.Context, repo domain.RepoName, p RunPage) *ArtifactsView { | 56 | func (s *Server) runArtifacts(ctx context.Context, repo domain.RepoName, p RunPage) *ArtifactsView { | ||
| 59 | if s.cfg.Artifacts == nil || !SignedIn(ctx) { | 57 | if s.cfg.Artifacts == nil || !SignedIn(ctx) { | ||
| 60 | return nil | 58 | return nil | ||
| @@ -112,8 +110,6 @@ func parseArtifactPath(r *http.Request) (int64, error) { | |||||
| 112 | // is never served: a zip is application/zip, anything else | 110 | // is never served: a zip is application/zip, anything else | ||
| 113 | // application/octet-stream, and the answer is sandboxed and not sniffed, so | 111 | // application/octet-stream, and the answer is sandboxed and not sniffed, so | ||
| 114 | // that nothing uploaded by a job can run in the origin of the forge. | 112 | // that nothing uploaded by a job can run in the origin of the forge. | ||
| 115 | - | // | |||
| 116 | - | //hexcheck:ignore suspicious-business-logic-in-adapter streams what the use case sends; the headers are about the response, no rule is decided here | |||
| 117 | func (s *Server) artifactDownload(w http.ResponseWriter, r *http.Request) { | 113 | func (s *Server) artifactDownload(w http.ResponseWriter, r *http.Request) { | ||
| 118 | ctx, cancel := context.WithTimeout(r.Context(), artifactTimeout) | 114 | ctx, cancel := context.WithTimeout(r.Context(), artifactTimeout) | ||
| 119 | defer cancel() | 115 | defer cancel() | ||
M internal/adapters/web/browse.go +0 -2
| @@ -71,8 +71,6 @@ func (s *Server) openBlobAt(ctx context.Context, repo domain.RepoName, rev, path | |||||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | // resolveRevision resolves rev to the commit it designates. | 73 | // resolveRevision resolves rev to the commit it designates. | ||
| 74 | - | // | |||
| 75 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses the URL text and calls the use case; no rule is decided here | |||
| 76 | func (s *Server) resolveRevision(ctx context.Context, repo domain.RepoName, rev string) (domain.ResolvedRevision, error) { | 74 | func (s *Server) resolveRevision(ctx context.Context, repo domain.RepoName, rev string) (domain.ResolvedRevision, error) { | ||
| 77 | r, err := domain.ParseRevision(rev) | 75 | r, err := domain.ParseRevision(rev) | ||
| 78 | if err != nil { | 76 | if err != nil { | ||
M internal/adapters/web/checks.go +2 -0
| @@ -54,6 +54,8 @@ func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string | |||||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | // checksSummary reduces checks to the mark a list shows next to a commit. | 56 | // checksSummary reduces checks to the mark a list shows next to a commit. | ||
| 57 | + | // | |||
| 58 | + | //hexcheck:ignore suspicious-business-logic-in-adapter renders domain check outcomes as a commit-list mark, passed count, label and actions link | |||
| 57 | func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary { | 59 | func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary { | ||
| 58 | if len(checks) == 0 { | 60 | if len(checks) == 0 { | ||
| 59 | return ChecksSummary{} | 61 | return ChecksSummary{} | ||
M internal/adapters/web/commit.go +0 -2
| @@ -96,8 +96,6 @@ func diffLines(files []DiffFile) (lines int, invisible bool) { | |||||
| 96 | 96 | ||||
| 97 | // commit shows a commit: its message, its totals, the changed files and the | 97 | // commit shows a commit: its message, its totals, the changed files and the | ||
| 98 | // diff against the first parent. ?view=split selects the split layout. | 98 | // diff against the first parent. ?view=split selects the split layout. | ||
| 99 | - | // | |||
| 100 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the commit page from the use case results; no rule is decided here | |||
| 101 | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 99 | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 102 | sha := r.PathValue("sha") | 100 | sha := r.PathValue("sha") | ||
| 103 | if !isCommitID(sha) { | 101 | if !isCommitID(sha) { | ||
M internal/adapters/web/compare.go +3 -55
| @@ -5,7 +5,6 @@ package web | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | - | "slices" | |||
| 9 | 8 | ||||
| 10 | "github.com/a-h/templ" | 9 | "github.com/a-h/templ" | ||
| 11 | 10 | ||||
| @@ -13,10 +12,6 @@ import ( | |||||
| 13 | "bnema.dev/ermite/internal/ports" | 12 | "bnema.dev/ermite/internal/ports" | ||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| 16 | - | // compareDepth bounds the commits read on each side to find where a branch | |||
| 17 | - | // left its target. | |||
| 18 | - | const compareDepth = maxComparePages * pageSize | |||
| 19 | - | ||||
| 20 | // compare shows a branch against the default branch of the repository: the | 15 | // compare shows a branch against the default branch of the repository: the | ||
| 21 | // commits it is ahead by and, for the owner, the form that opens a merge | 16 | // commits it is ahead by and, for the owner, the form that opens a merge | ||
| 22 | // request. | 17 | // request. | ||
| @@ -27,8 +22,6 @@ const compareDepth = maxComparePages * pageSize | |||||
| 27 | // own diff is then the whole change). Otherwise it lists the commits, which | 22 | // own diff is then the whole change). Otherwise it lists the commits, which | ||
| 28 | // link to their own diffs, and says the whole diff comes with the merge | 23 | // link to their own diffs, and says the whole diff comes with the merge | ||
| 29 | // request. | 24 | // request. | ||
| 30 | - | // | |||
| 31 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the compare page from the use case results; the merge rules are the use case's | |||
| 32 | func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 25 | func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 33 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 26 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 34 | if err != nil { | 27 | if err != nil { | ||
| @@ -56,7 +49,9 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 56 | p.Note = "this is the default branch: there is nothing to compare it with." | 49 | p.Note = "this is the default branch: there is nothing to compare it with." | ||
| 57 | p.AheadText = "0 commits" | 50 | p.AheadText = "0 commits" | ||
| 58 | } else { | 51 | } else { | ||
| 59 | - | ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String()) | 52 | + | var comparison ports.CompareResult |
| 53 | + | comparison, err = s.cfg.MergeRequests.Compare(ctx, principal(ctx), d.repo.Name, tgt.CommitID, src.CommitID) | |||
| 54 | + | ahead, exact, behind = comparison.Ahead, comparison.Exact, comparison.Behind | |||
| 60 | if err != nil { | 55 | if err != nil { | ||
| 61 | return nil, 0, err | 56 | return nil, 0, err | ||
| 62 | } | 57 | } | ||
| @@ -139,53 +134,6 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 139 | return Compare(l, p), http.StatusOK, nil | 134 | return Compare(l, p), http.StatusOK, nil | ||
| 140 | } | 135 | } | ||
| 141 | 136 | ||||
| 142 | - | // commitsAhead lists the commits of the source that the target lacks, newest | |||
| 143 | - | // first. exact is false when the walk gave up before finding where the source | |||
| 144 | - | // meets the target. behind is true when the target has commits the source | |||
| 145 | - | // lacks (the tip of the target is not an ancestor of the source). | |||
| 146 | - | // | |||
| 147 | - | //hexcheck:ignore suspicious-business-logic-in-adapter walks the history pages the use case returns to find where the branch left its target; the page needs it, the forge decides nothing | |||
| 148 | - | func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) { | |||
| 149 | - | if srcTip == tgtTip { | |||
| 150 | - | return nil, true, false, nil | |||
| 151 | - | } | |||
| 152 | - | onTarget := map[string]bool{} | |||
| 153 | - | tokenT := "" | |||
| 154 | - | for len(onTarget) < compareDepth { | |||
| 155 | - | res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT) | |||
| 156 | - | if err != nil { | |||
| 157 | - | return nil, false, false, err | |||
| 158 | - | } | |||
| 159 | - | for _, c := range res.Commits { | |||
| 160 | - | onTarget[c.ID.String()] = true | |||
| 161 | - | } | |||
| 162 | - | if tokenT = res.NextPageToken; tokenT == "" { | |||
| 163 | - | break | |||
| 164 | - | } | |||
| 165 | - | } | |||
| 166 | - | token := "" | |||
| 167 | - | for n := 0; n < compareDepth; { | |||
| 168 | - | res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token) | |||
| 169 | - | if err != nil { | |||
| 170 | - | return nil, false, false, err | |||
| 171 | - | } | |||
| 172 | - | for _, c := range res.Commits { | |||
| 173 | - | n++ | |||
| 174 | - | if onTarget[c.ID.String()] { | |||
| 175 | - | // The first commit of the source that the target has: where the | |||
| 176 | - | // branch left it. The target moved on when this is not its tip. | |||
| 177 | - | return ahead, true, c.ID.String() != tgtTip, nil | |||
| 178 | - | } | |||
| 179 | - | ahead = append(ahead, c) | |||
| 180 | - | } | |||
| 181 | - | if token = res.NextPageToken; token == "" { | |||
| 182 | - | // The whole history of the source is ahead: no common history. | |||
| 183 | - | return ahead, true, true, nil | |||
| 184 | - | } | |||
| 185 | - | } | |||
| 186 | - | return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | // openRequestFor finds the open merge request with this source and target. | 137 | // openRequestFor finds the open merge request with this source and target. | ||
| 190 | func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) { | 138 | func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) { | ||
| 191 | token := "" | 139 | token := "" | ||
M internal/adapters/web/context.go +1 -1
| @@ -30,7 +30,7 @@ type ContextRows struct { | |||||
| 30 | // the numbers of the first hidden line on each side, which move together (the | 30 | // the numbers of the first hidden line on each side, which move together (the | ||
| 31 | // lines are unchanged); n the number of lines, 0 for the rest of the file. | 31 | // lines are unchanged); n the number of lines, 0 for the rest of the file. | ||
| 32 | // | 32 | // | ||
| 33 | - | //hexcheck:ignore suspicious-business-logic-in-adapter cuts a file the use case opened into rows of the diff; no rule is decided here | 33 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates diff-context HTTP parameters, rejects binary or oversized files, and caps rendered context rows |
| 34 | func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 34 | func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 35 | sha := r.PathValue("sha") | 35 | sha := r.PathValue("sha") | ||
| 36 | if !isFullID(sha) { | 36 | if !isFullID(sha) { | ||
M internal/adapters/web/diff_highlight.go +0 -2
| @@ -370,8 +370,6 @@ func computeFile(ctx context.Context, lx chroma.Lexer, hunks []domain.Hunk) (d * | |||||
| 370 | 370 | ||||
| 371 | // applyFile puts the colours on the lines of a file; false when the data is | 371 | // applyFile puts the colours on the lines of a file; false when the data is | ||
| 372 | // not the one of this file (another number of lines), nothing is changed. | 372 | // not the one of this file (another number of lines), nothing is changed. | ||
| 373 | - | // | |||
| 374 | - | //hexcheck:ignore suspicious-business-logic-in-adapter puts coloured runs on the lines of a diff for the page; no rule is decided here | |||
| 375 | func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool { | 373 | func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool { | ||
| 376 | n := 0 | 374 | n := 0 | ||
| 377 | for _, h := range hunks { | 375 | for _, h := range hunks { | ||
M internal/adapters/web/events.go +3 -1
| @@ -342,6 +342,8 @@ func (s *Server) canSee(ctx context.Context, rawName string) error { | |||||
| 342 | 342 | ||||
| 343 | // issuesOn is domain.ErrNotFound unless the repository has issues on and | 343 | // issuesOn is domain.ErrNotFound unless the repository has issues on and | ||
| 344 | // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see. | 344 | // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see. | ||
| 345 | + | // | |||
| 346 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.IssuesEnabled to a not-found stream; decides nothing itself | |||
| 345 | func (s *Server) issuesOn(ctx context.Context, rawName string) error { | 347 | func (s *Server) issuesOn(ctx context.Context, rawName string) error { | ||
| 346 | if s.cfg.Issues == nil { | 348 | if s.cfg.Issues == nil { | ||
| 347 | return domain.ErrNotFound | 349 | return domain.ErrNotFound | ||
| @@ -354,7 +356,7 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error { | |||||
| 354 | if err != nil { | 356 | if err != nil { | ||
| 355 | return err | 357 | return err | ||
| 356 | } | 358 | } | ||
| 357 | - | if v.Settings.Issues.OrDefault() == domain.IssuesOff { | 359 | + | if !v.EffectiveSettings().IssuesEnabled() { |
| 358 | return domain.ErrNotFound | 360 | return domain.ErrNotFound | ||
| 359 | } | 361 | } | ||
| 360 | return nil | 362 | return nil | ||
M internal/adapters/web/events_test.go +13 -0
| @@ -465,3 +465,16 @@ func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) { | |||||
| 465 | resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil) | 465 | resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil) | ||
| 466 | assert.Equal(t, http.StatusOK, resp.StatusCode) | 466 | assert.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 467 | } | 467 | } | ||
| 468 | + | ||||
| 469 | + | // Without the merge request service the commits page still gets a banner when | |||
| 470 | + | // its branch moves; it only cannot count the new commits. | |||
| 471 | + | func TestCommitsBannerWithoutMergeRequests(t *testing.T) { | |||
| 472 | + | t.Parallel() | |||
| 473 | + | srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)}) | |||
| 474 | + | old, tip := strings.Repeat("a", 40), strings.Repeat("b", 40) | |||
| 475 | + | ls := &liveStream{s: srv, repo: "x", view: "commits", ref: "main", head: old} | |||
| 476 | + | got, err := ls.banner(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/main", Old: old, New: tip}}}) | |||
| 477 | + | require.NoError(t, err) | |||
| 478 | + | require.Len(t, got, 1) | |||
| 479 | + | assert.Contains(t, got[0].html, "the branch moved — reload") | |||
| 480 | + | } | |||
M internal/adapters/web/fragments.go +43 -32
| @@ -356,7 +356,7 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error)) | |||||
| 356 | // banner tells the commits page how many commits its branch got since the | 356 | // banner tells the commits page how many commits its branch got since the | ||
| 357 | // head it lists. | 357 | // head it lists. | ||
| 358 | // | 358 | // | ||
| 359 | - | //hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only | 359 | + | //hexcheck:ignore suspicious-business-logic-in-adapter picks the update of the listed branch from the push event; the count and rewrite come from MergeRequests.PushSummary |
| 360 | func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | 360 | func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | ||
| 361 | if l.ref == "" || !isFullID(l.head) { | 361 | if l.ref == "" || !isFullID(l.head) { | ||
| 362 | return nil, nil | 362 | return nil, nil | ||
| @@ -376,37 +376,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment | |||||
| 376 | case tip == "": | 376 | case tip == "": | ||
| 377 | return gap, nil // the branch was deleted | 377 | return gap, nil // the branch was deleted | ||
| 378 | } | 378 | } | ||
| 379 | - | text := "" | 379 | + | text, err := l.bannerText(ctx, tip) |
| 380 | - | if tip != l.head { | 380 | + | if err != nil { |
| 381 | - | name, err := domain.ParseRepoName(l.repo) | 381 | + | return nil, err |
| 382 | - | if err != nil { | |||
| 383 | - | return nil, err | |||
| 384 | - | } | |||
| 385 | - | res, err := l.s.commitList(ctx, name, tip, "", pageSize, "") | |||
| 386 | - | if err != nil { | |||
| 387 | - | return nil, err | |||
| 388 | - | } | |||
| 389 | - | // The count is approximate for a history with merges: the log is | |||
| 390 | - | // ordered by date, not as "head..tip", so commits that are not new may | |||
| 391 | - | // be counted before the head is met. And one page of the log being | |||
| 392 | - | // all that is read, "100+" cannot be told from a rewritten branch | |||
| 393 | - | // whose old head is further back: the use cases have no "is ancestor". | |||
| 394 | - | n, found := 0, false | |||
| 395 | - | for _, c := range res.Commits { | |||
| 396 | - | if c.ID.String() == l.head { | |||
| 397 | - | found = true | |||
| 398 | - | break | |||
| 399 | - | } | |||
| 400 | - | n++ | |||
| 401 | - | } | |||
| 402 | - | switch { | |||
| 403 | - | case found: | |||
| 404 | - | text = newCommits(strconv.Itoa(n), n) | |||
| 405 | - | case res.NextPageToken != "": | |||
| 406 | - | text = newCommits(strconv.Itoa(n)+"+", 2) | |||
| 407 | - | default: | |||
| 408 | - | text = "the branch was rewritten — reload" | |||
| 409 | - | } | |||
| 410 | } | 382 | } | ||
| 411 | c := LiveSlot("commits-head") | 383 | c := LiveSlot("commits-head") | ||
| 412 | if text != "" { | 384 | if text != "" { | ||
| @@ -416,6 +388,45 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment | |||||
| 416 | return []fragment{f}, err | 388 | return []fragment{f}, err | ||
| 417 | } | 389 | } | ||
| 418 | 390 | ||||
| 391 | + | func (l *liveStream) bannerText(ctx context.Context, tip string) (string, error) { | |||
| 392 | + | if tip == l.head { | |||
| 393 | + | return "", nil | |||
| 394 | + | } | |||
| 395 | + | if l.s.cfg.MergeRequests == nil { | |||
| 396 | + | // Without the merge request service there is no history summary: the | |||
| 397 | + | // banner only says the branch moved. | |||
| 398 | + | return "the branch moved — reload", nil | |||
| 399 | + | } | |||
| 400 | + | name, err := domain.ParseRepoName(l.repo) | |||
| 401 | + | if err != nil { | |||
| 402 | + | return "", err | |||
| 403 | + | } | |||
| 404 | + | oldHead, err := domain.ParseObjectID(l.head) | |||
| 405 | + | if err != nil { | |||
| 406 | + | return "", err | |||
| 407 | + | } | |||
| 408 | + | newTip, err := domain.ParseObjectID(tip) | |||
| 409 | + | if err != nil { | |||
| 410 | + | return "", err | |||
| 411 | + | } | |||
| 412 | + | res, err := l.s.cfg.MergeRequests.PushSummary(ctx, principal(ctx), name, oldHead, newTip) | |||
| 413 | + | if err != nil { | |||
| 414 | + | return "", err | |||
| 415 | + | } | |||
| 416 | + | return pushSummaryText(res), nil | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | func pushSummaryText(res ports.PushSummary) string { | |||
| 420 | + | switch { | |||
| 421 | + | case res.Rewritten: | |||
| 422 | + | return "the branch was rewritten — reload" | |||
| 423 | + | case res.More: | |||
| 424 | + | return newCommits(strconv.Itoa(res.Count)+"+", 2) | |||
| 425 | + | default: | |||
| 426 | + | return newCommits(strconv.Itoa(res.Count), res.Count) | |||
| 427 | + | } | |||
| 428 | + | } | |||
| 429 | + | ||||
| 419 | // newCommits is the text of the banner: "1 new commit — reload". | 430 | // newCommits is the text of the banner: "1 new commit — reload". | ||
| 420 | func newCommits(count string, n int) string { | 431 | func newCommits(count string, n int) string { | ||
| 421 | if n == 1 { | 432 | if n == 1 { | ||
M internal/adapters/web/issues.go +3 -5
| @@ -97,7 +97,7 @@ func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, erro | |||||
| 97 | if err != nil { | 97 | if err != nil { | ||
| 98 | return nil, err | 98 | return nil, err | ||
| 99 | } | 99 | } | ||
| 100 | - | if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff { | 100 | + | if !d.repo.Settings.IssuesEnabled() { |
| 101 | return nil, errNotFound | 101 | return nil, errNotFound | ||
| 102 | } | 102 | } | ||
| 103 | return d, nil | 103 | return d, nil | ||
| @@ -129,7 +129,7 @@ func (s *Server) issueRow(repo string, i domain.Issue) IssueRow { | |||||
| 129 | 129 | ||||
| 130 | // issueList lists the issues of one state, a page at a time. | 130 | // issueList lists the issues of one state, a page at a time. | ||
| 131 | // | 131 | // | ||
| 132 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode | 132 | + | //hexcheck:ignore suspicious-business-logic-in-adapter renders issue-list navigation; signed-in users see New issue, while anonymous users need a configured form and the domain AcceptsAnonymousIssues predicate |
| 133 | func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 133 | func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 134 | d, err := s.issueRepo(ctx, r.PathValue("repo")) | 134 | d, err := s.issueRepo(ctx, r.PathValue("repo")) | ||
| 135 | if err != nil { | 135 | if err != nil { | ||
| @@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen | |||||
| 179 | if token != "" { | 179 | if token != "" { | ||
| 180 | p.Newest = link("") | 180 | p.Newest = link("") | ||
| 181 | } | 181 | } | ||
| 182 | - | if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) { | 182 | + | if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.AcceptsAnonymousIssues()) { |
| 183 | p.NewHref = issuesBase(d.name()) + "/new" | 183 | p.NewHref = issuesBase(d.name()) + "/new" | ||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| @@ -216,8 +216,6 @@ func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component | |||||
| 216 | } | 216 | } | ||
| 217 | 217 | ||||
| 218 | // issueShow shows an issue with its comments. | 218 | // issueShow shows an issue with its comments. | ||
| 219 | - | // | |||
| 220 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here | |||
| 221 | func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 219 | func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 222 | n, err := mrNumber(r) | 220 | n, err := mrNumber(r) | ||
| 223 | if err != nil { | 221 | if err != nil { | ||
M internal/adapters/web/issues_form.go +3 -1
| @@ -245,6 +245,8 @@ func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.R | |||||
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | // issueSubmittedPage is GET /<repo>/issues/submitted. | 247 | // issueSubmittedPage is GET /<repo>/issues/submitted. | ||
| 248 | + | // | |||
| 249 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.ShowsIssueSubmissionReceipt to a 404; decides nothing itself | |||
| 248 | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | 250 | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | ||
| 249 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | 251 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | ||
| 250 | defer cancel() | 252 | defer cancel() | ||
| @@ -253,7 +255,7 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |||||
| 253 | s.fail(ctx, w, r, err) | 255 | s.fail(ctx, w, r, err) | ||
| 254 | return | 256 | return | ||
| 255 | } | 257 | } | ||
| 256 | - | if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic { | 258 | + | if !d.repo.Settings.ShowsIssueSubmissionReceipt() { |
| 257 | s.fail(ctx, w, r, errNotFound) | 259 | s.fail(ctx, w, r, errNotFound) | ||
| 258 | return | 260 | return | ||
| 259 | } | 261 | } | ||
M internal/adapters/web/issues_post.go +0 -1
| @@ -67,7 +67,6 @@ func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, stri | |||||
| 67 | return s.issueTransition(ctx, r, s.cfg.Issues.Reopen) | 67 | return s.issueTransition(ctx, r, s.cfg.Issues.Reopen) | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | - | //hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which | |||
| 71 | func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) { | 70 | func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) { | ||
| 72 | repo := r.PathValue("repo") | 71 | repo := r.PathValue("repo") | ||
| 73 | n, err := number(r) | 72 | n, err := number(r) | ||
M internal/adapters/web/mr.go +96 -88
| @@ -62,9 +62,6 @@ const ( | |||||
| 62 | // maxOpenPages bounds the pages read for the open merge requests, which | 62 | // maxOpenPages bounds the pages read for the open merge requests, which | ||
| 63 | // the first list page shows in full. | 63 | // the first list page shows in full. | ||
| 64 | maxOpenPages = 5 | 64 | maxOpenPages = 5 | ||
| 65 | - | // maxComparePages bounds how many pages of history the compare page reads | |||
| 66 | - | // to find where the branch left the target. | |||
| 67 | - | maxComparePages = 5 | |||
| 68 | // railBranches is how many branches the list offers to compare. | 65 | // railBranches is how many branches the list offers to compare. | ||
| 69 | railBranches = 15 | 66 | railBranches = 15 | ||
| 70 | ) | 67 | ) | ||
| @@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, | |||||
| 546 | return s.mrPage(ctx, r, true) | 543 | return s.mrPage(ctx, r, true) | ||
| 547 | } | 544 | } | ||
| 548 | 545 | ||||
| 549 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here | |||
| 550 | func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) { | 546 | func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) { | ||
| 547 | + | if since && !SignedIn(ctx) { | |||
| 548 | + | return nil, 0, errNotFound | |||
| 549 | + | } | |||
| 551 | n, err := mrNumber(r) | 550 | n, err := mrNumber(r) | ||
| 552 | if err != nil { | 551 | if err != nil { | ||
| 553 | return nil, 0, err | 552 | return nil, 0, err | ||
| 554 | } | 553 | } | ||
| 555 | - | if since && !SignedIn(ctx) { | |||
| 556 | - | // The read position is the owner's: there is nothing to show others. | |||
| 557 | - | return nil, 0, errNotFound | |||
| 558 | - | } | |||
| 559 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 554 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 560 | if err != nil { | 555 | if err != nil { | ||
| 561 | return nil, 0, err | 556 | return nil, 0, err | ||
| @@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 574 | return nil, 0, errNotFound | 569 | return nil, 0, errNotFound | ||
| 575 | } | 570 | } | ||
| 576 | m := res.MergeRequest | 571 | m := res.MergeRequest | ||
| 577 | - | now := s.cfg.Now() | |||
| 578 | signed := SignedIn(ctx) | 572 | signed := SignedIn(ctx) | ||
| 579 | open := m.State == domain.MRStateOpen | 573 | open := m.State == domain.MRStateOpen | ||
| 580 | head := m.HeadID.String() | 574 | head := m.HeadID.String() | ||
| @@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 595 | Files: s.hl.files(ctx, res.Diff.Files), | 589 | Files: s.hl.files(ctx, res.Diff.Files), | ||
| 596 | } | 590 | } | ||
| 597 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head)) | 591 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head)) | ||
| 592 | + | if err := s.mrFacts(ctx, r, d, &p, res); err != nil { | |||
| 593 | + | return nil, 0, err | |||
| 594 | + | } | |||
| 595 | + | ||||
| 596 | + | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |||
| 597 | + | countNotes(p.Files, res.Comments) | |||
| 598 | + | if !res.DiffBaseID.IsZero() { | |||
| 599 | + | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | |||
| 600 | + | } | |||
| 601 | + | p.From = from | |||
| 602 | + | ||||
| 603 | + | s.mrReadPresentation(&p, d.name(), n, res, since, signed) | |||
| 604 | + | ||||
| 605 | + | // Comments: under their line, else in the list below the diff. The forms | |||
| 606 | + | // are the owner's, on the whole diff of an open request. | |||
| 607 | + | canWrite := signed && open && !since | |||
| 608 | + | var on *lineRef | |||
| 609 | + | if canWrite { | |||
| 610 | + | on = parseOn(r.URL.Query().Get("on")) | |||
| 611 | + | } | |||
| 612 | + | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | |||
| 613 | + | lines, invisible := diffLines(p.Files) | |||
| 614 | + | p.Invisible = invisible | |||
| 615 | + | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |||
| 616 | + | if signed && open { | |||
| 617 | + | p.Box = s.mrBox(ctx, d, res) | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | if mergeErrorOf(ctx) != "" { | |||
| 621 | + | p.Canonical = self | |||
| 622 | + | } | |||
| 623 | + | if p.Box == nil { | |||
| 624 | + | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | |||
| 625 | + | } | |||
| 626 | + | if canWrite { | |||
| 627 | + | p.GeneralForm = &CommentForm{ | |||
| 628 | + | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | |||
| 629 | + | } | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | l := s.repoLayout(ctx, d, "merge-requests") | |||
| 633 | + | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | |||
| 634 | + | if head != "" { | |||
| 635 | + | pinRef(&l, target{rev: head, name: m.Source}) | |||
| 636 | + | if !d.hasBranch(m.Source) { | |||
| 637 | + | pinRef(&l, target{rev: head, name: short(head), detached: true}) | |||
| 638 | + | } | |||
| 639 | + | } | |||
| 640 | + | l.Crumbs[0].Current = false | |||
| 641 | + | l.Crumbs = append(l.Crumbs, | |||
| 642 | + | Crumb{Label: "merge requests", Href: mrBase(d.name())}, | |||
| 643 | + | Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since}, | |||
| 644 | + | ) | |||
| 645 | + | if since { | |||
| 646 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | |||
| 647 | + | } | |||
| 648 | + | p.Live = true | |||
| 649 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | |||
| 650 | + | l.HasRail = true | |||
| 651 | + | l.MR = true | |||
| 652 | + | return MergeRequest(l, p), http.StatusOK, nil | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox { | |||
| 656 | + | m := res.MergeRequest | |||
| 657 | + | box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.HeadUnread, mergeabilityView(res.Mergeability, m.Target)) | |||
| 658 | + | box.Error = mergeErrorOf(ctx) | |||
| 659 | + | if m.IsDraft() { | |||
| 660 | + | box.ReadyAction = mrHref(d.name(), m.Number, "ready") | |||
| 661 | + | } | |||
| 662 | + | return box | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | // mrFacts formats the description, metadata, branch links and checks. | |||
| 666 | + | func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error { | |||
| 667 | + | m := res.MergeRequest | |||
| 668 | + | head := m.HeadID.String() | |||
| 669 | + | signed := SignedIn(ctx) | |||
| 670 | + | open := m.State == domain.MRStateOpen | |||
| 671 | + | now := s.cfg.Now() | |||
| 598 | if desc := m.Description.String(); strings.TrimSpace(desc) != "" { | 672 | if desc := m.Description.String(); strings.TrimSpace(desc) != "" { | ||
| 599 | // Relative links point into the source branch as it is now (its head | 673 | // Relative links point into the source branch as it is now (its head | ||
| 600 | // commit); the heading ids carry their own prefix, apart from the | 674 | // commit); the heading ids carry their own prefix, apart from the | ||
| 601 | // ids of the diff. | 675 | // ids of the diff. | ||
| 602 | h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments)) | 676 | h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments)) | ||
| 603 | if err != nil { | 677 | if err != nil { | ||
| 604 | - | return nil, 0, err | 678 | + | return err |
| 605 | } | 679 | } | ||
| 606 | p.Desc = h | 680 | p.Desc = h | ||
| 607 | } | 681 | } | ||
| @@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 644 | p.Checks.Note = "" | 718 | p.Checks.Note = "" | ||
| 645 | } | 719 | } | ||
| 646 | } | 720 | } | ||
| 647 | - | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | 721 | + | return nil |
| 648 | - | countNotes(p.Files, res.Comments) | 722 | + | } |
| 649 | - | if !res.DiffBaseID.IsZero() { | |||
| 650 | - | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | |||
| 651 | - | } | |||
| 652 | - | p.From = from | |||
| 653 | 723 | ||||
| 724 | + | // mrReadPresentation maps usecase read tracking to commit rows and navigation. | |||
| 725 | + | func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) { | |||
| 726 | + | now := s.cfg.Now() | |||
| 727 | + | head := res.MergeRequest.HeadID.String() | |||
| 654 | // The commits of the request, newest first. | 728 | // The commits of the request, newest first. | ||
| 655 | - | for _, c := range res.Commits { | 729 | + | for i, c := range res.Commits { |
| 656 | - | row := commitRow(d.name(), c, now) | 730 | + | row := commitRow(name, c, now) |
| 657 | p.Commits = append(p.Commits, MRCommit{ | 731 | p.Commits = append(p.Commits, MRCommit{ | ||
| 658 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | 732 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | ||
| 659 | - | Sig: row.Sig, | 733 | + | Sig: row.Sig, New: signed && i < res.NewCommits, |
| 660 | }) | 734 | }) | ||
| 661 | } | 735 | } | ||
| 662 | lastRead := res.LastReadID.String() | 736 | lastRead := res.LastReadID.String() | ||
| 663 | - | if signed && lastRead != "" { | |||
| 664 | - | // The commits are newest first: those before the last read head are new. | |||
| 665 | - | // A head that is not among them (the branch was rewritten) marks none. | |||
| 666 | - | for i, c := range res.Commits { | |||
| 667 | - | if c.ID.String() != lastRead { | |||
| 668 | - | continue | |||
| 669 | - | } | |||
| 670 | - | for j := 0; j < i; j++ { | |||
| 671 | - | p.Commits[j].New = true | |||
| 672 | - | } | |||
| 673 | - | } | |||
| 674 | - | } | |||
| 675 | if res.CommitsTruncated { | 737 | if res.CommitsTruncated { | ||
| 676 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | 738 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | ||
| 677 | } | 739 | } | ||
| 678 | 740 | ||||
| 679 | // Since the last read: the navigation, and what the page says about it. | 741 | // Since the last read: the navigation, and what the page says about it. | ||
| 680 | - | if !signed { | |||
| 681 | - | lastRead = "" // the read position is the owner's | |||
| 682 | - | } | |||
| 683 | if signed && lastRead != "" { | 742 | if signed && lastRead != "" { | ||
| 684 | p.Since = &SinceNav{ | 743 | p.Since = &SinceNav{ | ||
| 685 | - | AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"), | 744 | + | AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"), |
| 686 | ReadShort: short(lastRead), HeadShort: short(head), Current: since, | 745 | ReadShort: short(lastRead), HeadShort: short(head), Current: since, | ||
| 687 | } | 746 | } | ||
| 688 | } | 747 | } | ||
| @@ -702,57 +761,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 702 | p.Since.Files = len(p.Files) | 761 | p.Since.Files = len(p.Files) | ||
| 703 | } | 762 | } | ||
| 704 | 763 | ||||
| 705 | - | // Comments: under their line, else in the list below the diff. The forms | |||
| 706 | - | // are the owner's, on the whole diff of an open request. | |||
| 707 | - | canWrite := signed && open && !since | |||
| 708 | - | var on *lineRef | |||
| 709 | - | if canWrite { | |||
| 710 | - | on = parseOn(r.URL.Query().Get("on")) | |||
| 711 | - | } | |||
| 712 | - | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | |||
| 713 | - | lines, invisible := diffLines(p.Files) | |||
| 714 | - | p.Invisible = invisible | |||
| 715 | - | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |||
| 716 | - | if signed && open { | |||
| 717 | - | p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target)) | |||
| 718 | - | p.Box.Error = mergeErrorOf(ctx) | |||
| 719 | - | if m.IsDraft() { | |||
| 720 | - | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | |||
| 721 | - | } | |||
| 722 | - | } | |||
| 723 | - | if mergeErrorOf(ctx) != "" { | |||
| 724 | - | p.Canonical = self | |||
| 725 | - | } | |||
| 726 | - | if p.Box == nil { | |||
| 727 | - | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | |||
| 728 | - | } | |||
| 729 | - | if canWrite { | |||
| 730 | - | p.GeneralForm = &CommentForm{ | |||
| 731 | - | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | |||
| 732 | - | } | |||
| 733 | - | } | |||
| 734 | - | ||||
| 735 | - | l := s.repoLayout(ctx, d, "merge-requests") | |||
| 736 | - | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | |||
| 737 | - | if head != "" { | |||
| 738 | - | pinRef(&l, target{rev: head, name: m.Source}) | |||
| 739 | - | if !d.hasBranch(m.Source) { | |||
| 740 | - | pinRef(&l, target{rev: head, name: short(head), detached: true}) | |||
| 741 | - | } | |||
| 742 | - | } | |||
| 743 | - | l.Crumbs[0].Current = false | |||
| 744 | - | l.Crumbs = append(l.Crumbs, | |||
| 745 | - | Crumb{Label: "merge requests", Href: mrBase(d.name())}, | |||
| 746 | - | Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since}, | |||
| 747 | - | ) | |||
| 748 | - | if since { | |||
| 749 | - | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | |||
| 750 | - | } | |||
| 751 | - | p.Live = true | |||
| 752 | - | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | |||
| 753 | - | l.HasRail = true | |||
| 754 | - | l.MR = true | |||
| 755 | - | return MergeRequest(l, p), http.StatusOK, nil | |||
| 756 | } | 764 | } | ||
| 757 | 765 | ||||
| 758 | // hasBranch tells whether the repository has the branch. | 766 | // hasBranch tells whether the repository has the branch. | ||
| @@ -766,11 +774,11 @@ func (d *repoData) hasBranch(name string) bool { | |||||
| 766 | } | 774 | } | ||
| 767 | 775 | ||||
| 768 | // mergeBox is what the owner can do with an open request. | 776 | // mergeBox is what the owner can do with an open request. | ||
| 769 | - | func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox { | 777 | + | func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox { |
| 770 | b := &MergeBox{ | 778 | b := &MergeBox{ | ||
| 771 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | 779 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | ||
| 772 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | 780 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | ||
| 773 | - | CanMarkRead: head != "" && head != lastRead, | 781 | + | CanMarkRead: canMarkRead, |
| 774 | CanDeleteBranch: source != d.repo.DefaultBranch, | 782 | CanDeleteBranch: source != d.repo.DefaultBranch, | ||
| 775 | Mergeability: mg, | 783 | Mergeability: mg, | ||
| 776 | } | 784 | } | ||
M internal/adapters/web/mr_files.go +0 -2
| @@ -157,8 +157,6 @@ func splitPath(p string) (name, dir string) { | |||||
| 157 | // the files of a window with their hunks and their comments, for a | 157 | // the files of a window with their hunks and their comments, for a | ||
| 158 | // placeholder of the page. A base or a head that is not the one of the diff | 158 | // placeholder of the page. A base or a head that is not the one of the diff | ||
| 159 | // any more answers 409: the page says to reload. | 159 | // any more answers 409: the page says to reload. | ||
| 160 | - | // | |||
| 161 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here | |||
| 162 | func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 160 | func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 163 | n, err := mrNumber(r) | 161 | n, err := mrNumber(r) | ||
| 164 | if err != nil { | 162 | if err != nil { | ||
M internal/adapters/web/mr_post.go +0 -2
| @@ -295,8 +295,6 @@ func commentID(s string) int64 { | |||||
| 295 | } | 295 | } | ||
| 296 | 296 | ||||
| 297 | // headID parses a commit id of a form; the empty string is the zero id. | 297 | // headID parses a commit id of a form; the empty string is the zero id. | ||
| 298 | - | // | |||
| 299 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here | |||
| 300 | func headID(s string) (domain.ObjectID, error) { | 298 | func headID(s string) (domain.ObjectID, error) { | ||
| 301 | if s == "" { | 299 | if s == "" { | ||
| 302 | return domain.ObjectID{}, nil | 300 | return domain.ObjectID{}, nil | ||
M internal/adapters/web/mr_test.go +4 -4
| @@ -63,7 +63,7 @@ func TestMergeBoxModes(t *testing.T) { | |||||
| 63 | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | 63 | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | ||
| 64 | s := &Server{} | 64 | s := &Server{} | ||
| 65 | 65 | ||||
| 66 | - | behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main")) | 66 | + | behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main")) |
| 67 | require.Len(t, behind.Modes, 3) | 67 | require.Len(t, behind.Modes, 3) | ||
| 68 | assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind") | 68 | assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind") | ||
| 69 | assert.NotEmpty(t, behind.Modes[0].Reason) | 69 | assert.NotEmpty(t, behind.Modes[0].Reason) | ||
| @@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) { | |||||
| 73 | assert.True(t, behind.CanMerge()) | 73 | assert.True(t, behind.CanMerge()) | ||
| 74 | assert.True(t, behind.Signed) | 74 | assert.True(t, behind.Signed) | ||
| 75 | 75 | ||||
| 76 | - | conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main")) | 76 | + | conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main")) |
| 77 | assert.False(t, conflict.CanMerge()) | 77 | assert.False(t, conflict.CanMerge()) | ||
| 78 | assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled") | 78 | assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled") | ||
| 79 | assert.Equal(t, 0, conflict.Checked()) | 79 | assert.Equal(t, 0, conflict.Checked()) | ||
| @@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) { | |||||
| 84 | func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) { | 84 | func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) { | ||
| 85 | t.Parallel() | 85 | t.Parallel() | ||
| 86 | d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)} | 86 | d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)} | ||
| 87 | - | b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) | 87 | + | b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) |
| 88 | assert.False(t, b.Signed, "no commit is created, nothing to explain") | 88 | assert.False(t, b.Signed, "no commit is created, nothing to explain") | ||
| 89 | assert.Len(t, b.Modes, 1) | 89 | assert.Len(t, b.Modes, 1) | ||
| 90 | } | 90 | } | ||
| @@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) { | |||||
| 94 | key := mocks.NewSigningKeyService(t) | 94 | key := mocks.NewSigningKeyService(t) | ||
| 95 | key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}}) | 95 | key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}}) | ||
| 96 | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | 96 | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | ||
| 97 | - | b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main")) | 97 | + | b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main")) |
| 98 | assert.Equal(t, "SHA256:abc", b.Fingerprint) | 98 | assert.Equal(t, "SHA256:abc", b.Fingerprint) | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
M internal/adapters/web/paths.go +0 -2
| @@ -33,8 +33,6 @@ type pathList struct { | |||||
| 33 | // an ETag that makes it a 304 (answered before the list is loaded, so it never | 33 | // an ETag that makes it a 304 (answered before the list is loaded, so it never | ||
| 34 | // waits for a build); the owner's copy is private and never stored. The ETag | 34 | // waits for a build); the owner's copy is private and never stored. The ETag | ||
| 35 | // differs for the gzip and the plain variant. | 35 | // differs for the gzip and the plain variant. | ||
| 36 | - | // | |||
| 37 | - | //hexcheck:ignore suspicious-business-logic-in-adapter serves a cached list after the use case authorized it; the headers are about the response, no rule is decided here | |||
| 38 | func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | 36 | func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | ||
| 39 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | 37 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | ||
| 40 | defer cancel() | 38 | defer cancel() | ||
M internal/adapters/web/raw.go +0 -2
| @@ -31,8 +31,6 @@ const rawCSP = "default-src 'none'; sandbox" | |||||
| 31 | // image types (by their bytes, never SVG) as images, and anything else as an | 31 | // image types (by their bytes, never SVG) as images, and anything else as an | ||
| 32 | // attachment of unknown type. A file is never served as HTML or SVG, so what | 32 | // attachment of unknown type. A file is never served as HTML or SVG, so what | ||
| 33 | // a repository holds cannot run in the origin of the forge. | 33 | // a repository holds cannot run in the origin of the forge. | ||
| 34 | - | // | |||
| 35 | - | //hexcheck:ignore suspicious-business-logic-in-adapter streams a file the use case opened; the headers are about the response, no rule is decided here | |||
| 36 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | 34 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | ||
| 37 | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | 35 | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | ||
| 38 | defer cancel() | 36 | defer cancel() | ||
M internal/adapters/web/refs.go +0 -4
| @@ -15,8 +15,6 @@ import ( | |||||
| 15 | // that is the short name of a branch or tag wins. A branch wins over a tag of | 15 | // that is the short name of a branch or tag wins. A branch wins over a tag of | ||
| 16 | // the same name. rest has no leading slash and is "" when the path is the ref | 16 | // the same name. rest has no leading slash and is "" when the path is the ref | ||
| 17 | // alone. ok is false when no ref matches; the caller may then try a commit id. | 17 | // alone. ok is false when no ref matches; the caller may then try a commit id. | ||
| 18 | - | // | |||
| 19 | - | //hexcheck:ignore suspicious-business-logic-in-adapter finds the ref a URL path starts with; URL routing, no rule is decided here | |||
| 20 | func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) { | 18 | func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) { | ||
| 21 | names := refsByName(refs) | 19 | names := refsByName(refs) | ||
| 22 | p = strings.Trim(p, "/") | 20 | p = strings.Trim(p, "/") | ||
| @@ -97,8 +95,6 @@ func (t target) commitID() string { | |||||
| 97 | // resolveCommit sets the full id of a target named by an abbreviated commit id, | 95 | // resolveCommit sets the full id of a target named by an abbreviated commit id, | ||
| 98 | // with one ResolveRevision. The page renders without it (it only lacks the file | 96 | // with one ResolveRevision. The page renders without it (it only lacks the file | ||
| 99 | // finder), so a failure is not one. | 97 | // finder), so a failure is not one. | ||
| 100 | - | // | |||
| 101 | - | //hexcheck:ignore suspicious-business-logic-in-adapter learns the full id of the commit a URL names for the finder links; no rule is decided here | |||
| 102 | func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) { | 98 | func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) { | ||
| 103 | if !t.detached || isFullID(t.rev) { | 99 | if !t.detached || isFullID(t.rev) { | ||
| 104 | return | 100 | return | ||
M internal/adapters/web/repo.go +3 -7
| @@ -44,16 +44,12 @@ func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.Repo | |||||
| 44 | 44 | ||||
| 45 | // repoRecord reads the repository record only, without Git: its name, | 45 | // repoRecord reads the repository record only, without Git: its name, | ||
| 46 | // visibility and settings (the defaults when it has none). | 46 | // visibility and settings (the defaults when it has none). | ||
| 47 | - | // | |||
| 48 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule | |||
| 49 | func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) { | 47 | func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) { | ||
| 50 | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | 48 | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | ||
| 51 | if err != nil { | 49 | if err != nil { | ||
| 52 | return domain.Repo{}, err | 50 | return domain.Repo{}, err | ||
| 53 | } | 51 | } | ||
| 54 | - | if repo.Settings.IsZero() { | 52 | + | repo.Settings = repo.EffectiveSettings() |
| 55 | - | repo.Settings = domain.DefaultRepoSettings() | |||
| 56 | - | } | |||
| 57 | return repo, nil | 53 | return repo, nil | ||
| 58 | } | 54 | } | ||
| 59 | 55 | ||||
| @@ -132,7 +128,7 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string { | |||||
| 132 | // switcher on the head ref and the ":" targets. The caller sets the title, | 128 | // switcher on the head ref and the ":" targets. The caller sets the title, | ||
| 133 | // the crumbs, the status and the rail. | 129 | // the crumbs, the status and the rail. | ||
| 134 | // | 130 | // | ||
| 135 | - | //hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here | 131 | + | //hexcheck:ignore suspicious-business-logic-in-adapter renders repository navigation; the issues tab uses the domain IssuesEnabled predicate |
| 136 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | 132 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | ||
| 137 | l := s.base() | 133 | l := s.base() | ||
| 138 | l.Repo = d.name() | 134 | l.Repo = d.name() | ||
| @@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||||
| 157 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | 153 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | ||
| 158 | l.Sections = append(l.Sections, mrs) | 154 | l.Sections = append(l.Sections, mrs) | ||
| 159 | } | 155 | } | ||
| 160 | - | if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff { | 156 | + | if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() { |
| 161 | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | 157 | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | ||
| 162 | } | 158 | } | ||
| 163 | l.Sections = s.withActions(l.Sections, d.name(), section) | 159 | l.Sections = s.withActions(l.Sections, d.name(), section) | ||
M internal/adapters/web/tree.go +0 -2
| @@ -72,8 +72,6 @@ func (p TreePage) Unit() string { | |||||
| 72 | 72 | ||||
| 73 | // tree lists a directory of a ref, with the last commit of each entry. With | 73 | // tree lists a directory of a ref, with the last commit of each entry. With | ||
| 74 | // ?rows it answers the entries alone, for a directory opened in place. | 74 | // ?rows it answers the entries alone, for a directory opened in place. | ||
| 75 | - | // | |||
| 76 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here | |||
| 77 | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | 75 | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | ||
| 78 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | 76 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | ||
| 79 | if err != nil { | 77 | if err != nil { | ||
M internal/adapters/webauthn/webauthn.go +0 -2
| @@ -137,8 +137,6 @@ func (v *Verifier) BeginLogin() ([]byte, []byte, error) { | |||||
| 137 | 137 | ||||
| 138 | // FinishLogin implements ports.PasskeyVerifier. The library compares the user | 138 | // FinishLogin implements ports.PasskeyVerifier. The library compares the user | ||
| 139 | // handle of the response with userHandle. | 139 | // handle of the response with userHandle. | ||
| 140 | - | // | |||
| 141 | - | //hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; it decides nothing: the use case refuses a counter that does not advance | |||
| 142 | func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) { | 140 | func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) { | ||
| 143 | var session gowebauthn.SessionData | 141 | var session gowebauthn.SessionData | ||
| 144 | if err := json.Unmarshal(ceremony, &session); err != nil { | 142 | if err := json.Unmarshal(ceremony, &session); err != nil { | ||
M internal/app/forge.go +3 -8
| @@ -151,8 +151,7 @@ type forge struct { | |||||
| 151 | CI *usecase.CI | 151 | CI *usecase.CI | ||
| 152 | // Settings are the secrets, variables and environments of CI. | 152 | // Settings are the secrets, variables and environments of CI. | ||
| 153 | Settings *usecase.CISettings | 153 | Settings *usecase.CISettings | ||
| 154 | - | // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets | 154 | + | // OIDC is the provider of ID tokens, disabled without a secrets key. |
| 155 | - | // key. | |||
| 156 | OIDC *usecase.OIDC | 155 | OIDC *usecase.OIDC | ||
| 157 | API *rpc.API | 156 | API *rpc.API | ||
| 158 | Bus ports.EventBus | 157 | Bus ports.EventBus | ||
| @@ -313,7 +312,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 313 | opts.ci(&ciCfg) | 312 | opts.ci(&ciCfg) | ||
| 314 | } | 313 | } | ||
| 315 | f.CI = usecase.NewCI(ctx, ciCfg) | 314 | f.CI = usecase.NewCI(ctx, ciCfg) | ||
| 316 | - | f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI()) | 315 | + | f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI()) |
| 317 | f.Repos.WithCIRunCleaner(f.CI) | 316 | f.Repos.WithCIRunCleaner(f.CI) | ||
| 318 | f.CI.Start() | 317 | f.CI.Start() | ||
| 319 | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | 318 | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | ||
| @@ -394,10 +393,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 394 | if f.Passkeys != nil { | 393 | if f.Passkeys != nil { | ||
| 395 | apiPasskeys = f.Passkeys | 394 | apiPasskeys = f.Passkeys | ||
| 396 | } | 395 | } | ||
| 397 | - | var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys | |||
| 398 | - | if f.OIDC != nil { | |||
| 399 | - | apiOIDC = f.OIDC | |||
| 400 | - | } | |||
| 401 | var jobArtifacts ports.JobArtifactService | 396 | var jobArtifacts ports.JobArtifactService | ||
| 402 | var artifacts ports.ArtifactService | 397 | var artifacts ports.ArtifactService | ||
| 403 | if cfg.CIArtifacts { | 398 | if cfg.CIArtifacts { | ||
| @@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 421 | Secrets: f.Settings, | 416 | Secrets: f.Settings, | ||
| 422 | Variables: f.Settings, | 417 | Variables: f.Settings, | ||
| 423 | Environments: f.Settings, | 418 | Environments: f.Settings, | ||
| 424 | - | OIDCKeys: apiOIDC, | 419 | + | OIDCKeys: f.OIDC, |
| 425 | JobArtifacts: jobArtifacts, | 420 | JobArtifacts: jobArtifacts, | ||
| 426 | Artifacts: artifacts, | 421 | Artifacts: artifacts, | ||
| 427 | Version: serverVersion(), | 422 | Version: serverVersion(), | ||
A internal/domain/budget.go +85 -0
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import "fmt" | |||
| 6 | + | ||||
| 7 | + | // ValidateCommitObjects checks a request before any repository I/O. max is the | |||
| 8 | + | // caller's commit-count limit. | |||
| 9 | + | func ValidateCommitObjects(ids []ObjectID, max int) error { | |||
| 10 | + | if len(ids) > max { | |||
| 11 | + | return fmt.Errorf("%w: %d commit objects asked, at most %d", ErrInvalid, len(ids), max) | |||
| 12 | + | } | |||
| 13 | + | for _, id := range ids { | |||
| 14 | + | if id.IsZero() { | |||
| 15 | + | return fmt.Errorf("%w: empty commit id", ErrInvalidObjectID) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | return nil | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // CommitObjectBudget bounds a batch of raw commits. Its zero value is ready to | |||
| 22 | + | // use. Admission is a preview; Charge records only successfully read content. | |||
| 23 | + | type CommitObjectBudget struct { | |||
| 24 | + | total int64 | |||
| 25 | + | seen map[ObjectID]bool | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (b *CommitObjectBudget) Seen(id ObjectID) bool { return b.seen[id] } | |||
| 29 | + | ||||
| 30 | + | func (b *CommitObjectBudget) Admit(id ObjectID, commit bool, size int64) (take, stop bool) { | |||
| 31 | + | if b.Seen(id) || !commit || size < 0 || size > MaxCommitObjectBytes { | |||
| 32 | + | return false, false | |||
| 33 | + | } | |||
| 34 | + | if size > MaxCommitObjectsBytes-b.total { | |||
| 35 | + | return false, true | |||
| 36 | + | } | |||
| 37 | + | return true, false | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (b *CommitObjectBudget) Charge(id ObjectID, size int64) { | |||
| 41 | + | if b.seen == nil { | |||
| 42 | + | b.seen = make(map[ObjectID]bool) | |||
| 43 | + | } | |||
| 44 | + | b.seen[id] = true | |||
| 45 | + | b.total += size | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // DiffBudget owns file selection and retained hunk accounting. Truncated files | |||
| 49 | + | // consume no budget; streaming readers may use a copy while attempting a file. | |||
| 50 | + | type DiffBudget struct { | |||
| 51 | + | limits DiffLimits | |||
| 52 | + | linesLeft, bytesLeft int | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func NewDiffBudget(limits DiffLimits) DiffBudget { | |||
| 56 | + | return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes} | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) { | |||
| 60 | + | kept = files | |||
| 61 | + | if b.limits.MaxFiles > 0 && kept > b.limits.MaxFiles { | |||
| 62 | + | kept = b.limits.MaxFiles | |||
| 63 | + | truncated = true | |||
| 64 | + | } | |||
| 65 | + | from, to = max(b.limits.From, 0), kept | |||
| 66 | + | if b.limits.To > 0 { | |||
| 67 | + | to = min(b.limits.To, kept) | |||
| 68 | + | } | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 } | |||
| 73 | + | ||||
| 74 | + | func (b DiffBudget) WantsHunks(binary bool, n int) bool { | |||
| 75 | + | return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0 | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) } | |||
| 79 | + | ||||
| 80 | + | // ChargeLine accounts for one decoded hunk line and reports whether it fits. | |||
| 81 | + | func (b *DiffBudget) ChargeLine(bytes int) bool { | |||
| 82 | + | b.linesLeft-- | |||
| 83 | + | b.bytesLeft -= bytes | |||
| 84 | + | return b.linesLeft >= 0 && b.bytesLeft >= 0 | |||
| 85 | + | } | |||
A internal/domain/budget_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func budgetID(t *testing.T, s string) ObjectID { | |||
| 11 | + | t.Helper() | |||
| 12 | + | id, err := ParseObjectID(s) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | return id | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestValidateCommitObjects(t *testing.T) { | |||
| 20 | + | id := budgetID(t, "1111111111111111111111111111111111111111") | |||
| 21 | + | for _, tt := range []struct { | |||
| 22 | + | name string | |||
| 23 | + | ids []ObjectID | |||
| 24 | + | max int | |||
| 25 | + | want error | |||
| 26 | + | }{ | |||
| 27 | + | {"empty", nil, 1, nil}, {"boundary", []ObjectID{id}, 1, nil}, | |||
| 28 | + | {"too many", []ObjectID{id, id}, 1, ErrInvalid}, {"zero", []ObjectID{{}}, 1, ErrInvalidObjectID}, | |||
| 29 | + | } { | |||
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | + | if err := ValidateCommitObjects(tt.ids, tt.max); !errors.Is(err, tt.want) { | |||
| 32 | + | t.Fatalf("got %v, want %v", err, tt.want) | |||
| 33 | + | } | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestCommitObjectBudget(t *testing.T) { | |||
| 39 | + | id := budgetID(t, "1111111111111111111111111111111111111111") | |||
| 40 | + | for _, tt := range []struct { | |||
| 41 | + | name string | |||
| 42 | + | commit bool | |||
| 43 | + | size, total int64 | |||
| 44 | + | seen, take, stop bool | |||
| 45 | + | }{ | |||
| 46 | + | {"non commit", false, 1, 0, false, false, false}, | |||
| 47 | + | {"oversize", true, MaxCommitObjectBytes + 1, 0, false, false, false}, | |||
| 48 | + | {"exact object", true, MaxCommitObjectBytes, 0, false, true, false}, | |||
| 49 | + | {"exact total", true, 1, MaxCommitObjectsBytes - 1, false, true, false}, | |||
| 50 | + | {"over total", true, 2, MaxCommitObjectsBytes - 1, false, false, true}, | |||
| 51 | + | {"duplicate", true, 1, MaxCommitObjectsBytes, true, false, false}, | |||
| 52 | + | {"negative", true, -1, 0, false, false, false}, | |||
| 53 | + | } { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | b := CommitObjectBudget{total: tt.total} | |||
| 56 | + | if tt.seen { | |||
| 57 | + | b.seen = map[ObjectID]bool{id: true} | |||
| 58 | + | } | |||
| 59 | + | take, stop := b.Admit(id, tt.commit, tt.size) | |||
| 60 | + | if take != tt.take || stop != tt.stop { | |||
| 61 | + | t.Fatalf("got %v,%v", take, stop) | |||
| 62 | + | } | |||
| 63 | + | if take { | |||
| 64 | + | b.Charge(id, tt.size) | |||
| 65 | + | if !b.Seen(id) || b.total != tt.total+tt.size { | |||
| 66 | + | t.Fatal("charge not recorded") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestDiffBudgetWindow(t *testing.T) { | |||
| 74 | + | for _, tt := range []struct { | |||
| 75 | + | name string | |||
| 76 | + | limits DiffLimits | |||
| 77 | + | kept, from, to int | |||
| 78 | + | truncated bool | |||
| 79 | + | }{ | |||
| 80 | + | {"unlimited", DiffLimits{}, 5, 0, 5, false}, | |||
| 81 | + | {"cap", DiffLimits{MaxFiles: 3}, 3, 0, 3, true}, | |||
| 82 | + | {"window", DiffLimits{From: 1, To: 4}, 5, 1, 4, false}, | |||
| 83 | + | {"clamped", DiffLimits{From: -1, To: 9, MaxFiles: 3}, 3, 0, 3, true}, | |||
| 84 | + | {"past end", DiffLimits{From: 8}, 5, 8, 5, false}, | |||
| 85 | + | {"reversed", DiffLimits{From: 4, To: 2}, 5, 4, 2, false}, | |||
| 86 | + | } { | |||
| 87 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 88 | + | b := NewDiffBudget(tt.limits) | |||
| 89 | + | k, f, to, tr := b.Window(5) | |||
| 90 | + | if k != tt.kept || f != tt.from || to != tt.to || tr != tt.truncated { | |||
| 91 | + | t.Fatalf("got %d,%d,%d,%v", k, f, to, tr) | |||
| 92 | + | } | |||
| 93 | + | }) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestDiffBudgetHunks(t *testing.T) { | |||
| 98 | + | for _, tt := range []struct { | |||
| 99 | + | name string | |||
| 100 | + | binary bool | |||
| 101 | + | n, lines, bytes int | |||
| 102 | + | want bool | |||
| 103 | + | }{ | |||
| 104 | + | {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false}, | |||
| 105 | + | {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false}, | |||
| 106 | + | {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false}, | |||
| 107 | + | } { | |||
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 109 | + | b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes}) | |||
| 110 | + | if got := b.WantsHunks(tt.binary, tt.n); got != tt.want { | |||
| 111 | + | t.Fatalf("got %v", got) | |||
| 112 | + | } | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestDiffBudgetCharge(t *testing.T) { | |||
| 118 | + | for _, tt := range []struct { | |||
| 119 | + | name string | |||
| 120 | + | bytes int | |||
| 121 | + | fits bool | |||
| 122 | + | }{{"exact", 3, true}, {"over", 4, false}} { | |||
| 123 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 124 | + | b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3}) | |||
| 125 | + | before := b | |||
| 126 | + | if b.FileLines() != 1 { | |||
| 127 | + | t.Fatal("wrong file limit") | |||
| 128 | + | } | |||
| 129 | + | if b.ChargeLine(tt.bytes) != tt.fits { | |||
| 130 | + | t.Fatal("wrong charge result") | |||
| 131 | + | } | |||
| 132 | + | if b.ChargeLine(0) { | |||
| 133 | + | t.Fatal("line budget exceeded") | |||
| 134 | + | } | |||
| 135 | + | b = before | |||
| 136 | + | if !b.WantsHunks(false, 1) { | |||
| 137 | + | t.Fatal("discarded file consumed budget") | |||
| 138 | + | } | |||
| 139 | + | }) | |||
| 140 | + | } | |||
| 141 | + | } | |||
A internal/domain/ci_workflow_guard.go +123 -0
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import "fmt" | |||
| 6 | + | ||||
| 7 | + | // PushObjectType is the type of a peeled Git object. | |||
| 8 | + | type PushObjectType string | |||
| 9 | + | ||||
| 10 | + | // Peeled object types accepted by the push tree reader. | |||
| 11 | + | const ( | |||
| 12 | + | PushObjectCommit PushObjectType = "commit" | |||
| 13 | + | PushObjectTree PushObjectType = "tree" | |||
| 14 | + | PushObjectBlob PushObjectType = "blob" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // PushWalkBounds bounds the walk over the commits of a push. | |||
| 18 | + | type PushWalkBounds struct{ Commits, IDs int } | |||
| 19 | + | ||||
| 20 | + | // Bounds fail closed and count repeated parent IDs too. | |||
| 21 | + | const ( | |||
| 22 | + | MaxPushedCommits = 10000 | |||
| 23 | + | MaxPushedIDs = 4 * MaxPushedCommits | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // DefaultPushWalkBounds returns the job-token push walk limits. | |||
| 27 | + | func DefaultPushWalkBounds() PushWalkBounds { return PushWalkBounds{MaxPushedCommits, MaxPushedIDs} } | |||
| 28 | + | ||||
| 29 | + | // Subtree is the kind and identity of an entry at a tree path. | |||
| 30 | + | type Subtree struct { | |||
| 31 | + | Kind EntryKind | |||
| 32 | + | ID ObjectID | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // PushedCommit is a newly reachable commit with its ordered parents. | |||
| 36 | + | type PushedCommit struct { | |||
| 37 | + | ID ObjectID | |||
| 38 | + | Parents []ObjectID | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // HeadKey identifies the default branch head in PushSubtrees.Subtrees. | |||
| 42 | + | const HeadKey = "HEAD" | |||
| 43 | + | ||||
| 44 | + | // PushSubtrees contains endpoint types and entries keyed by original object ID | |||
| 45 | + | // (or HeadKey), and newly reachable commits in rev-list order. Absent entries | |||
| 46 | + | // mean there is no subtree at the requested path. | |||
| 47 | + | type PushSubtrees struct { | |||
| 48 | + | Ends map[string]PushObjectType | |||
| 49 | + | Commits []PushedCommit | |||
| 50 | + | Subtrees map[string]Subtree | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // WorkflowTreeChange is the first forbidden workflow-tree replacement. | |||
| 54 | + | // A zero Old denotes no workflow directory. | |||
| 55 | + | type WorkflowTreeChange struct{ Old, New ObjectID } | |||
| 56 | + | ||||
| 57 | + | // WorkflowChangeOf applies the job-token workflow policy. Every new commit must | |||
| 58 | + | // keep the workflows of at least one parent; then each ref must keep its old | |||
| 59 | + | // workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed. | |||
| 60 | + | // New commits are judged before ref ends, in their supplied order. Missing ends | |||
| 61 | + | // are a reader contract breach and fail closed with ErrInvalid. | |||
| 62 | + | func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) { | |||
| 63 | + | for _, up := range updates { | |||
| 64 | + | if up.IsDelete() { | |||
| 65 | + | continue | |||
| 66 | + | } | |||
| 67 | + | ids := []string{up.New} | |||
| 68 | + | if !up.IsCreate() { | |||
| 69 | + | ids = append(ids, up.Old) | |||
| 70 | + | } | |||
| 71 | + | for _, id := range ids { | |||
| 72 | + | if _, ok := t.Ends[id]; !ok { | |||
| 73 | + | return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | dirOf := func(key string) ObjectID { | |||
| 78 | + | s := t.Subtrees[key] | |||
| 79 | + | if s.Kind != EntryDirectory { | |||
| 80 | + | return ObjectID{} | |||
| 81 | + | } | |||
| 82 | + | return s.ID | |||
| 83 | + | } | |||
| 84 | + | for _, c := range t.Commits { | |||
| 85 | + | cur := dirOf(c.ID.String()) | |||
| 86 | + | if cur.IsZero() { | |||
| 87 | + | continue | |||
| 88 | + | } | |||
| 89 | + | var first ObjectID | |||
| 90 | + | same := false | |||
| 91 | + | for i, p := range c.Parents { | |||
| 92 | + | w := dirOf(p.String()) | |||
| 93 | + | if i == 0 { | |||
| 94 | + | first = w | |||
| 95 | + | } | |||
| 96 | + | if w == cur { | |||
| 97 | + | same = true | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | if !same { | |||
| 101 | + | return WorkflowTreeChange{first, cur}, true, nil | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | for _, up := range updates { | |||
| 105 | + | if up.IsDelete() || t.Ends[up.New] == PushObjectBlob { | |||
| 106 | + | continue | |||
| 107 | + | } | |||
| 108 | + | cur := dirOf(up.New) | |||
| 109 | + | if cur.IsZero() { | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | var base ObjectID | |||
| 113 | + | if up.IsCreate() { | |||
| 114 | + | base = dirOf(HeadKey) | |||
| 115 | + | } else if t.Ends[up.Old] != PushObjectBlob { | |||
| 116 | + | base = dirOf(up.Old) | |||
| 117 | + | } | |||
| 118 | + | if base != cur { | |||
| 119 | + | return WorkflowTreeChange{base, cur}, true, nil | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | return WorkflowTreeChange{}, false, nil | |||
| 123 | + | } | |||
A internal/domain/ci_workflow_guard_test.go +120 -0
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite/internal/domain" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestWorkflowChangeOf(t *testing.T) { | |||
| 15 | + | id := func(n int) domain.ObjectID { | |||
| 16 | + | v, err := domain.ParseObjectID(fmt.Sprintf("%040x", n)) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | return v | |||
| 19 | + | } | |||
| 20 | + | a, b, c, d := id(1), id(2), id(3), id(4) | |||
| 21 | + | x, y, z := id(11), id(12), id(13) | |||
| 22 | + | dir := func(v domain.ObjectID) domain.Subtree { return domain.Subtree{Kind: domain.EntryDirectory, ID: v} } | |||
| 23 | + | up := func(old, new domain.ObjectID) domain.RefUpdate { | |||
| 24 | + | o, n := old.String(), new.String() | |||
| 25 | + | if old.IsZero() { | |||
| 26 | + | o = domain.ZeroOID | |||
| 27 | + | } | |||
| 28 | + | if new.IsZero() { | |||
| 29 | + | n = domain.ZeroOID | |||
| 30 | + | } | |||
| 31 | + | return domain.RefUpdate{Old: o, New: n} | |||
| 32 | + | } | |||
| 33 | + | zero := domain.ObjectID{} | |||
| 34 | + | commit := func(v domain.ObjectID, parents ...domain.ObjectID) domain.PushedCommit { | |||
| 35 | + | return domain.PushedCommit{ID: v, Parents: parents} | |||
| 36 | + | } | |||
| 37 | + | type row struct { | |||
| 38 | + | name string | |||
| 39 | + | ups []domain.RefUpdate | |||
| 40 | + | commits []domain.PushedCommit | |||
| 41 | + | trees map[string]domain.Subtree | |||
| 42 | + | treeEnds []domain.ObjectID | |||
| 43 | + | blobs []domain.ObjectID | |||
| 44 | + | want domain.WorkflowTreeChange | |||
| 45 | + | found bool | |||
| 46 | + | missing bool | |||
| 47 | + | } | |||
| 48 | + | change := func(old, new domain.ObjectID) domain.WorkflowTreeChange { | |||
| 49 | + | return domain.WorkflowTreeChange{Old: old, New: new} | |||
| 50 | + | } | |||
| 51 | + | rows := []row{ | |||
| 52 | + | {name: "no updates"}, | |||
| 53 | + | {name: "delete", ups: []domain.RefUpdate{up(a, zero)}}, | |||
| 54 | + | {name: "new blob ignores even a misleading subtree", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{b}}, | |||
| 55 | + | {name: "old blob is not a directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{a}, want: change(zero, y), found: true}, | |||
| 56 | + | {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}}, | |||
| 57 | + | {name: "new ref same directory as HEAD", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(x)}}, | |||
| 58 | + | {name: "new ref old workflows", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(y)}, want: change(x, y), found: true}, | |||
| 59 | + | {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |||
| 60 | + | {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}}, | |||
| 61 | + | {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}}, | |||
| 62 | + | {name: "move back or remove one file from surviving directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, want: change(x, y), found: true}, | |||
| 63 | + | {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}}, | |||
| 64 | + | {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}}, | |||
| 65 | + | {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |||
| 66 | + | {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}}, | |||
| 67 | + | {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}}, | |||
| 68 | + | {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}}, | |||
| 69 | + | {name: "merge keeps second parent", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}}, | |||
| 70 | + | {name: "merge differs from all parents", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z)}, want: change(x, z), found: true}, | |||
| 71 | + | {name: "merge new side changes workflows", commits: []domain.PushedCommit{commit(d, a, c), commit(c, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z), d.String(): dir(x)}, want: change(y, z), found: true}, | |||
| 72 | + | {name: "merge adopts other parent refused by ref rule", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}, want: change(x, y), found: true}, | |||
| 73 | + | {name: "change then revert", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, b), commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(x)}, want: change(y, x), found: true}, | |||
| 74 | + | {name: "change below plain tip", commits: []domain.PushedCommit{commit(d, c), commit(c, b)}, trees: map[string]domain.Subtree{b.String(): dir(x), c.String(): dir(y), d.String(): dir(y)}, want: change(x, y), found: true}, | |||
| 75 | + | {name: "all new commits before any ref", ups: []domain.RefUpdate{up(a, b)}, commits: []domain.PushedCommit{commit(d, c)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true}, | |||
| 76 | + | {name: "first ref wins", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(x, y), found: true}, | |||
| 77 | + | {name: "plain ref then changed ref", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true}, | |||
| 78 | + | {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true}, | |||
| 79 | + | } | |||
| 80 | + | rows = append(rows, | |||
| 81 | + | row{name: "new tree end with workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |||
| 82 | + | row{name: "new tree end without workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}}, | |||
| 83 | + | row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}}, | |||
| 84 | + | ) | |||
| 85 | + | ||||
| 86 | + | for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} { | |||
| 87 | + | rows = append(rows, | |||
| 88 | + | row{name: string(kind) + " to directory same id", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): dir(x)}, want: change(zero, x), found: true}, | |||
| 89 | + | row{name: "directory to " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): {Kind: kind, ID: x}}}, | |||
| 90 | + | row{name: "same " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): {Kind: kind, ID: x}}}, | |||
| 91 | + | ) | |||
| 92 | + | } | |||
| 93 | + | rows = append(rows, row{name: "gitlink to symlink", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: domain.EntrySubmodule, ID: x}, b.String(): {Kind: domain.EntrySymlink, ID: y}}}) | |||
| 94 | + | for _, r := range rows { | |||
| 95 | + | t.Run(r.name, func(t *testing.T) { | |||
| 96 | + | data := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Commits: r.commits, Subtrees: r.trees} | |||
| 97 | + | if !r.missing { | |||
| 98 | + | for _, u := range r.ups { | |||
| 99 | + | for _, oid := range []string{u.Old, u.New} { | |||
| 100 | + | data.Ends[oid] = domain.PushObjectCommit | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | for _, tree := range r.treeEnds { | |||
| 105 | + | data.Ends[tree.String()] = domain.PushObjectTree | |||
| 106 | + | } | |||
| 107 | + | for _, b := range r.blobs { | |||
| 108 | + | data.Ends[b.String()] = domain.PushObjectBlob | |||
| 109 | + | } | |||
| 110 | + | got, found, err := domain.WorkflowChangeOf(r.ups, data) | |||
| 111 | + | if r.missing { | |||
| 112 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | require.NoError(t, err) | |||
| 116 | + | assert.Equal(t, r.found, found) | |||
| 117 | + | assert.Equal(t, r.want, got) | |||
| 118 | + | }) | |||
| 119 | + | } | |||
| 120 | + | } | |||
M internal/domain/mergerequest.go +9 -0
| @@ -569,3 +569,12 @@ func BoundBehind(n int) (count int, capped bool) { | |||||
| 569 | } | 569 | } | ||
| 570 | return n, false | 570 | return n, false | ||
| 571 | } | 571 | } | ||
| 572 | + | ||||
| 573 | + | // IssuesEnabled reports whether the repository exposes issue streams and pages. | |||
| 574 | + | func (s RepoSettings) IssuesEnabled() bool { return s.Issues.OrDefault() != IssuesOff } | |||
| 575 | + | ||||
| 576 | + | // ShowsIssueSubmissionReceipt reports whether submissions await public moderation. | |||
| 577 | + | func (s RepoSettings) ShowsIssueSubmissionReceipt() bool { return s.Issues.OrDefault() == IssuesPublic } | |||
| 578 | + | ||||
| 579 | + | // AcceptsAnonymousIssues reports whether anyone may submit an issue. | |||
| 580 | + | func (s RepoSettings) AcceptsAnonymousIssues() bool { return s.Issues.OrDefault() == IssuesPublic } | |||
M internal/domain/repo.go +8 -0
| @@ -92,3 +92,11 @@ func validTopic(t string) bool { | |||||
| 92 | } | 92 | } | ||
| 93 | return true | 93 | return true | ||
| 94 | } | 94 | } | ||
| 95 | + | ||||
| 96 | + | // EffectiveSettings returns the repository settings, using defaults for an unset record. | |||
| 97 | + | func (r Repo) EffectiveSettings() RepoSettings { | |||
| 98 | + | if r.Settings.IsZero() { | |||
| 99 | + | return DefaultRepoSettings() | |||
| 100 | + | } | |||
| 101 | + | return r.Settings | |||
| 102 | + | } | |||
M internal/domain/repo_test.go +34 -0
| @@ -60,3 +60,37 @@ func TestNormalizeTopics(t *testing.T) { | |||||
| 60 | }) | 60 | }) | ||
| 61 | } | 61 | } | ||
| 62 | } | 62 | } | ||
| 63 | + | ||||
| 64 | + | func TestRepoEffectiveSettings(t *testing.T) { | |||
| 65 | + | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeMode("squash")}, Issues: domain.IssuesPublic} | |||
| 66 | + | for _, tc := range []struct { | |||
| 67 | + | name string | |||
| 68 | + | settings, want domain.RepoSettings | |||
| 69 | + | }{ | |||
| 70 | + | {"unset", domain.RepoSettings{}, domain.DefaultRepoSettings()}, | |||
| 71 | + | {"custom", custom, custom}, | |||
| 72 | + | } { | |||
| 73 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 74 | + | repo := domain.Repo{Settings: tc.settings} | |||
| 75 | + | assert.Equal(t, tc.want, repo.EffectiveSettings()) | |||
| 76 | + | assert.Equal(t, tc.settings, repo.Settings) | |||
| 77 | + | }) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestRepoIssuePresentationRules(t *testing.T) { | |||
| 82 | + | for _, tc := range []struct { | |||
| 83 | + | mode domain.IssuesMode | |||
| 84 | + | enabled, receipt bool | |||
| 85 | + | }{ | |||
| 86 | + | {"", false, false}, {domain.IssuesOff, false, false}, | |||
| 87 | + | {domain.IssuesOwner, true, false}, {domain.IssuesPublic, true, true}, | |||
| 88 | + | } { | |||
| 89 | + | t.Run(string(tc.mode), func(t *testing.T) { | |||
| 90 | + | s := domain.RepoSettings{Issues: tc.mode} | |||
| 91 | + | assert.Equal(t, tc.enabled, s.IssuesEnabled()) | |||
| 92 | + | assert.Equal(t, tc.receipt, s.ShowsIssueSubmissionReceipt()) | |||
| 93 | + | assert.Equal(t, tc.receipt, s.AcceptsAnonymousIssues()) | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
A internal/domain/revision.go +54 -0
| @@ -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 | + | } | |||
M internal/ports/ci.go +41 -15
| @@ -407,21 +407,47 @@ type PushObjects struct { | |||||
| 407 | Alternates string | 407 | Alternates string | ||
| 408 | } | 408 | } | ||
| 409 | 409 | ||||
| 410 | - | // WorkflowGuard finds the changes of a push to the workflow files of a | 410 | + | // 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 the | 413 | + | type PushTreeReader interface { |
| 414 | - | // updates change, "" for none. Two checks, as GitHub judges the token of a | 414 | + | // 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 compared | 415 | + | // directory path without leading or trailing slash, e.g. |
| 416 | - | // with each of its parents (a root commit with nothing) and is a change when | 416 | + | // domain.WorkflowDir) in each object concerned. |
| 417 | - | // it has workflows that differ from every parent's; (2) the workflows a ref | 417 | + | // |
| 418 | - | // ends with must be its old ones or none for an update, and the default | 418 | + | // 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). A | 419 | + | // For every other update, Old (unless ZeroOID) and New are peeled |
| 420 | - | // workflows directory that disappears is no change, and a deleted ref changes | 420 | + | // (`<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 not | 421 | + | // 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 check | 422 | + | // 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 { | |||||
| 249 | Comments []domain.IssueComment | 249 | 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.MergeRequest | 268 | 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, newest | 273 | // 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.Commit | 275 | 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.RepoName | 376 | 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 | |||||
| 210 | return _c | 210 | return _c | ||
| 211 | } | 211 | } | ||
| 212 | 212 | ||||
| 213 | + | // Compare provides a mock function for the type MergeRequestService | |||
| 214 | + | func (_mock *MergeRequestService) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error) { | |||
| 215 | + | ret := _mock.Called(ctx, p, repo, target, source) | |||
| 216 | + | ||||
| 217 | + | if len(ret) == 0 { | |||
| 218 | + | panic("no return value specified for Compare") | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | var r0 ports.CompareResult | |||
| 222 | + | var r1 error | |||
| 223 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.CompareResult, error)); ok { | |||
| 224 | + | return returnFunc(ctx, p, repo, target, source) | |||
| 225 | + | } | |||
| 226 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.CompareResult); ok { | |||
| 227 | + | r0 = returnFunc(ctx, p, repo, target, source) | |||
| 228 | + | } else { | |||
| 229 | + | r0 = ret.Get(0).(ports.CompareResult) | |||
| 230 | + | } | |||
| 231 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok { | |||
| 232 | + | r1 = returnFunc(ctx, p, repo, target, source) | |||
| 233 | + | } else { | |||
| 234 | + | r1 = ret.Error(1) | |||
| 235 | + | } | |||
| 236 | + | return r0, r1 | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // MergeRequestService_Compare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Compare' | |||
| 240 | + | type MergeRequestService_Compare_Call struct { | |||
| 241 | + | *mock.Call | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // Compare is a helper method to define mock.On call | |||
| 245 | + | // - ctx context.Context | |||
| 246 | + | // - p domain.Principal | |||
| 247 | + | // - repo domain.RepoName | |||
| 248 | + | // - target domain.ObjectID | |||
| 249 | + | // - source domain.ObjectID | |||
| 250 | + | func (_e *MergeRequestService_Expecter) Compare(ctx any, p any, repo any, target any, source any) *MergeRequestService_Compare_Call { | |||
| 251 | + | return &MergeRequestService_Compare_Call{Call: _e.mock.On("Compare", ctx, p, repo, target, source)} | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (_c *MergeRequestService_Compare_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID)) *MergeRequestService_Compare_Call { | |||
| 255 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 256 | + | var arg0 context.Context | |||
| 257 | + | if args[0] != nil { | |||
| 258 | + | arg0 = args[0].(context.Context) | |||
| 259 | + | } | |||
| 260 | + | var arg1 domain.Principal | |||
| 261 | + | if args[1] != nil { | |||
| 262 | + | arg1 = args[1].(domain.Principal) | |||
| 263 | + | } | |||
| 264 | + | var arg2 domain.RepoName | |||
| 265 | + | if args[2] != nil { | |||
| 266 | + | arg2 = args[2].(domain.RepoName) | |||
| 267 | + | } | |||
| 268 | + | var arg3 domain.ObjectID | |||
| 269 | + | if args[3] != nil { | |||
| 270 | + | arg3 = args[3].(domain.ObjectID) | |||
| 271 | + | } | |||
| 272 | + | var arg4 domain.ObjectID | |||
| 273 | + | if args[4] != nil { | |||
| 274 | + | arg4 = args[4].(domain.ObjectID) | |||
| 275 | + | } | |||
| 276 | + | run( | |||
| 277 | + | arg0, | |||
| 278 | + | arg1, | |||
| 279 | + | arg2, | |||
| 280 | + | arg3, | |||
| 281 | + | arg4, | |||
| 282 | + | ) | |||
| 283 | + | }) | |||
| 284 | + | return _c | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func (_c *MergeRequestService_Compare_Call) Return(compareResult ports.CompareResult, err error) *MergeRequestService_Compare_Call { | |||
| 288 | + | _c.Call.Return(compareResult, err) | |||
| 289 | + | return _c | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func (_c *MergeRequestService_Compare_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error)) *MergeRequestService_Compare_Call { | |||
| 293 | + | _c.Call.Return(run) | |||
| 294 | + | return _c | |||
| 295 | + | } | |||
| 296 | + | ||||
| 213 | // CountOpen provides a mock function for the type MergeRequestService | 297 | // CountOpen provides a mock function for the type MergeRequestService | ||
| 214 | func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) { | 298 | func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) { | ||
| 215 | ret := _mock.Called(ctx, p, name) | 299 | ret := _mock.Called(ctx, p, name) | ||
| @@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte | |||||
| 1020 | return _c | 1104 | return _c | ||
| 1021 | } | 1105 | } | ||
| 1022 | 1106 | ||||
| 1107 | + | // PushSummary provides a mock function for the type MergeRequestService | |||
| 1108 | + | func (_mock *MergeRequestService) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error) { | |||
| 1109 | + | ret := _mock.Called(ctx, p, repo, oldHead, newTip) | |||
| 1110 | + | ||||
| 1111 | + | if len(ret) == 0 { | |||
| 1112 | + | panic("no return value specified for PushSummary") | |||
| 1113 | + | } | |||
| 1114 | + | ||||
| 1115 | + | var r0 ports.PushSummary | |||
| 1116 | + | var r1 error | |||
| 1117 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.PushSummary, error)); ok { | |||
| 1118 | + | return returnFunc(ctx, p, repo, oldHead, newTip) | |||
| 1119 | + | } | |||
| 1120 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.PushSummary); ok { | |||
| 1121 | + | r0 = returnFunc(ctx, p, repo, oldHead, newTip) | |||
| 1122 | + | } else { | |||
| 1123 | + | r0 = ret.Get(0).(ports.PushSummary) | |||
| 1124 | + | } | |||
| 1125 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok { | |||
| 1126 | + | r1 = returnFunc(ctx, p, repo, oldHead, newTip) | |||
| 1127 | + | } else { | |||
| 1128 | + | r1 = ret.Error(1) | |||
| 1129 | + | } | |||
| 1130 | + | return r0, r1 | |||
| 1131 | + | } | |||
| 1132 | + | ||||
| 1133 | + | // MergeRequestService_PushSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSummary' | |||
| 1134 | + | type MergeRequestService_PushSummary_Call struct { | |||
| 1135 | + | *mock.Call | |||
| 1136 | + | } | |||
| 1137 | + | ||||
| 1138 | + | // PushSummary is a helper method to define mock.On call | |||
| 1139 | + | // - ctx context.Context | |||
| 1140 | + | // - p domain.Principal | |||
| 1141 | + | // - repo domain.RepoName | |||
| 1142 | + | // - oldHead domain.ObjectID | |||
| 1143 | + | // - newTip domain.ObjectID | |||
| 1144 | + | func (_e *MergeRequestService_Expecter) PushSummary(ctx any, p any, repo any, oldHead any, newTip any) *MergeRequestService_PushSummary_Call { | |||
| 1145 | + | return &MergeRequestService_PushSummary_Call{Call: _e.mock.On("PushSummary", ctx, p, repo, oldHead, newTip)} | |||
| 1146 | + | } | |||
| 1147 | + | ||||
| 1148 | + | func (_c *MergeRequestService_PushSummary_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID)) *MergeRequestService_PushSummary_Call { | |||
| 1149 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1150 | + | var arg0 context.Context | |||
| 1151 | + | if args[0] != nil { | |||
| 1152 | + | arg0 = args[0].(context.Context) | |||
| 1153 | + | } | |||
| 1154 | + | var arg1 domain.Principal | |||
| 1155 | + | if args[1] != nil { | |||
| 1156 | + | arg1 = args[1].(domain.Principal) | |||
| 1157 | + | } | |||
| 1158 | + | var arg2 domain.RepoName | |||
| 1159 | + | if args[2] != nil { | |||
| 1160 | + | arg2 = args[2].(domain.RepoName) | |||
| 1161 | + | } | |||
| 1162 | + | var arg3 domain.ObjectID | |||
| 1163 | + | if args[3] != nil { | |||
| 1164 | + | arg3 = args[3].(domain.ObjectID) | |||
| 1165 | + | } | |||
| 1166 | + | var arg4 domain.ObjectID | |||
| 1167 | + | if args[4] != nil { | |||
| 1168 | + | arg4 = args[4].(domain.ObjectID) | |||
| 1169 | + | } | |||
| 1170 | + | run( | |||
| 1171 | + | arg0, | |||
| 1172 | + | arg1, | |||
| 1173 | + | arg2, | |||
| 1174 | + | arg3, | |||
| 1175 | + | arg4, | |||
| 1176 | + | ) | |||
| 1177 | + | }) | |||
| 1178 | + | return _c | |||
| 1179 | + | } | |||
| 1180 | + | ||||
| 1181 | + | func (_c *MergeRequestService_PushSummary_Call) Return(pushSummary ports.PushSummary, err error) *MergeRequestService_PushSummary_Call { | |||
| 1182 | + | _c.Call.Return(pushSummary, err) | |||
| 1183 | + | return _c | |||
| 1184 | + | } | |||
| 1185 | + | ||||
| 1186 | + | func (_c *MergeRequestService_PushSummary_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error)) *MergeRequestService_PushSummary_Call { | |||
| 1187 | + | _c.Call.Return(run) | |||
| 1188 | + | return _c | |||
| 1189 | + | } | |||
| 1190 | + | ||||
| 1023 | // ResolveComment provides a mock function for the type MergeRequestService | 1191 | // ResolveComment provides a mock function for the type MergeRequestService | ||
| 1024 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | 1192 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | ||
| 1025 | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | 1193 | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | ||
M internal/ports/mocks/OIDCKeyService.go +44 -0
| @@ -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 OIDCKeyService | 95 | // 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 | + | } | |||
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 | - | } | |||
M internal/ports/oidc.go +2 -0
| @@ -53,6 +53,8 @@ type OIDCRotation struct { | |||||
| 53 | // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC | 53 | // 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 { | |||||
| 109 | // MergeRequestService is the merge request use cases. *usecase.MergeRequests | 109 | // MergeRequestService is the merge request use cases. *usecase.MergeRequests | ||
| 110 | // implements it. | 110 | // implements it. | ||
| 111 | type MergeRequestService interface { | 111 | type MergeRequestService interface { | ||
| 112 | + | // Compare 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 { | |||||
| 106 | // read when an action of another repository is resolved for the run. Nil | 106 | // 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.TokenStore | 108 | 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 |
| 110 | // token endpoint, and the provider is not served. | 110 | // token endpoint, and the provider is not served. | ||
| 111 | OIDC *OIDC | 111 | OIDC *OIDC | ||
| 112 | // Settings resolves the variables and secrets of a job (CI-3). Nil gives | 112 | // 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 | + | } | |||
M internal/usecase/ci_oidc.go +3 -9
| @@ -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.c | 33 | 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.OIDC | 69 | o := c.cfg.OIDC | ||
| 76 | - | if o == nil { | 70 | + | if !o.Enabled() { |
| 77 | return "", domain.ErrNotFound | 71 | 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, | |||||
| 681 | // createRun stores a run. The run of an open merge request is stored under the | 681 | // 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.CIRunDraft | 695 | 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 error | 697 | 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 err | 699 | 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 | |||||
| 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.idToken | 285 | out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken | ||
| 286 | } | 286 | } | ||
| 287 | return out | 287 | 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 | |||||
| 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 nil | 577 | 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 nil | 614 | 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 them | 617 | 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 | |||||
| 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] = true | 76 | exists[name] = true | ||
| 77 | - | issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff | 77 | + | issuesOn[name] = repo.Settings.IssuesEnabled() |
| 78 | } | 78 | } | ||
| 79 | return nil | 79 | 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 | |||||
| 71 | if err != nil { | 71 | if err != nil { | ||
| 72 | return domain.Issue{}, err | 72 | return domain.Issue{}, err | ||
| 73 | } | 73 | } | ||
| 74 | - | if r.Settings.Issues.OrDefault() == domain.IssuesOff { | 74 | + | if !r.Settings.IssuesEnabled() { |
| 75 | return domain.Issue{}, domain.ErrNotFound | 75 | 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 | |||||
| 162 | } | 162 | } | ||
| 163 | return err | 163 | 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.ErrNotFound | 166 | return domain.ErrNotFound | ||
| 167 | } | 167 | } | ||
| 168 | return nil | 168 | 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 | |||||
| 59 | if err != nil { | 59 | if err != nil { | ||
| 60 | return domain.Repo{}, err | 60 | return domain.Repo{}, err | ||
| 61 | } | 61 | } | ||
| 62 | - | if repo.Settings.Issues.OrDefault() == domain.IssuesOff { | 62 | + | if !repo.Settings.IssuesEnabled() { |
| 63 | return domain.Repo{}, domain.ErrNotFound | 63 | return domain.Repo{}, domain.ErrNotFound | ||
| 64 | } | 64 | } | ||
| 65 | return repo, nil | 65 | return repo, nil | ||
M internal/usecase/mergerequests.go +55 -5
| @@ -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 = 100 | 24 | + | const MaxMergeRequestCommits = ports.MaxMergeRequestCommits |
| 25 | 25 | ||||
| 26 | // maxWriteRetries bounds the retries of a write that loses a race with another | 26 | // 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, so | 27 | // 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{}, err | 547 | 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{}, err | 575 | return ports.MergeRequestDetail{}, err | ||
| 575 | } | 576 | } | ||
| 576 | verifyCommits(ctx, m.verifier, name, d.Commits) | 577 | verifyCommits(ctx, m.verifier, name, d.Commits) | ||
| 578 | + | if !d.LastReadID.IsZero() { | |||
| 579 | + | for i, c := range d.Commits { | |||
| 580 | + | if c.ID == d.LastReadID { | |||
| 581 | + | d.NewCommits = i | |||
| 582 | + | break | |||
| 583 | + | } | |||
| 584 | + | } | |||
| 585 | + | } | |||
| 577 | } | 586 | } | ||
| 578 | if opts.SinceLastRead && !d.LastReadID.IsZero() { | 587 | if opts.SinceLastRead && !d.LastReadID.IsZero() { | ||
| 579 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | 588 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | ||
| @@ -1046,10 +1055,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 1046 | } | 1055 | } | ||
| 1047 | return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err) | 1056 | return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err) | ||
| 1048 | } | 1057 | } | ||
| 1049 | - | settings := repo.Settings | 1058 | + | settings := repo.EffectiveSettings() |
| 1050 | - | if settings.IsZero() { | |||
| 1051 | - | settings = domain.DefaultRepoSettings() | |||
| 1052 | - | } | |||
| 1053 | if !settings.Allows(mode) { | 1059 | if !settings.Allows(mode) { | ||
| 1054 | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | 1060 | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | ||
| 1055 | } | 1061 | } | ||
| @@ -1438,3 +1444,47 @@ func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, num | |||||
| 1438 | } | 1444 | } | ||
| 1439 | return root, nil | 1445 | return root, nil | ||
| 1440 | } | 1446 | } | ||
| 1447 | + | ||||
| 1448 | + | // Compare lists at most ports.MaxCompareCommits source commits absent from target. Unlike bounded | |||
| 1449 | + | // history intersection, ancestry remains accurate even for deep histories. | |||
| 1450 | + | func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) { | |||
| 1451 | + | if _, err := m.readable(ctx, p, repo); err != nil { | |||
| 1452 | + | return ports.CompareResult{}, err | |||
| 1453 | + | } | |||
| 1454 | + | if target == source { | |||
| 1455 | + | return ports.CompareResult{Exact: true}, nil | |||
| 1456 | + | } | |||
| 1457 | + | ancestor, err := m.git.IsAncestor(ctx, repo, target, source) | |||
| 1458 | + | if err != nil { | |||
| 1459 | + | return ports.CompareResult{}, err | |||
| 1460 | + | } | |||
| 1461 | + | commits, more, err := m.git.ListRange(ctx, repo, target, source, ports.MaxCompareCommits) | |||
| 1462 | + | if err != nil { | |||
| 1463 | + | return ports.CompareResult{}, err | |||
| 1464 | + | } | |||
| 1465 | + | verifyCommits(ctx, m.verifier, repo, commits) | |||
| 1466 | + | return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil | |||
| 1467 | + | } | |||
| 1468 | + | ||||
| 1469 | + | // PushSummary detects rewrites by ancestry, not log ordering, and counts at | |||
| 1470 | + | // most ports.MaxPushSummaryCommits new commits. More distinguishes a longer range from a rewrite. | |||
| 1471 | + | func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) { | |||
| 1472 | + | if _, err := m.readable(ctx, p, repo); err != nil { | |||
| 1473 | + | return ports.PushSummary{}, err | |||
| 1474 | + | } | |||
| 1475 | + | if oldHead == newTip { | |||
| 1476 | + | return ports.PushSummary{}, nil | |||
| 1477 | + | } | |||
| 1478 | + | ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip) | |||
| 1479 | + | if err != nil { | |||
| 1480 | + | return ports.PushSummary{}, err | |||
| 1481 | + | } | |||
| 1482 | + | if !ancestor { | |||
| 1483 | + | return ports.PushSummary{Rewritten: true}, nil | |||
| 1484 | + | } | |||
| 1485 | + | commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, ports.MaxPushSummaryCommits) | |||
| 1486 | + | if err != nil { | |||
| 1487 | + | return ports.PushSummary{}, err | |||
| 1488 | + | } | |||
| 1489 | + | return ports.PushSummary{Count: len(commits), More: more}, nil | |||
| 1490 | + | } | |||
M internal/usecase/mergerequests_test.go +144 -0
| @@ -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 { | |||||
| 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 nil | 77 | + | // 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. A | 78 | + | // 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 server | 79 | // 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 this | 80 | // 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, nil | 86 | + | 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 whose | 180 | // 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.ErrUnauthenticated | 192 | 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 the | 268 | // 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) { | |||||
| 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 { | |||||
| 26 | auth ports.Revalidator | 26 | auth ports.Revalidator | ||
| 27 | fresh ports.RepoFreshener // nil: the reader needs no telling | 27 | fresh ports.RepoFreshener // nil: the reader needs no telling | ||
| 28 | ci ports.WorkflowTrigger // nil: no CI | 28 | 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 |
| 30 | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | 30 | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | ||
| 31 | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | 31 | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | ||
| 32 | } | 32 | } | ||
| @@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes { | |||||
| 73 | return u | 73 | return u | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | - | // WithWorkflowGuard lets Check tell whether a push changes workflow files, which | 76 | + | // 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 is | 77 | // 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 = g | 80 | + | u.trees = g |
| 81 | return u | 81 | 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 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) | |||
| 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
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | + | "strings" | |||
| 9 | "testing" | 10 | "testing" | ||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| @@ -440,16 +441,41 @@ func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.Re | |||||
| 440 | return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{}) | 441 | return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{}) | ||
| 441 | } | 442 | } | ||
| 442 | 443 | ||||
| 443 | - | type fakeGuard struct { | 444 | + | type fakePushTrees struct { |
| 444 | - | path string | 445 | + | path string |
| 445 | - | err error | 446 | + | err error |
| 446 | - | got []domain.RefUpdate | 447 | + | diffErr error |
| 447 | - | objs ports.PushObjects | 448 | + | got []domain.RefUpdate |
| 449 | + | objs ports.PushObjects | |||
| 450 | + | bounds domain.PushWalkBounds | |||
| 451 | + | subtreePath string | |||
| 452 | + | calls int | |||
| 453 | + | old, new domain.ObjectID | |||
| 454 | + | data *domain.PushSubtrees | |||
| 455 | + | change bool | |||
| 448 | } | 456 | } | ||
| 449 | 457 | ||||
| 450 | - | func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) { | 458 | + | func (g *fakePushTrees) PushSubtrees(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { |
| 451 | - | g.got, g.objs = ups, o | 459 | + | g.got, g.objs, g.subtreePath, g.bounds = ups, o, path, bounds |
| 452 | - | return g.path, g.err | 460 | + | if g.data != nil { |
| 461 | + | return *g.data, g.err | |||
| 462 | + | } | |||
| 463 | + | t := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Subtrees: map[string]domain.Subtree{}} | |||
| 464 | + | for _, up := range ups { | |||
| 465 | + | t.Ends[up.Old] = domain.PushObjectCommit | |||
| 466 | + | t.Ends[up.New] = domain.PushObjectCommit | |||
| 467 | + | if g.path != "" || g.change { | |||
| 468 | + | id, _ := domain.ParseObjectID(up.New) | |||
| 469 | + | t.Subtrees[up.New] = domain.Subtree{Kind: domain.EntryDirectory, ID: id} | |||
| 470 | + | } | |||
| 471 | + | } | |||
| 472 | + | return t, g.err | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func (g *fakePushTrees) FirstDifferentEntry(_ context.Context, _ domain.RepoName, _ ports.PushObjects, old, new domain.ObjectID) (string, error) { | |||
| 476 | + | g.calls++ | |||
| 477 | + | g.old, g.new = old, new | |||
| 478 | + | return strings.TrimPrefix(g.path, domain.WorkflowDir+"/"), g.diffErr | |||
| 453 | } | 479 | } | ||
| 454 | 480 | ||||
| 455 | // A push by the token of a job is judged on the workflows it changes, and by it | 481 | // A push by the token of a job is judged on the workflows it changes, and by it | ||
| @@ -464,10 +490,10 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||||
| 464 | job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}}) | 490 | job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}}) | ||
| 465 | readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp}) | 491 | readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp}) | ||
| 466 | 492 | ||||
| 467 | - | build := func(t *testing.T, g *fakeGuard) *pushFixture { | 493 | + | build := func(t *testing.T, g *fakePushTrees) *pushFixture { |
| 468 | f := newPushFixture(t) | 494 | f := newPushFixture(t) | ||
| 469 | if g != nil { | 495 | if g != nil { | ||
| 470 | - | f.pushes.WithWorkflowGuard(g) | 496 | + | f.pushes.WithPushTreeReader(g) |
| 471 | } | 497 | } | ||
| 472 | f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe() | 498 | f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe() | ||
| 473 | return f | 499 | return f | ||
| @@ -475,29 +501,65 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||||
| 475 | 501 | ||||
| 476 | t.Run("no workflow touched", func(t *testing.T) { | 502 | t.Run("no workflow touched", func(t *testing.T) { | ||
| 477 | t.Parallel() | 503 | t.Parallel() | ||
| 478 | - | g := &fakeGuard{} | 504 | + | g := &fakePushTrees{} |
| 479 | f := build(t, g) | 505 | f := build(t, g) | ||
| 480 | require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs)) | 506 | require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs)) | ||
| 481 | assert.Equal(t, ups, g.got, "every ref is judged") | 507 | assert.Equal(t, ups, g.got, "every ref is judged") | ||
| 482 | assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine") | 508 | assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine") | ||
| 509 | + | assert.Equal(t, domain.WorkflowDir, g.subtreePath) | |||
| 510 | + | assert.Equal(t, domain.DefaultPushWalkBounds(), g.bounds) | |||
| 511 | + | assert.Zero(t, g.calls) | |||
| 483 | }) | 512 | }) | ||
| 484 | t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) { | 513 | t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) { | ||
| 485 | t.Parallel() | 514 | t.Parallel() | ||
| 486 | - | f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"}) | 515 | + | f := build(t, &fakePushTrees{path: ".github/workflows/ci.yml"}) |
| 487 | err := f.pushes.Check(ctx, job, n, ups, objs) | 516 | err := f.pushes.Check(ctx, job, n, ups, objs) | ||
| 488 | require.ErrorIs(t, err, domain.ErrRejected) | 517 | require.ErrorIs(t, err, domain.ErrRejected) | ||
| 489 | assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission") | 518 | assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission") | ||
| 490 | }) | 519 | }) | ||
| 520 | + | t.Run("empty differing entry reports the directory and uses the domain pair once", func(t *testing.T) { | |||
| 521 | + | g := &fakePushTrees{change: true} | |||
| 522 | + | f := build(t, g) | |||
| 523 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |||
| 524 | + | require.ErrorIs(t, err, domain.ErrRejected) | |||
| 525 | + | assert.Contains(t, err.Error(), "workflow `.github/workflows`") | |||
| 526 | + | assert.Equal(t, 1, g.calls) | |||
| 527 | + | assert.True(t, g.old.IsZero()) | |||
| 528 | + | assert.Equal(t, oidB, g.new.String()) | |||
| 529 | + | }) | |||
| 530 | + | t.Run("diff invalid is rejected with reason", func(t *testing.T) { | |||
| 531 | + | g := &fakePushTrees{change: true, diffErr: fmt.Errorf("%w: path too long", domain.ErrInvalid)} | |||
| 532 | + | f := build(t, g) | |||
| 533 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |||
| 534 | + | require.ErrorIs(t, err, domain.ErrRejected) | |||
| 535 | + | assert.Contains(t, err.Error(), "path too long") | |||
| 536 | + | assert.Equal(t, 1, g.calls) | |||
| 537 | + | }) | |||
| 538 | + | t.Run("diff I/O failure retains its class", func(t *testing.T) { | |||
| 539 | + | boom := errors.New("diff broke") | |||
| 540 | + | g := &fakePushTrees{change: true, diffErr: boom} | |||
| 541 | + | f := build(t, g) | |||
| 542 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |||
| 543 | + | require.ErrorIs(t, err, boom) | |||
| 544 | + | assert.NotErrorIs(t, err, domain.ErrRejected) | |||
| 545 | + | }) | |||
| 546 | + | t.Run("missing endpoint is rejected before diff", func(t *testing.T) { | |||
| 547 | + | g := &fakePushTrees{data: &domain.PushSubtrees{}} | |||
| 548 | + | f := build(t, g) | |||
| 549 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |||
| 550 | + | require.ErrorIs(t, err, domain.ErrRejected) | |||
| 551 | + | assert.Zero(t, g.calls) | |||
| 552 | + | }) | |||
| 491 | t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) { | 553 | t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) { | ||
| 492 | t.Parallel() | 554 | t.Parallel() | ||
| 493 | - | f := build(t, &fakeGuard{err: errors.New("git broke")}) | 555 | + | f := build(t, &fakePushTrees{err: errors.New("git broke")}) |
| 494 | err := f.pushes.Check(ctx, job, n, ups, objs) | 556 | err := f.pushes.Check(ctx, job, n, ups, objs) | ||
| 495 | require.Error(t, err) | 557 | require.Error(t, err) | ||
| 496 | assert.NotErrorIs(t, err, domain.ErrRejected) | 558 | assert.NotErrorIs(t, err, domain.ErrRejected) | ||
| 497 | }) | 559 | }) | ||
| 498 | t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) { | 560 | t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) { | ||
| 499 | t.Parallel() | 561 | t.Parallel() | ||
| 500 | - | f := build(t, &fakeGuard{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)}) | 562 | + | f := build(t, &fakePushTrees{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)}) |
| 501 | err := f.pushes.Check(ctx, job, n, ups, objs) | 563 | err := f.pushes.Check(ctx, job, n, ups, objs) | ||
| 502 | require.ErrorIs(t, err, domain.ErrRejected) | 564 | require.ErrorIs(t, err, domain.ErrRejected) | ||
| 503 | assert.Contains(t, err.Error(), "more than 10000 commits") | 565 | assert.Contains(t, err.Error(), "more than 10000 commits") | ||
| @@ -509,19 +571,19 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||||
| 509 | }) | 571 | }) | ||
| 510 | t.Run("a job token without contents: write cannot push at all", func(t *testing.T) { | 572 | t.Run("a job token without contents: write cannot push at all", func(t *testing.T) { | ||
| 511 | t.Parallel() | 573 | t.Parallel() | ||
| 512 | - | f := build(t, &fakeGuard{}) | 574 | + | f := build(t, &fakePushTrees{}) |
| 513 | assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected) | 575 | assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected) | ||
| 514 | }) | 576 | }) | ||
| 515 | t.Run("the owner is not asked", func(t *testing.T) { | 577 | t.Run("the owner is not asked", func(t *testing.T) { | ||
| 516 | t.Parallel() | 578 | t.Parallel() | ||
| 517 | - | g := &fakeGuard{path: ".github/workflows/ci.yml"} | 579 | + | g := &fakePushTrees{path: ".github/workflows/ci.yml"} |
| 518 | f := build(t, g) | 580 | f := build(t, g) | ||
| 519 | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs)) | 581 | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs)) | ||
| 520 | assert.Nil(t, g.got) | 582 | assert.Nil(t, g.got) | ||
| 521 | }) | 583 | }) | ||
| 522 | t.Run("a job token never creates a repository on push", func(t *testing.T) { | 584 | t.Run("a job token never creates a repository on push", func(t *testing.T) { | ||
| 523 | t.Parallel() | 585 | t.Parallel() | ||
| 524 | - | f := build(t, &fakeGuard{}) | 586 | + | f := build(t, &fakePushTrees{}) |
| 525 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 587 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | ||
| 526 | assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound) | 588 | assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound) | ||
| 527 | }) | 589 | }) | ||
M internal/usecase/repos_write.go +1 -3
| @@ -165,9 +165,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||||
| 165 | if params.Visibility != nil { | 165 | if params.Visibility != nil { | ||
| 166 | repo.Visibility = *params.Visibility | 166 | repo.Visibility = *params.Visibility | ||
| 167 | } | 167 | } | ||
| 168 | - | if repo.Settings.IsZero() { | 168 | + | repo.Settings = repo.EffectiveSettings() |
| 169 | - | repo.Settings = domain.DefaultRepoSettings() | |||
| 170 | - | } | |||
| 171 | if params.AllowedMergeModes != nil { | 169 | if params.AllowedMergeModes != nil { | ||
| 172 | repo.Settings.AllowedMergeModes = *params.AllowedMergeModes | 170 | repo.Settings.AllowedMergeModes = *params.AllowedMergeModes | ||
| 173 | } | 171 | } | ||